From 30600aa35a9c6fdc1244c42682c1f5addd0fb9fa Mon Sep 17 00:00:00 2001 From: Jan Klecka Date: Wed, 5 Mar 2025 19:31:28 +0100 Subject: [PATCH 1/5] feat: Add possibility to map nested attributes --- README.md | 36 +++++++++++++++++++++++++++++++ automapper/mapper.py | 9 +++++++- poetry.lock | 2 +- tests/test_automapper_basics.py | 38 +++++++++++++++++++++++++++++++++ 4 files changed, 83 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index e5481fb..f52df29 100644 --- a/README.md +++ b/README.md @@ -134,6 +134,42 @@ print(vars(public_user_info)) # {'full_name': 'John Cusack', 'profession': 'engineer'} ``` +It is possible to easily specify nested field mappings. +```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": "AdvancedUser.user.name", + "city": "AdvancedUser.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. diff --git a/automapper/mapper.py b/automapper/mapper.py index ea1be9a..1a90362 100644 --- a/automapper/mapper.py +++ b/automapper/mapper.py @@ -1,5 +1,6 @@ import inspect from copy import deepcopy +from functools import reduce from typing import ( Any, Callable, @@ -208,7 +209,7 @@ def map( # transform mapping if it's from source class field common_fields_mapping = { target_obj_field: ( - getattr(obj, source_field[len(obj_type_prefix) :]) + self._rgetter(obj, source_field[len(obj_type_prefix) :]) if isinstance(source_field, str) and source_field.startswith(obj_type_prefix) else source_field @@ -344,6 +345,12 @@ def _map_common( return cast(target_cls, target_cls(**mapped_values)) # type: ignore [valid-type] + @staticmethod + def _rgetter(obj: object, value: Any) -> Any: + """Recursively go through chain of references.""" + attributes = value.split(".") + return reduce(lambda o, attr: getattr(o, attr), attributes, obj) + def to(self, target_cls: Type[T]) -> MappingWrapper[T]: """Specify `target class` to which map `source class` object. diff --git a/poetry.lock b/poetry.lock index 1d5259a..9a45f62 100644 --- a/poetry.lock +++ b/poetry.lock @@ -269,7 +269,7 @@ version = "0.19.2" description = "Easy async ORM for python, built with relations in mind" category = "dev" optional = false -python-versions = ">=3.7,<4.0" +python-versions = ">=3.8,<4.0" [package.dependencies] aiosqlite = ">=0.16.0,<0.18.0" diff --git a/tests/test_automapper_basics.py b/tests/test_automapper_basics.py index 02842ee..215949a 100644 --- a/tests/test_automapper_basics.py +++ b/tests/test_automapper_basics.py @@ -3,6 +3,22 @@ from automapper import mapper +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 UserInfo: def __init__(self, name: str, age: int, profession: str): self.name = name @@ -53,6 +69,28 @@ def test_map__field_with_same_name(): assert public_user_info.profession == "engineer" +def test_map__chained_attributes(): + try: + mapper.add( + AdvancedUser, + BasicUser, + fields_mapping={ + "name": "AdvancedUser.user.name", + "city": "AdvancedUser.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__field_with_different_name(): user_info = UserInfo("John Malkovich", 35, "engineer") public_user_info: PublicUserInfoDiff = mapper.to(PublicUserInfoDiff).map( From 9d8d4e700f6bfb0ef67078cd10e51c8bf5311fe1 Mon Sep 17 00:00:00 2001 From: Jan Klecka Date: Fri, 7 Mar 2025 19:46:41 +0100 Subject: [PATCH 2/5] add MapPath for complex mapping --- automapper/mapper.py | 50 ++++++++++++------- automapper/path_mapper.py | 22 +++++++++ tests/test_automapper_basics.py | 39 ++------------- tests/test_path_mapper.py | 85 +++++++++++++++++++++++++++++++++ 4 files changed, 142 insertions(+), 54 deletions(-) create mode 100644 automapper/path_mapper.py create mode 100644 tests/test_path_mapper.py diff --git a/automapper/mapper.py b/automapper/mapper.py index 1a90362..73297d2 100644 --- a/automapper/mapper.py +++ b/automapper/mapper.py @@ -22,6 +22,7 @@ DuplicatedRegistrationError, MappingError, ) +from .path_mapper import MapPath from .utils import is_dictionary, is_enum, is_primitive, is_sequence, object_contains # Custom Types @@ -29,7 +30,7 @@ T = TypeVar("T") ClassifierFunction = Callable[[Type[T]], bool] SpecFunction = Callable[[Type[T]], Iterable[str]] -FieldsMap = Optional[Dict[str, Any]] +FieldsMap = Optional[Dict[str | MapPath, Any]] def _try_get_field_value( @@ -206,21 +207,28 @@ 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: ( - self._rgetter(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) :] + ) + elif isinstance(source_field, MapPath): + common_fields_mapping[target_obj_field] = self._rgetter( + obj, source_field + ) + else: + common_fields_mapping[target_obj_field] = source_field + if fields_mapping: - common_fields_mapping = { - **common_fields_mapping, - **fields_mapping, - } # merge two dict into one, fields_mapping has priority + for key, value in fields_mapping.items(): + common_fields_mapping[key] = ( + value # Merge, with fields_mapping having priority + ) return self._map_common( obj, @@ -347,9 +355,15 @@ def _map_common( @staticmethod def _rgetter(obj: object, value: Any) -> Any: - """Recursively go through chain of references.""" - attributes = value.split(".") - return reduce(lambda o, attr: getattr(o, attr), attributes, obj) + """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. diff --git a/automapper/path_mapper.py b/automapper/path_mapper.py new file mode 100644 index 0000000..3fa615c --- /dev/null +++ b/automapper/path_mapper.py @@ -0,0 +1,22 @@ +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) >= 2: + raise ValueError( + f"Invalid path: '{path}'. Can´t reference to object attribute." + ) + + self.obj_prefix = self.attributes[0] + self.attributes = self.attributes[1:] + + def __call__(self): + return self.attributes + + def __repr__(self): + return f"MapPath({self.attributes})" diff --git a/tests/test_automapper_basics.py b/tests/test_automapper_basics.py index 215949a..0d1d54d 100644 --- a/tests/test_automapper_basics.py +++ b/tests/test_automapper_basics.py @@ -3,22 +3,6 @@ from automapper import mapper -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 UserInfo: def __init__(self, name: str, age: int, profession: str): self.name = name @@ -69,26 +53,9 @@ def test_map__field_with_same_name(): assert public_user_info.profession == "engineer" -def test_map__chained_attributes(): - try: - mapper.add( - AdvancedUser, - BasicUser, - fields_mapping={ - "name": "AdvancedUser.user.name", - "city": "AdvancedUser.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_mapping_of_nested_attributes_without_map_path_should_fail(): + """It tries to only do reference for the first attribute in the chain which doesnt exists.""" + ... def test_map__field_with_different_name(): diff --git a/tests/test_path_mapper.py b/tests/test_path_mapper.py new file mode 100644 index 0000000..5a95cf4 --- /dev/null +++ b/tests/test_path_mapper.py @@ -0,0 +1,85 @@ +import pytest +from automapper import mapper +from automapper.path_mapper import ( # Replace 'your_module' with the actual module name + MapPath, +) + + +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 == ["example", "path"] # obj_prefix is excluded + assert path.obj_prefix == "some" # obj_prefix is correctly assigned + + 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() == ["two", "three"] + + def test_repr(self): + """Test that __repr__ returns the expected string representation.""" + path = MapPath("foo.bar") + assert ( + repr(path) == "MapPath(['bar'])" + ) # Only attributes are shown, excluding obj_prefix + + +class TestMappingObjectAttributes: + def test_use_registered_mapping_with_map_path(self): + try: + mapper.add( + AdvancedUser, + BasicUser, + fields_mapping={ + "name": MapPath("AdvancedUser.user.name"), + "city": MapPath("AdvancedUser.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() From 2352b84f8304e07ae7e292bfaef88f7d5bfa2d5f Mon Sep 17 00:00:00 2001 From: Jan Klecka Date: Fri, 7 Mar 2025 20:58:21 +0100 Subject: [PATCH 3/5] Create MapPath for mapping nested objects --- README.md | 15 +++++++++---- automapper/__init__.py | 1 + automapper/custom_types.py | 10 +++++++++ automapper/exceptions.py | 4 ++++ automapper/mapper.py | 45 +++++++++++++++++--------------------- automapper/path_mapper.py | 15 +++++++++---- tests/test_path_mapper.py | 36 +++++++++++++++++++----------- 7 files changed, 80 insertions(+), 46 deletions(-) create mode 100644 automapper/custom_types.py diff --git a/README.md b/README.md index f52df29..d66b209 100644 --- a/README.md +++ b/README.md @@ -134,7 +134,12 @@ print(vars(public_user_info)) # {'full_name': 'John Cusack', 'profession': 'engineer'} ``` -It is possible to easily specify nested field mappings. +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): @@ -148,9 +153,11 @@ class AdvancedUser: self.salary = salary mapper.add( - AdvancedUser, BasicUser, fields_mapping={ - "name": "AdvancedUser.user.name", - "city": "AdvancedUser.user.city", + AdvancedUser, + BasicUser, + fields_mapping={ + "name": MapPath("user.name"), + "city": MapPath("user.city"), } ) diff --git a/automapper/__init__.py b/automapper/__init__.py index 834e420..a02c782 100644 --- a/automapper/__init__.py +++ b/automapper/__init__.py @@ -6,6 +6,7 @@ ) from .mapper import Mapper from .mapper_initializer import create_mapper +from .path_mapper import MapPath # Global mapper mapper = create_mapper() diff --git a/automapper/custom_types.py b/automapper/custom_types.py new file mode 100644 index 0000000..a0592bf --- /dev/null +++ b/automapper/custom_types.py @@ -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]] diff --git a/automapper/exceptions.py b/automapper/exceptions.py index b2d6595..8627de4 100644 --- a/automapper/exceptions.py +++ b/automapper/exceptions.py @@ -6,6 +6,10 @@ class MappingError(Exception): pass +class MapPathMissMatchError(Exception): + pass + + class CircularReferenceError(Exception): def __init__(self, *args: object) -> None: super().__init__( diff --git a/automapper/mapper.py b/automapper/mapper.py index 73297d2..4961483 100644 --- a/automapper/mapper.py +++ b/automapper/mapper.py @@ -1,37 +1,18 @@ import inspect from copy import deepcopy from functools import reduce -from typing import ( - Any, - Callable, - Dict, - Generic, - Iterable, - Optional, - Set, - Tuple, - Type, - TypeVar, - Union, - cast, - overload, -) +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 | MapPath, Any]] - def _try_get_field_value( field_name: str, original_obj: Any, custom_mapping: FieldsMap @@ -162,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." + ) + + for map_path in map_paths: + map_path.obj_prefix = source_cls.__name__ + self._mappings[source_cls] = (target_cls, fields_mapping) def map( diff --git a/automapper/path_mapper.py b/automapper/path_mapper.py index 3fa615c..69f3eba 100644 --- a/automapper/path_mapper.py +++ b/automapper/path_mapper.py @@ -4,16 +4,23 @@ class MapPath: 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) >= 2: + if not len(self.attributes) >= 1: raise ValueError( f"Invalid path: '{path}'. Can´t reference to object attribute." ) - self.obj_prefix = self.attributes[0] - self.attributes = self.attributes[1:] + self._obj_prefix: str | None = None + + @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 diff --git a/tests/test_path_mapper.py b/tests/test_path_mapper.py index 5a95cf4..da89aa8 100644 --- a/tests/test_path_mapper.py +++ b/tests/test_path_mapper.py @@ -1,8 +1,6 @@ import pytest -from automapper import mapper -from automapper.path_mapper import ( # Replace 'your_module' with the actual module name - MapPath, -) +from automapper import MapPath, mapper +from automapper.exceptions import MapPathMissMatchError class BasicUser: @@ -28,8 +26,10 @@ 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 == ["example", "path"] # obj_prefix is excluded - assert path.obj_prefix == "some" # obj_prefix is correctly assigned + 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.""" @@ -41,25 +41,23 @@ def test_invalid_map_path_missing_dot(self): def test_callable_behavior(self): """Test that calling an instance returns the correct split attributes.""" path = MapPath("one.two.three") - assert path() == ["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(['bar'])" - ) # Only attributes are shown, excluding obj_prefix + assert repr(path) == "MapPath(['foo', 'bar'])" -class TestMappingObjectAttributes: +class TestAddMappingWithNestedObjectReference: def test_use_registered_mapping_with_map_path(self): try: mapper.add( AdvancedUser, BasicUser, fields_mapping={ - "name": MapPath("AdvancedUser.user.name"), - "city": MapPath("AdvancedUser.user.city"), + "name": MapPath("user.name"), + "city": MapPath("user.city"), }, ) @@ -83,3 +81,15 @@ def test_map_object_directly_without_adding_map_path_cant_be_resolved(self): 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"), + }, + ) From 92805158f0ee07d6f371fbc408e4e73aabbda9f9 Mon Sep 17 00:00:00 2001 From: Jan Klecka Date: Fri, 7 Mar 2025 21:03:27 +0100 Subject: [PATCH 4/5] change back condition common field format --- automapper/mapper.py | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/automapper/mapper.py b/automapper/mapper.py index 4961483..2a2cd3d 100644 --- a/automapper/mapper.py +++ b/automapper/mapper.py @@ -220,10 +220,10 @@ def map( common_fields_mapping[target_obj_field] = source_field if fields_mapping: - for key, value in fields_mapping.items(): - common_fields_mapping[key] = ( - value # Merge, with fields_mapping having priority - ) + common_fields_mapping = { + **common_fields_mapping, + **fields_mapping, + } # merge two dict into one, fields_mapping has priority return self._map_common( obj, From 30098025d80d83a8bc2d17fb01f4089d76bde729 Mon Sep 17 00:00:00 2001 From: Jan Klecka Date: Fri, 7 Mar 2025 21:04:22 +0100 Subject: [PATCH 5/5] remove unused test --- tests/test_automapper_basics.py | 5 ----- 1 file changed, 5 deletions(-) diff --git a/tests/test_automapper_basics.py b/tests/test_automapper_basics.py index 0d1d54d..02842ee 100644 --- a/tests/test_automapper_basics.py +++ b/tests/test_automapper_basics.py @@ -53,11 +53,6 @@ def test_map__field_with_same_name(): assert public_user_info.profession == "engineer" -def test_mapping_of_nested_attributes_without_map_path_should_fail(): - """It tries to only do reference for the first attribute in the chain which doesnt exists.""" - ... - - def test_map__field_with_different_name(): user_info = UserInfo("John Malkovich", 35, "engineer") public_user_info: PublicUserInfoDiff = mapper.to(PublicUserInfoDiff).map(