Skip to content

feature: Add possibility to map nested attributes #29

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Draft
wants to merge 5 commits into
base: main
Choose a base branch
from
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
43 changes: 43 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -134,6 +134,49 @@ print(vars(public_user_info))
# {'full_name': 'John Cusack', 'profession': 'engineer'}
```

It is possible to easily specify nested field mappings. It can be done through "MapPath" object for your fields.
Please be aware that you can´t mix in one mapping string mapping with MapPath mapping. You don´t need to specify source class
from which mappings comes from as code will automatically use ```__name__``` of your source class.

This is supported only if you register mapping through ```add``` method.

```python
class BasicUser:
def __init__(self, name: str, city: str):
self.name = name
self.city = city

class AdvancedUser:
def __init__(self, user: BasicUser, job: str, salary: int):
self.user = user
self.job = job
self.salary = salary

mapper.add(
AdvancedUser,
BasicUser,
fields_mapping={
"name": MapPath("user.name"),
"city": MapPath("user.city"),
}
)

user = BasicUser(
name="John",
city="USA"
)
advanced_user = AdvancedUser(
user = user,
job = "Engineer",
salary = 100
)

basic_user = mapper.map(advanced_user)
print(vars(basic_user))
# {'name': 'John', 'city': 'USA'}
```


## Disable Deepcopy
By default, py-automapper performs a recursive `copy.deepcopy()` call on all attributes when copying from source object into target class instance.
This makes sure that changes in the attributes of the source do not affect the target and vice versa.
Expand Down
1 change: 1 addition & 0 deletions automapper/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@
)
from .mapper import Mapper
from .mapper_initializer import create_mapper
from .path_mapper import MapPath

# Global mapper
mapper = create_mapper()
10 changes: 10 additions & 0 deletions automapper/custom_types.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
from typing import Any, Callable, Dict, Iterable, Optional, Type, TypeVar

from automapper.path_mapper import MapPath

# Custom Types
S = TypeVar("S")
T = TypeVar("T")
ClassifierFunction = Callable[[Type[T]], bool]
SpecFunction = Callable[[Type[T]], Iterable[str]]
FieldsMap = Optional[Dict[str | MapPath, Any]]
Copy link
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I have enabled Github Actions to run checks on your branch and it seems to be failing.
Maybe makes sense for now to put these back as these are only used in 1 file. Not very confident in these TypeVar types that I've created a while ago. Need to refresh my memory.

4 changes: 4 additions & 0 deletions automapper/exceptions.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,10 @@ class MappingError(Exception):
pass


class MapPathMissMatchError(Exception):
pass


class CircularReferenceError(Exception):
def __init__(self, *args: object) -> None:
super().__init__(
Expand Down
86 changes: 51 additions & 35 deletions automapper/mapper.py
Original file line number Diff line number Diff line change
@@ -1,35 +1,18 @@
import inspect
from copy import deepcopy
from typing import (
Any,
Callable,
Dict,
Generic,
Iterable,
Optional,
Set,
Tuple,
Type,
TypeVar,
Union,
cast,
overload,
)
from functools import reduce
from typing import Any, Dict, Generic, Iterable, Set, Tuple, Type, Union, cast, overload

from .custom_types import ClassifierFunction, FieldsMap, S, SpecFunction, T
from .exceptions import (
CircularReferenceError,
DuplicatedRegistrationError,
MapPathMissMatchError,
MappingError,
)
from .path_mapper import MapPath
from .utils import is_dictionary, is_enum, is_primitive, is_sequence, object_contains

# Custom Types
S = TypeVar("S")
T = TypeVar("T")
ClassifierFunction = Callable[[Type[T]], bool]
SpecFunction = Callable[[Type[T]], Iterable[str]]
FieldsMap = Optional[Dict[str, Any]]


def _try_get_field_value(
field_name: str, original_obj: Any, custom_mapping: FieldsMap
Expand Down Expand Up @@ -160,14 +143,28 @@ def add(

Raises:
DuplicatedRegistrationError: Same mapping for `source class` was added.
Only one mapping per source class can exist at a time for now.
You can specify target class manually using `mapper.to(target_cls)` method
or use `override` argument to replace existing mapping.
Only one mapping per source class can exist at a time for now.
You can specify target class manually using `mapper.to(target_cls)` method
or use `override` argument to replace existing mapping.
MapPathMissMatchError: When mixing `MapPath` with string mappings for a single mapping.
"""
if source_cls in self._mappings and not override:
raise DuplicatedRegistrationError(
f"source_cls {source_cls} was already added for mapping"
)

if fields_mapping and any(
isinstance(map_path, MapPath) for map_path in fields_mapping.values()
):
map_paths = fields_mapping.values()
if not all(isinstance(map_path, MapPath) for map_path in map_paths):
raise MapPathMissMatchError(
"It is not allowed to mix MapPath mappings with string mappings."
)
Copy link
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

why not?


for map_path in map_paths:
map_path.obj_prefix = source_cls.__name__

self._mappings[source_cls] = (target_cls, fields_mapping)

def map(
Expand Down Expand Up @@ -205,16 +202,23 @@ def map(

common_fields_mapping = fields_mapping
if target_cls_field_mappings:
# transform mapping if it's from source class field
common_fields_mapping = {
target_obj_field: (
getattr(obj, source_field[len(obj_type_prefix) :])
if isinstance(source_field, str)
and source_field.startswith(obj_type_prefix)
else source_field
)
for target_obj_field, source_field in target_cls_field_mappings.items()
}
# Transform mapping if it's from source class field
common_fields_mapping = {}

for target_obj_field, source_field in target_cls_field_mappings.items():
if isinstance(source_field, str) and source_field.startswith(
obj_type_prefix
):
common_fields_mapping[target_obj_field] = self._rgetter(
obj, source_field[len(obj_type_prefix) :]
)
Copy link
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

here I would just leave getattr, as it's simple and easy to understand.

elif isinstance(source_field, MapPath):
common_fields_mapping[target_obj_field] = self._rgetter(
obj, source_field
)
Copy link
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

then here we know that we need to use recursive getter.
Could be also resolve_map_path or get_by_map_path or something similar, little bit more descriptive.
You'll know that you pass in a source_field of type MapPath and this function will get you a value using this MapPath.

If the value not possible to retrieve, I would suggest this function raises an InvalidMapPathError. So this is something user may expect from our package. Currently the reduce function in _rgetter would fail with unknown error.

else:
common_fields_mapping[target_obj_field] = source_field

if fields_mapping:
common_fields_mapping = {
**common_fields_mapping,
Expand Down Expand Up @@ -344,6 +348,18 @@ def _map_common(

return cast(target_cls, target_cls(**mapped_values)) # type: ignore [valid-type]

@staticmethod
def _rgetter(obj: object, value: Any) -> Any:
"""Recursively resolves a value from an object.

If `value` is an instance of `MapPath`, it traverses the object's attributes recursively.
Otherwise, it retrieves the direct attribute from the object.
"""
if isinstance(value, MapPath):
return reduce(lambda o, attr: getattr(o, attr), value.attributes, obj)

return getattr(obj, value)

def to(self, target_cls: Type[T]) -> MappingWrapper[T]:
"""Specify `target class` to which map `source class` object.

Expand Down
29 changes: 29 additions & 0 deletions automapper/path_mapper.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
class MapPath:
"""Represents a recursive path to an object attribute using dot notation (e.g., ob.attribute.sub_attribute)."""

def __init__(self, path: str):
if "." not in path:
raise ValueError(f"Invalid path: '{path}' does not contain '.'")
self.path = path
self.attributes = path.split(".")
if not len(self.attributes) >= 1:
raise ValueError(
f"Invalid path: '{path}'. Can´t reference to object attribute."
)

self._obj_prefix: str | None = None
Copy link
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Is . really required? what if user just wants to map to source object property with different name?
E.g.

fields_mapping={
     "name": MapPath("user_name")    # where user_name is just a property in source object.
}


@property
def obj_prefix(self):
return self._obj_prefix

@obj_prefix.setter
def obj_prefix(self, src_cls_name: str) -> None:
"""Setter for obj_prefix."""
self._obj_prefix = src_cls_name

def __call__(self):
return self.attributes

def __repr__(self):
return f"MapPath({self.attributes})"
Copy link
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This looks a bit complex. I honestly thought this would be just:

@dataclass
class MapPath:
    path: str

and in the file named map_path.py

2 changes: 1 addition & 1 deletion poetry.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

95 changes: 95 additions & 0 deletions tests/test_path_mapper.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,95 @@
import pytest
from automapper import MapPath, mapper
from automapper.exceptions import MapPathMissMatchError


class BasicUser:
def __init__(self, name: str, city: str):
self.name = name
self.city = city

def __repr__(self):
return f"BasicUser(name={self.name}, city={self.city})"


class AdvancedUser:
def __init__(self, user: BasicUser, job: str, salary: int):
self.user = user
self.job = job
self.salary = salary


class TestMapPath:
"""Test suite for the MapPath class."""

def test_valid_map_path(self):
"""Test that MapPath correctly splits a valid path."""
path = MapPath("some.example.path")
assert path.path == "some.example.path"
assert path.attributes == ["some", "example", "path"]
assert (
path.obj_prefix is None
) # this is set by automatic in the process of mapping.

def test_invalid_map_path_missing_dot(self):
"""Test that MapPath raises ValueError for paths without a dot."""
with pytest.raises(
ValueError, match="Invalid path: 'singleword' does not contain '.'"
):
MapPath("singleword")

def test_callable_behavior(self):
"""Test that calling an instance returns the correct split attributes."""
path = MapPath("one.two.three")
assert path() == ["one", "two", "three"]

def test_repr(self):
"""Test that __repr__ returns the expected string representation."""
path = MapPath("foo.bar")
assert repr(path) == "MapPath(['foo', 'bar'])"


class TestAddMappingWithNestedObjectReference:
def test_use_registered_mapping_with_map_path(self):
try:
mapper.add(
AdvancedUser,
BasicUser,
fields_mapping={
"name": MapPath("user.name"),
"city": MapPath("user.city"),
},
)

user = BasicUser(name="John Malkovich", city="USA")
advanced_user = AdvancedUser(user=user, job="Engineer", salary=100)

mapped_basic_user: BasicUser = mapper.map(advanced_user)

assert mapped_basic_user.name == advanced_user.user.name
assert mapped_basic_user.city == advanced_user.user.city
finally:
mapper._mappings.clear()

def test_map_object_directly_without_adding_map_path_cant_be_resolved(self):
"""Mapping nested objects without adding registration rule should fail."""
try:
user = BasicUser(name="John Malkovich", city="USA")
advanced_user = AdvancedUser(user=user, job="Engineer", salary=100)

with pytest.raises(TypeError):
mapper.to(BasicUser).map(advanced_user)
finally:
mapper._mappings.clear()

def test_cant_add_mapping_with_mixed_map_path_and_string_mapping(self):
"""Cant mix MapPath for one field and another field be classic string mapping."""
with pytest.raises(MapPathMissMatchError):
mapper.add(
AdvancedUser,
BasicUser,
fields_mapping={
"name": "AdvancedUser.user.name",
"city": MapPath("AdvancedUser.user.city"),
},
)
Loading