-
Notifications
You must be signed in to change notification settings - Fork 13
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
base: main
Are you sure you want to change the base?
Changes from all commits
30600aa
9d8d4e7
2352b84
9280515
3009802
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
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]] | ||
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 | ||
|
@@ -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." | ||
) | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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( | ||
|
@@ -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) :] | ||
) | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. here I would just leave |
||
elif isinstance(source_field, MapPath): | ||
common_fields_mapping[target_obj_field] = self._rgetter( | ||
obj, source_field | ||
) | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. then here we know that we need to use recursive getter. If the value not possible to retrieve, I would suggest this function raises an |
||
else: | ||
common_fields_mapping[target_obj_field] = source_field | ||
|
||
if fields_mapping: | ||
common_fields_mapping = { | ||
**common_fields_mapping, | ||
|
@@ -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. | ||
|
||
|
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 | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Is 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})" | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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 |
Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.
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"), | ||
}, | ||
) |
There was a problem hiding this comment.
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.