From 3ae708d5aae01ff771d5627ae38508b0b826a4e3 Mon Sep 17 00:00:00 2001 From: Georg Pichler Date: Wed, 12 Oct 2022 10:06:46 +0200 Subject: [PATCH 1/9] Addition of "deepcopy" parameter for add() and map() --- README.md | 40 +++++++++++++++++ automapper/mapper.py | 33 +++++++++++--- tests/test_automapper_dict_field.py | 70 +++++++++++++++++++---------- tests/test_automapper_sample.py | 36 +++++++++++++++ 4 files changed, 149 insertions(+), 30 deletions(-) diff --git a/README.md b/README.md index dc2d4a3..f1c81f8 100644 --- a/README.md +++ b/README.md @@ -124,6 +124,46 @@ print(vars(public_user_info)) # {'full_name': 'John Cusack', 'profession': 'engineer'} ``` +## Use of Deepcopy +By default, automapper performs a recursive deepcopy() on all attributes. This makes sure that changes in the attributes of the source +do not affect the target and vice-versa: + +```python +from dataclasses import dataclass +from automapper import mapper + +@dataclass +class Address: + street: str + number: int + zip_code: int + city: str + +class PersonInfo: + def __init__(self, name: str, age: int, address: Address): + self.name = name + self.age = age + self.address = address + +class PublicPersonInfo: + def __init__(self, name: str, address: Address): + self.name = name + self.address = address + +address = Address(street="Main Street", number=1, zip_code=100001, city='Test City') +info = PersonInfo('John Doe', age=35, address=address) + +public_info = mapper.to(PublicPersonInfo).map(info) +assert address is not public_info.address +``` + +To disable this behavior, you may pass `deepcopy=False` to either `mapper.map()` or to `mapper.add()`. If both are passed, +the argument of the `.map()` call has priority. E.g. + +```python +public_info = mapper.to(PublicPersonInfo).map(info, deepcopy=False) +assert address is public_info.address +``` ## Extensions `py-automapper` has few predefined extensions for mapping support to classes for frameworks: diff --git a/automapper/mapper.py b/automapper/mapper.py index d789c2d..3accb9a 100644 --- a/automapper/mapper.py +++ b/automapper/mapper.py @@ -65,12 +65,14 @@ def map( *, skip_none_values: bool = False, fields_mapping: FieldsMap = None, + deepcopy: bool = True, ) -> T: """Produces output object mapped from source object and custom arguments Parameters: skip_none_values - do not map fields that has None value fields_mapping - mapping for fields with different names + deepcopy - should we deepcopy all attributes? [default: True] """ return self.__mapper._map_common( obj, @@ -78,13 +80,14 @@ def map( set(), skip_none_values=skip_none_values, fields_mapping=fields_mapping, + deepcopy=deepcopy, ) class Mapper: def __init__(self) -> None: """Initializes internal containers""" - self._mappings: Dict[Type[S], Tuple[T, FieldsMap]] = {} # type: ignore [valid-type] + self._mappings: Dict[Type[S], Tuple[T, FieldsMap, bool]] = {} # type: ignore [valid-type] self._class_specs: Dict[Type[T], SpecFunction[T]] = {} # type: ignore [valid-type] self._classifier_specs: Dict[ # type: ignore [valid-type] ClassifierFunction[T], SpecFunction[T] @@ -141,6 +144,7 @@ def add( target_cls: Type[T], override: bool = False, fields_mapping: FieldsMap = None, + deepcopy: bool = True, ) -> None: """Adds mapping between object of `source class` to an object of `target class`. @@ -152,12 +156,14 @@ def add( Target class to map to override : bool, optional Override existing `source class` mapping to use new `target class` + deepcopy : bool, optional + Should we deepcopy all attributes? [default: True] """ if source_cls in self._mappings and not override: raise DuplicatedRegistrationError( f"source_cls {source_cls} was already added for mapping" ) - self._mappings[source_cls] = (target_cls, fields_mapping) + self._mappings[source_cls] = (target_cls, fields_mapping, deepcopy) def map( self, @@ -165,6 +171,7 @@ def map( *, skip_none_values: bool = False, fields_mapping: FieldsMap = None, + deepcopy: bool = None, ) -> T: # type: ignore [type-var] """Produces output object mapped from source object and custom arguments""" obj_type = type(obj) @@ -172,7 +179,7 @@ def map( raise MappingError(f"Missing mapping type for input type {obj_type}") obj_type_preffix = f"{obj_type.__name__}." - target_cls, target_cls_field_mappings = self._mappings[obj_type] + target_cls, target_cls_field_mappings, target_deepcopy = self._mappings[obj_type] common_fields_mapping = fields_mapping if target_cls_field_mappings: @@ -190,12 +197,17 @@ def map( **fields_mapping, } # merge two dict into one, fields_mapping has priority + # If deepcopy is not explicitly given, we use target_deepcopy + if deepcopy is None: + deepcopy = target_deepcopy + return self._map_common( obj, target_cls, set(), skip_none_values=skip_none_values, fields_mapping=common_fields_mapping, + deepcopy=deepcopy, ) def _get_fields(self, target_cls: Type[T]) -> Iterable[str]: @@ -224,7 +236,7 @@ def _map_subobject( raise CircularReferenceError() if type(obj) in self._mappings: - target_cls, _ = self._mappings[type(obj)] + target_cls, _, _ = self._mappings[type(obj)] result: Any = self._map_common( obj, target_cls, _visited_stack, skip_none_values=skip_none_values ) @@ -262,12 +274,14 @@ def _map_common( _visited_stack: Set[int], skip_none_values: bool = False, fields_mapping: FieldsMap = None, + deepcopy: bool = True, ) -> T: """Produces output object mapped from source object and custom arguments Parameters: skip_none_values - do not map fields that has None value fields_mapping - fields mappings for fields with different names + deepcopy - Should we deepcopy all attributes? [default: True] """ obj_id = id(obj) @@ -293,9 +307,14 @@ def _map_common( value = obj[field_name] # type: ignore [index] if value is not None: - mapped_values[field_name] = self._map_subobject( - value, _visited_stack, skip_none_values - ) + if deepcopy: + mapped_values[field_name] = self._map_subobject( + value, _visited_stack, skip_none_values + ) + else: + # if deepcopy is disabled, we can act as if value was a primitive type and + # avoid the ._map_subobject() call entirely. + mapped_values[field_name] = value elif not skip_none_values: mapped_values[field_name] = None diff --git a/tests/test_automapper_dict_field.py b/tests/test_automapper_dict_field.py index c63ec49..aa3c3e3 100644 --- a/tests/test_automapper_dict_field.py +++ b/tests/test_automapper_dict_field.py @@ -1,7 +1,7 @@ -from copy import deepcopy from typing import Any, Dict +from unittest import TestCase -from automapper import mapper +from automapper import mapper, create_mapper class Candy: @@ -12,36 +12,60 @@ def __init__(self, name: str, brand: str): class Shop: def __init__(self, products: Dict[str, Any], annual_income: int): - self.products: Dict[str, Any] = deepcopy(products) + self.products: Dict[str, Any] = products self.annual_income = annual_income class ShopPublicInfo: def __init__(self, products: Dict[str, Any]): - self.products: Dict[str, Any] = deepcopy(products) + self.products: Dict[str, Any] = products -def test_map__with_dict_field(): - products = { - "magazines": ["Forbes", "Time", "The New Yorker"], - "candies": [ - Candy("Reese's cups", "The Hershey Company"), - Candy("Snickers", "Mars, Incorporated"), - ], - } - shop = Shop(products=products, annual_income=10000000) +class AutomapperTest(TestCase): + def setUp(self) -> None: + products = { + "magazines": ["Forbes", "Time", "The New Yorker"], + "candies": [ + Candy("Reese's cups", "The Hershey Company"), + Candy("Snickers", "Mars, Incorporated"), + ], + } + self.shop = Shop(products=products, annual_income=10000000) + self.mapper = create_mapper() - public_info = mapper.to(ShopPublicInfo).map(shop) + def test_map__with_dict_field(self): + public_info = mapper.to(ShopPublicInfo).map(self.shop) - assert public_info.products["magazines"] == shop.products["magazines"] - assert id(public_info.products["magazines"]) != id(shop.products["magazines"]) + self.assertEqual(public_info.products["magazines"], self.shop.products["magazines"]) + self.assertNotEqual(id(public_info.products["magazines"]), id(self.shop.products["magazines"])) - assert public_info.products["candies"] != shop.products["candies"] - assert public_info.products["candies"][0] != shop.products["candies"][0] - assert public_info.products["candies"][1] != shop.products["candies"][1] + self.assertNotEqual(public_info.products["candies"], self.shop.products["candies"]) + self.assertNotEqual(public_info.products["candies"][0], self.shop.products["candies"][0]) + self.assertNotEqual(public_info.products["candies"][1], self.shop.products["candies"][1]) - assert public_info.products["candies"][0].name == "Reese's cups" - assert public_info.products["candies"][0].brand == "The Hershey Company" + self.assertEqual(public_info.products["candies"][0].name, "Reese's cups") + self.assertEqual(public_info.products["candies"][0].brand, "The Hershey Company") - assert public_info.products["candies"][1].name == "Snickers" - assert public_info.products["candies"][1].brand == "Mars, Incorporated" + self.assertEqual(public_info.products["candies"][1].name, "Snickers") + self.assertEqual(public_info.products["candies"][1].brand, "Mars, Incorporated") + + def test_deepcopy_disabled(self): + public_info_deep = mapper.to(ShopPublicInfo).map(self.shop, deepcopy=False) + public_info = mapper.to(ShopPublicInfo).map(self.shop) + + self.assertIsNot(public_info.products, self.shop.products) + self.assertEqual(public_info.products["magazines"], self.shop.products["magazines"]) + self.assertNotEqual(public_info.products["magazines"], id(self.shop.products["magazines"])) + + self.assertIs(public_info_deep.products, self.shop.products) + self.assertEqual(id(public_info_deep.products["magazines"]), id(self.shop.products["magazines"])) + + def test_deepcopy_disabled_in_add(self): + self.mapper.add(Shop, ShopPublicInfo, deepcopy=False) + public_info = self.mapper.map(self.shop) + + self.assertIs(public_info.products, self.shop.products) + + # Manually enable deepcopy on .map() + public_info = self.mapper.map(self.shop, deepcopy=True) + self.assertIsNot(public_info.products, self.shop.products) diff --git a/tests/test_automapper_sample.py b/tests/test_automapper_sample.py index a320a5f..618448d 100644 --- a/tests/test_automapper_sample.py +++ b/tests/test_automapper_sample.py @@ -20,6 +20,31 @@ def __init__(self, full_name: str, profession: str): self.profession = profession +from dataclasses import dataclass +from automapper import mapper + + +@dataclass +class Address: + street: str + number: int + zip_code: int + city: str + + +class PersonInfo: + def __init__(self, name: str, age: int, address: Address): + self.name = name + self.age = age + self.address = address + + +class PublicPersonInfo: + def __init__(self, name: str, address: Address): + self.name = name + self.address = address + + def test_map__field_with_same_name(): user_info = UserInfo("John Malkovich", 35, "engineer") public_user_info = mapper.to(PublicUserInfo).map( @@ -79,3 +104,14 @@ def test_map__override_field_value_register(): assert public_user_info.profession == "engineer" finally: mapper._mappings.clear() + + +def test_deepcopy(): + address = Address(street="Main Street", number=1, zip_code=100001, city='Test City') + info = PersonInfo('John Doe', age=35, address=address) + + public_info = mapper.to(PublicPersonInfo).map(info) + assert address is not public_info.address + + public_info = mapper.to(PublicPersonInfo).map(info, deepcopy=False) + assert address is public_info.address From b6fef965b9dcb088ae5ea5eb09aeaa471fad01a5 Mon Sep 17 00:00:00 2001 From: Georg Pichler Date: Wed, 12 Oct 2022 10:10:04 +0200 Subject: [PATCH 2/9] Automated style reformatting. --- automapper/exceptions.py | 4 +--- automapper/extensions/default.py | 4 +--- automapper/mapper.py | 15 ++++----------- tests/test_automapper.py | 12 +++--------- tests/test_automapper_dict_field.py | 8 ++++++-- tests/test_automapper_sample.py | 8 +++----- tests/test_for_complex_obj.py | 8 ++------ 7 files changed, 20 insertions(+), 39 deletions(-) diff --git a/automapper/exceptions.py b/automapper/exceptions.py index b2d6595..c041757 100644 --- a/automapper/exceptions.py +++ b/automapper/exceptions.py @@ -8,6 +8,4 @@ class MappingError(Exception): class CircularReferenceError(Exception): def __init__(self, *args: object) -> None: - super().__init__( - "Mapper does not support objects with circular references yet", *args - ) + super().__init__("Mapper does not support objects with circular references yet", *args) diff --git a/automapper/extensions/default.py b/automapper/extensions/default.py index 1ad532e..a950bed 100644 --- a/automapper/extensions/default.py +++ b/automapper/extensions/default.py @@ -11,9 +11,7 @@ def __init_method_classifier__(target_cls: Type[T]) -> bool: return ( hasattr(target_cls, "__init__") and hasattr(getattr(target_cls, "__init__"), "__annotations__") - and isinstance( - getattr(getattr(target_cls, "__init__"), "__annotations__"), dict - ) + and isinstance(getattr(getattr(target_cls, "__init__"), "__annotations__"), dict) and getattr(getattr(target_cls, "__init__"), "__annotations__") ) diff --git a/automapper/mapper.py b/automapper/mapper.py index 3accb9a..813a8cb 100644 --- a/automapper/mapper.py +++ b/automapper/mapper.py @@ -105,9 +105,7 @@ def add_spec(self, classifier: Type[T], spec_func: SpecFunction[T]) -> None: ... @overload - def add_spec( - self, classifier: ClassifierFunction[T], spec_func: SpecFunction[T] - ) -> None: + def add_spec(self, classifier: ClassifierFunction[T], spec_func: SpecFunction[T]) -> None: """Add a spec function for all classes identified by classifier function. Parameters: @@ -186,8 +184,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_preffix) :]) - if isinstance(source_field, str) - and source_field.startswith(obj_type_preffix) + if isinstance(source_field, str) and source_field.startswith(obj_type_preffix) else source_field for target_obj_field, source_field in target_cls_field_mappings.items() } @@ -220,9 +217,7 @@ def _get_fields(self, target_cls: Type[T]) -> Iterable[str]: if classifier(target_cls): return self._classifier_specs[classifier](target_cls) - raise MappingError( - f"No spec function is added for base class of {type(target_cls)}" - ) + raise MappingError(f"No spec function is added for base class of {type(target_cls)}") def _map_subobject( self, obj: S, _visited_stack: Set[int], skip_none_values: bool = False @@ -246,9 +241,7 @@ def _map_subobject( if is_sequence(obj): if isinstance(obj, dict): result = { - k: self._map_subobject( - v, _visited_stack, skip_none_values=skip_none_values - ) + k: self._map_subobject(v, _visited_stack, skip_none_values=skip_none_values) for k, v in obj.items() } else: diff --git a/tests/test_automapper.py b/tests/test_automapper.py index fd27437..c307cd7 100644 --- a/tests/test_automapper.py +++ b/tests/test_automapper.py @@ -20,9 +20,7 @@ def __init__(self, num: int, text: str, flag: bool) -> None: @classmethod def fields(cls) -> Iterable[str]: - return ( - field for field in cls.__init__.__annotations__.keys() if field != "return" - ) + return (field for field in cls.__init__.__annotations__.keys() if field != "return") class AnotherClass: @@ -68,16 +66,12 @@ def setUp(self): def test_add_spec__adds_to_internal_collection(self): self.mapper.add_spec(ParentClass, custom_spec_func) assert ParentClass in self.mapper._class_specs - assert ["num", "text", "flag"] == self.mapper._class_specs[ParentClass]( - ChildClass - ) + assert ["num", "text", "flag"] == self.mapper._class_specs[ParentClass](ChildClass) def test_add_spec__error_on_adding_same_class_spec(self): self.mapper.add_spec(ParentClass, custom_spec_func) with pytest.raises(DuplicatedRegistrationError): - self.mapper.add_spec( - ParentClass, lambda concrete_type: ["field1", "field2"] - ) + self.mapper.add_spec(ParentClass, lambda concrete_type: ["field1", "field2"]) def test_add_spec__adds_to_internal_collection_for_classifier(self): self.mapper.add_spec(classifier_func, spec_func) diff --git a/tests/test_automapper_dict_field.py b/tests/test_automapper_dict_field.py index aa3c3e3..4e897b9 100644 --- a/tests/test_automapper_dict_field.py +++ b/tests/test_automapper_dict_field.py @@ -37,7 +37,9 @@ def test_map__with_dict_field(self): public_info = mapper.to(ShopPublicInfo).map(self.shop) self.assertEqual(public_info.products["magazines"], self.shop.products["magazines"]) - self.assertNotEqual(id(public_info.products["magazines"]), id(self.shop.products["magazines"])) + self.assertNotEqual( + id(public_info.products["magazines"]), id(self.shop.products["magazines"]) + ) self.assertNotEqual(public_info.products["candies"], self.shop.products["candies"]) self.assertNotEqual(public_info.products["candies"][0], self.shop.products["candies"][0]) @@ -58,7 +60,9 @@ def test_deepcopy_disabled(self): self.assertNotEqual(public_info.products["magazines"], id(self.shop.products["magazines"])) self.assertIs(public_info_deep.products, self.shop.products) - self.assertEqual(id(public_info_deep.products["magazines"]), id(self.shop.products["magazines"])) + self.assertEqual( + id(public_info_deep.products["magazines"]), id(self.shop.products["magazines"]) + ) def test_deepcopy_disabled_in_add(self): self.mapper.add(Shop, ShopPublicInfo, deepcopy=False) diff --git a/tests/test_automapper_sample.py b/tests/test_automapper_sample.py index 618448d..ffaea6b 100644 --- a/tests/test_automapper_sample.py +++ b/tests/test_automapper_sample.py @@ -67,9 +67,7 @@ def test_map__field_with_different_name(): def test_map__field_with_different_name_register(): try: - mapper.add( - UserInfo, PublicUserInfoDiff, fields_mapping={"full_name": "UserInfo.name"} - ) + mapper.add(UserInfo, PublicUserInfoDiff, fields_mapping={"full_name": "UserInfo.name"}) user_info = UserInfo("John Malkovich", 35, "engineer") public_user_info: PublicUserInfoDiff = mapper.map(user_info) @@ -107,8 +105,8 @@ def test_map__override_field_value_register(): def test_deepcopy(): - address = Address(street="Main Street", number=1, zip_code=100001, city='Test City') - info = PersonInfo('John Doe', age=35, address=address) + address = Address(street="Main Street", number=1, zip_code=100001, city="Test City") + info = PersonInfo("John Doe", age=35, address=address) public_info = mapper.to(PublicPersonInfo).map(info) assert address is not public_info.address diff --git a/tests/test_for_complex_obj.py b/tests/test_for_complex_obj.py index 0ffebe0..b3eb8dc 100644 --- a/tests/test_for_complex_obj.py +++ b/tests/test_for_complex_obj.py @@ -20,9 +20,7 @@ def __init__(self, num: int, text: str, flag: bool) -> None: @classmethod def fields(cls) -> Iterable[str]: - return ( - field for field in cls.__init__.__annotations__.keys() if field != "return" - ) + return (field for field in cls.__init__.__annotations__.keys() if field != "return") class AnotherClass: @@ -73,9 +71,7 @@ def setUp(self): self.mapper = create_mapper() def test_map__complext_obj(self): - complex_obj = ComplexClass( - obj=ChildClass(15, "nested_obj_msg", True), text="obj_msg" - ) + complex_obj = ComplexClass(obj=ChildClass(15, "nested_obj_msg", True), text="obj_msg") self.mapper.add(ChildClass, AnotherClass) self.mapper.add(ComplexClass, AnotherComplexClass) From d216d4b8404c4984785839778a9247a320eed541 Mon Sep 17 00:00:00 2001 From: Georg Pichler Date: Wed, 12 Oct 2022 10:11:55 +0200 Subject: [PATCH 3/9] Fixed flake8 linting errors. --- tests/test_automapper_sample.py | 5 +---- 1 file changed, 1 insertion(+), 4 deletions(-) diff --git a/tests/test_automapper_sample.py b/tests/test_automapper_sample.py index ffaea6b..eb83328 100644 --- a/tests/test_automapper_sample.py +++ b/tests/test_automapper_sample.py @@ -1,3 +1,4 @@ +from dataclasses import dataclass from automapper import mapper @@ -20,10 +21,6 @@ def __init__(self, full_name: str, profession: str): self.profession = profession -from dataclasses import dataclass -from automapper import mapper - - @dataclass class Address: street: str From 0dde3b50b08838e2455d3cb1c0db7e3b45a61e19 Mon Sep 17 00:00:00 2001 From: Georg Pichler Date: Wed, 12 Oct 2022 10:21:48 +0200 Subject: [PATCH 4/9] Add mypy type-check annotations to deal with SQLAlchemy quirks --- tests/test_for_sqlalchemy_extention.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/tests/test_for_sqlalchemy_extention.py b/tests/test_for_sqlalchemy_extention.py index 5db82c9..4835f10 100644 --- a/tests/test_for_sqlalchemy_extention.py +++ b/tests/test_for_sqlalchemy_extention.py @@ -9,7 +9,7 @@ Base = declarative_base() -class UserInfo(Base): +class UserInfo(Base): # type: ignore[misc,valid-type] __tablename__ = "users" id = Column(Integer, primary_key=True) full_name = Column(String) @@ -24,7 +24,7 @@ def __repr__(self): ) -class PublicUserInfo(Base): +class PublicUserInfo(Base): # type: ignore[misc,valid-type] __tablename__ = "public_users" id = Column(Integer, primary_key=True) public_name = Column(String) From 8ac61b175264e05341a2ac3503140c6306e12cc3 Mon Sep 17 00:00:00 2001 From: Andrii Nikolaienko Date: Tue, 25 Oct 2022 12:12:40 +0300 Subject: [PATCH 5/9] applied pre-commit corrections. Improved doc comments --- automapper/exceptions.py | 4 +- automapper/extensions/default.py | 4 +- automapper/mapper.py | 141 ++++++++++++++++++------- tests/test_automapper.py | 12 ++- tests/test_automapper_dict_field.py | 39 ++++--- tests/test_automapper_sample.py | 5 +- tests/test_for_complex_obj.py | 8 +- tests/test_for_sqlalchemy_extention.py | 4 +- 8 files changed, 155 insertions(+), 62 deletions(-) diff --git a/automapper/exceptions.py b/automapper/exceptions.py index c041757..b2d6595 100644 --- a/automapper/exceptions.py +++ b/automapper/exceptions.py @@ -8,4 +8,6 @@ class MappingError(Exception): class CircularReferenceError(Exception): def __init__(self, *args: object) -> None: - super().__init__("Mapper does not support objects with circular references yet", *args) + super().__init__( + "Mapper does not support objects with circular references yet", *args + ) diff --git a/automapper/extensions/default.py b/automapper/extensions/default.py index a950bed..1ad532e 100644 --- a/automapper/extensions/default.py +++ b/automapper/extensions/default.py @@ -11,7 +11,9 @@ def __init_method_classifier__(target_cls: Type[T]) -> bool: return ( hasattr(target_cls, "__init__") and hasattr(getattr(target_cls, "__init__"), "__annotations__") - and isinstance(getattr(getattr(target_cls, "__init__"), "__annotations__"), dict) + and isinstance( + getattr(getattr(target_cls, "__init__"), "__annotations__"), dict + ) and getattr(getattr(target_cls, "__init__"), "__annotations__") ) diff --git a/automapper/mapper.py b/automapper/mapper.py index 813a8cb..e2bc57d 100644 --- a/automapper/mapper.py +++ b/automapper/mapper.py @@ -32,17 +32,17 @@ __PRIMITIVE_TYPES = {int, float, complex, str, bytes, bytearray, bool} -def is_sequence(obj: Any) -> bool: +def _is_sequence(obj: Any) -> bool: """Check if object implements `__iter__` method""" return hasattr(obj, "__iter__") -def is_subscriptable(obj: Any) -> bool: +def _is_subscriptable(obj: Any) -> bool: """Check if object implements `__get_item__` method""" return hasattr(obj, "__get_item__") -def is_primitive(obj: Any) -> bool: +def _is_primitive(obj: Any) -> bool: """Check if object type is primitive""" return type(obj) in __PRIMITIVE_TYPES @@ -67,12 +67,26 @@ def map( fields_mapping: FieldsMap = None, deepcopy: bool = True, ) -> T: - """Produces output object mapped from source object and custom arguments + """Produces output object mapped from source object and custom arguments. Parameters: skip_none_values - do not map fields that has None value fields_mapping - mapping for fields with different names deepcopy - should we deepcopy all attributes? [default: True] + + Args: + obj (S): _description_ + skip_none_values (bool, optional): Skip None values when creating `target class` obj. Defaults to False. + fields_mapping (FieldsMap, optional): Custom mapping. + Specify dictionary in format {"field_name": value_object}. Defaults to None. + deepcopy (bool, optional): Applies deepcopy to all child objects when copy into output instance. + Defaults to True. + + Raises: + CircularReferenceError: Circular references in `source class` object are not allowed yet. + + Returns: + T: instance of `target class` with mapped values from `source class` or custom `fields_mapping` dictionary. """ return self.__mapper._map_common( obj, @@ -97,22 +111,22 @@ def __init__(self) -> None: def add_spec(self, classifier: Type[T], spec_func: SpecFunction[T]) -> None: """Add a spec function for all classes in inherited from base class. - Parameters: - * classifier - base class to identify all descendant classes - * spec_func - returns a list of fields (List[str]) for target class - that are accepted in constructor + Args: + classifier (ClassifierFunction[T]): base class to identify all descendant classes. + spec_func (SpecFunction[T]): get list of fields (List[str]) for `target class` to be passed in constructor. """ ... @overload - def add_spec(self, classifier: ClassifierFunction[T], spec_func: SpecFunction[T]) -> None: + def add_spec( + self, classifier: ClassifierFunction[T], spec_func: SpecFunction[T] + ) -> None: """Add a spec function for all classes identified by classifier function. - Parameters: - * classifier - boolean predicate that identifies a group of classes - by certain characteristics: if class has a specific method or a field, etc. - * spec_func - returns a list of fields (List[str]) for target class - that are accepted in constructor + Args: + classifier (ClassifierFunction[T]): boolean predicate that identifies a group of classes + by certain characteristics: if class has a specific method or a field, etc. + spec_func (SpecFunction[T]): get list of fields (List[str]) for `target class` to be passed in constructor. """ ... @@ -146,16 +160,21 @@ def add( ) -> None: """Adds mapping between object of `source class` to an object of `target class`. - Parameters - ---------- - source_cls : Type - Source class to map from - target_cls : Type - Target class to map to - override : bool, optional - Override existing `source class` mapping to use new `target class` - deepcopy : bool, optional - Should we deepcopy all attributes? [default: True] + Args: + source_cls (Type[S]): Source class to map from + target_cls (Type[T]): Target class to map to + override (bool, optional): Override existing `source class` mapping to use new `target class`. + Defaults to False. + fields_mapping (FieldsMap, optional): Custom mapping. + Specify dictionary in format {"field_name": value_object}. Defaults to None. + deepcopy (bool, optional): Applies deepcopy to all child objects when copy into output instance. + Defaults to True. + + 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. """ if source_cls in self._mappings and not override: raise DuplicatedRegistrationError( @@ -169,22 +188,42 @@ def map( *, skip_none_values: bool = False, fields_mapping: FieldsMap = None, - deepcopy: bool = None, + deepcopy: bool = True, ) -> T: # type: ignore [type-var] - """Produces output object mapped from source object and custom arguments""" + """Produces output object mapped from source object and custom arguments + + Args: + obj (object): Source object to map to `target class`. + skip_none_values (bool, optional): Skip None values when creating `target class` obj. Defaults to False. + fields_mapping (FieldsMap, optional): Custom mapping. + Specify dictionary in format {"field_name": value_object}. Defaults to None. + deepcopy (bool, optional): Applies deepcopy to all child objects when copy into output instance. + Defaults to True. + + Raises: + MappingError: No `target class` specified to be mapped into. + Register mappings using `mapped.add(...)` or specify `target class` using `mapper.to(target_cls).map()`. + CircularReferenceError: Circular references in `source class` object are not allowed yet. + + Returns: + T: instance of `target class` with mapped values from `source class` or custom `fields_mapping` dictionary. + """ obj_type = type(obj) if obj_type not in self._mappings: raise MappingError(f"Missing mapping type for input type {obj_type}") obj_type_preffix = f"{obj_type.__name__}." - target_cls, target_cls_field_mappings, target_deepcopy = self._mappings[obj_type] + target_cls, target_cls_field_mappings, target_deepcopy = self._mappings[ + obj_type + ] 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_preffix) :]) - if isinstance(source_field, str) and source_field.startswith(obj_type_preffix) + if isinstance(source_field, str) + and source_field.startswith(obj_type_preffix) else source_field for target_obj_field, source_field in target_cls_field_mappings.items() } @@ -217,13 +256,15 @@ def _get_fields(self, target_cls: Type[T]) -> Iterable[str]: if classifier(target_cls): return self._classifier_specs[classifier](target_cls) - raise MappingError(f"No spec function is added for base class of {type(target_cls)}") + raise MappingError( + f"No spec function is added for base class of {type(target_cls)}" + ) def _map_subobject( self, obj: S, _visited_stack: Set[int], skip_none_values: bool = False ) -> Any: """Maps subobjects recursively""" - if is_primitive(obj): + if _is_primitive(obj): return obj obj_id = id(obj) @@ -238,10 +279,12 @@ def _map_subobject( else: _visited_stack.add(obj_id) - if is_sequence(obj): + if _is_sequence(obj): if isinstance(obj, dict): result = { - k: self._map_subobject(v, _visited_stack, skip_none_values=skip_none_values) + k: self._map_subobject( + v, _visited_stack, skip_none_values=skip_none_values + ) for k, v in obj.items() } else: @@ -269,12 +312,23 @@ def _map_common( fields_mapping: FieldsMap = None, deepcopy: bool = True, ) -> T: - """Produces output object mapped from source object and custom arguments - - Parameters: - skip_none_values - do not map fields that has None value - fields_mapping - fields mappings for fields with different names - deepcopy - Should we deepcopy all attributes? [default: True] + """Produces output object mapped from source object and custom arguments. + + Args: + obj (S): Source object to map to `target class`. + target_cls (Type[T]): Target class to map to. + _visited_stack (Set[int]): Visited child objects. To avoid infinite recursive calls. + skip_none_values (bool, optional): Skip None values when creating `target class` obj. Defaults to False. + fields_mapping (FieldsMap, optional): Custom mapping. + Specify dictionary in format {"field_name": value_object}. Defaults to None. + deepcopy (bool, optional): Applies deepcopy to all child objects when copy into output instance. + Defaults to True. + + Raises: + CircularReferenceError: Circular references in `source class` object are not allowed yet. + + Returns: + T: Instance of `target class` with mapped fields. """ obj_id = id(obj) @@ -285,7 +339,7 @@ def _map_common( target_cls_fields = self._get_fields(target_cls) mapped_values: Dict[str, Any] = {} - is_obj_subscriptable = is_subscriptable(obj) + is_obj_subscriptable = _is_subscriptable(obj) for field_name in target_cls_fields: if ( (fields_mapping and field_name in fields_mapping) @@ -316,5 +370,12 @@ def _map_common( return cast(target_cls, target_cls(**mapped_values)) # type: ignore [valid-type] def to(self, target_cls: Type[T]) -> MappingWrapper[T]: - """Specify target class to map source object to""" + """Specify `target class` to which map `source class` object. + + Args: + target_cls (Type[T]): Target class. + + Returns: + MappingWrapper[T]: Mapping wrapper. Use `map` method to perform mapping now. + """ return MappingWrapper[T](self, target_cls) diff --git a/tests/test_automapper.py b/tests/test_automapper.py index c307cd7..fd27437 100644 --- a/tests/test_automapper.py +++ b/tests/test_automapper.py @@ -20,7 +20,9 @@ def __init__(self, num: int, text: str, flag: bool) -> None: @classmethod def fields(cls) -> Iterable[str]: - return (field for field in cls.__init__.__annotations__.keys() if field != "return") + return ( + field for field in cls.__init__.__annotations__.keys() if field != "return" + ) class AnotherClass: @@ -66,12 +68,16 @@ def setUp(self): def test_add_spec__adds_to_internal_collection(self): self.mapper.add_spec(ParentClass, custom_spec_func) assert ParentClass in self.mapper._class_specs - assert ["num", "text", "flag"] == self.mapper._class_specs[ParentClass](ChildClass) + assert ["num", "text", "flag"] == self.mapper._class_specs[ParentClass]( + ChildClass + ) def test_add_spec__error_on_adding_same_class_spec(self): self.mapper.add_spec(ParentClass, custom_spec_func) with pytest.raises(DuplicatedRegistrationError): - self.mapper.add_spec(ParentClass, lambda concrete_type: ["field1", "field2"]) + self.mapper.add_spec( + ParentClass, lambda concrete_type: ["field1", "field2"] + ) def test_add_spec__adds_to_internal_collection_for_classifier(self): self.mapper.add_spec(classifier_func, spec_func) diff --git a/tests/test_automapper_dict_field.py b/tests/test_automapper_dict_field.py index 4e897b9..3f52b25 100644 --- a/tests/test_automapper_dict_field.py +++ b/tests/test_automapper_dict_field.py @@ -1,7 +1,7 @@ from typing import Any, Dict from unittest import TestCase -from automapper import mapper, create_mapper +from automapper import create_mapper, mapper class Candy: @@ -36,17 +36,27 @@ def setUp(self) -> None: def test_map__with_dict_field(self): public_info = mapper.to(ShopPublicInfo).map(self.shop) - self.assertEqual(public_info.products["magazines"], self.shop.products["magazines"]) + self.assertEqual( + public_info.products["magazines"], self.shop.products["magazines"] + ) self.assertNotEqual( id(public_info.products["magazines"]), id(self.shop.products["magazines"]) ) - self.assertNotEqual(public_info.products["candies"], self.shop.products["candies"]) - self.assertNotEqual(public_info.products["candies"][0], self.shop.products["candies"][0]) - self.assertNotEqual(public_info.products["candies"][1], self.shop.products["candies"][1]) + self.assertNotEqual( + public_info.products["candies"], self.shop.products["candies"] + ) + self.assertNotEqual( + public_info.products["candies"][0], self.shop.products["candies"][0] + ) + self.assertNotEqual( + public_info.products["candies"][1], self.shop.products["candies"][1] + ) self.assertEqual(public_info.products["candies"][0].name, "Reese's cups") - self.assertEqual(public_info.products["candies"][0].brand, "The Hershey Company") + self.assertEqual( + public_info.products["candies"][0].brand, "The Hershey Company" + ) self.assertEqual(public_info.products["candies"][1].name, "Snickers") self.assertEqual(public_info.products["candies"][1].brand, "Mars, Incorporated") @@ -56,20 +66,25 @@ def test_deepcopy_disabled(self): public_info = mapper.to(ShopPublicInfo).map(self.shop) self.assertIsNot(public_info.products, self.shop.products) - self.assertEqual(public_info.products["magazines"], self.shop.products["magazines"]) - self.assertNotEqual(public_info.products["magazines"], id(self.shop.products["magazines"])) + self.assertEqual( + public_info.products["magazines"], self.shop.products["magazines"] + ) + self.assertNotEqual( + public_info.products["magazines"], id(self.shop.products["magazines"]) + ) self.assertIs(public_info_deep.products, self.shop.products) self.assertEqual( - id(public_info_deep.products["magazines"]), id(self.shop.products["magazines"]) + id(public_info_deep.products["magazines"]), + id(self.shop.products["magazines"]), ) def test_deepcopy_disabled_in_add(self): self.mapper.add(Shop, ShopPublicInfo, deepcopy=False) - public_info = self.mapper.map(self.shop) + public_info: ShopPublicInfo = self.mapper.map(self.shop) self.assertIs(public_info.products, self.shop.products) # Manually enable deepcopy on .map() - public_info = self.mapper.map(self.shop, deepcopy=True) - self.assertIsNot(public_info.products, self.shop.products) + public_info2: ShopPublicInfo = self.mapper.map(self.shop, deepcopy=True) + self.assertIsNot(public_info2.products, self.shop.products) diff --git a/tests/test_automapper_sample.py b/tests/test_automapper_sample.py index eb83328..f455ebb 100644 --- a/tests/test_automapper_sample.py +++ b/tests/test_automapper_sample.py @@ -1,4 +1,5 @@ from dataclasses import dataclass + from automapper import mapper @@ -64,7 +65,9 @@ def test_map__field_with_different_name(): def test_map__field_with_different_name_register(): try: - mapper.add(UserInfo, PublicUserInfoDiff, fields_mapping={"full_name": "UserInfo.name"}) + mapper.add( + UserInfo, PublicUserInfoDiff, fields_mapping={"full_name": "UserInfo.name"} + ) user_info = UserInfo("John Malkovich", 35, "engineer") public_user_info: PublicUserInfoDiff = mapper.map(user_info) diff --git a/tests/test_for_complex_obj.py b/tests/test_for_complex_obj.py index b3eb8dc..0ffebe0 100644 --- a/tests/test_for_complex_obj.py +++ b/tests/test_for_complex_obj.py @@ -20,7 +20,9 @@ def __init__(self, num: int, text: str, flag: bool) -> None: @classmethod def fields(cls) -> Iterable[str]: - return (field for field in cls.__init__.__annotations__.keys() if field != "return") + return ( + field for field in cls.__init__.__annotations__.keys() if field != "return" + ) class AnotherClass: @@ -71,7 +73,9 @@ def setUp(self): self.mapper = create_mapper() def test_map__complext_obj(self): - complex_obj = ComplexClass(obj=ChildClass(15, "nested_obj_msg", True), text="obj_msg") + complex_obj = ComplexClass( + obj=ChildClass(15, "nested_obj_msg", True), text="obj_msg" + ) self.mapper.add(ChildClass, AnotherClass) self.mapper.add(ComplexClass, AnotherComplexClass) diff --git a/tests/test_for_sqlalchemy_extention.py b/tests/test_for_sqlalchemy_extention.py index 4835f10..5db82c9 100644 --- a/tests/test_for_sqlalchemy_extention.py +++ b/tests/test_for_sqlalchemy_extention.py @@ -9,7 +9,7 @@ Base = declarative_base() -class UserInfo(Base): # type: ignore[misc,valid-type] +class UserInfo(Base): __tablename__ = "users" id = Column(Integer, primary_key=True) full_name = Column(String) @@ -24,7 +24,7 @@ def __repr__(self): ) -class PublicUserInfo(Base): # type: ignore[misc,valid-type] +class PublicUserInfo(Base): __tablename__ = "public_users" id = Column(Integer, primary_key=True) public_name = Column(String) From 70108418e2230c88aaa9d6b410e4faed8450220b Mon Sep 17 00:00:00 2001 From: Andrii Nikolaienko Date: Tue, 25 Oct 2022 12:30:25 +0300 Subject: [PATCH 6/9] renamed deepcopy argument, removed from method for now --- automapper/mapper.py | 44 +++++++++-------------------- tests/test_automapper_dict_field.py | 14 ++------- tests/test_automapper_sample.py | 4 +-- 3 files changed, 18 insertions(+), 44 deletions(-) diff --git a/automapper/mapper.py b/automapper/mapper.py index e2bc57d..5d60d43 100644 --- a/automapper/mapper.py +++ b/automapper/mapper.py @@ -65,21 +65,16 @@ def map( *, skip_none_values: bool = False, fields_mapping: FieldsMap = None, - deepcopy: bool = True, + use_deepcopy: bool = True, ) -> T: """Produces output object mapped from source object and custom arguments. - Parameters: - skip_none_values - do not map fields that has None value - fields_mapping - mapping for fields with different names - deepcopy - should we deepcopy all attributes? [default: True] - Args: obj (S): _description_ skip_none_values (bool, optional): Skip None values when creating `target class` obj. Defaults to False. fields_mapping (FieldsMap, optional): Custom mapping. Specify dictionary in format {"field_name": value_object}. Defaults to None. - deepcopy (bool, optional): Applies deepcopy to all child objects when copy into output instance. + use_deepcopy (bool, optional): Apply deepcopy to all child objects when copy from source to target object. Defaults to True. Raises: @@ -94,14 +89,14 @@ def map( set(), skip_none_values=skip_none_values, fields_mapping=fields_mapping, - deepcopy=deepcopy, + use_deepcopy=use_deepcopy, ) class Mapper: def __init__(self) -> None: """Initializes internal containers""" - self._mappings: Dict[Type[S], Tuple[T, FieldsMap, bool]] = {} # type: ignore [valid-type] + self._mappings: Dict[Type[S], Tuple[T, FieldsMap]] = {} # type: ignore [valid-type] self._class_specs: Dict[Type[T], SpecFunction[T]] = {} # type: ignore [valid-type] self._classifier_specs: Dict[ # type: ignore [valid-type] ClassifierFunction[T], SpecFunction[T] @@ -156,7 +151,6 @@ def add( target_cls: Type[T], override: bool = False, fields_mapping: FieldsMap = None, - deepcopy: bool = True, ) -> None: """Adds mapping between object of `source class` to an object of `target class`. @@ -167,8 +161,6 @@ def add( Defaults to False. fields_mapping (FieldsMap, optional): Custom mapping. Specify dictionary in format {"field_name": value_object}. Defaults to None. - deepcopy (bool, optional): Applies deepcopy to all child objects when copy into output instance. - Defaults to True. Raises: DuplicatedRegistrationError: Same mapping for `source class` was added. @@ -180,7 +172,7 @@ def add( raise DuplicatedRegistrationError( f"source_cls {source_cls} was already added for mapping" ) - self._mappings[source_cls] = (target_cls, fields_mapping, deepcopy) + self._mappings[source_cls] = (target_cls, fields_mapping) def map( self, @@ -188,7 +180,7 @@ def map( *, skip_none_values: bool = False, fields_mapping: FieldsMap = None, - deepcopy: bool = True, + use_deepcopy: bool = True, ) -> T: # type: ignore [type-var] """Produces output object mapped from source object and custom arguments @@ -197,7 +189,7 @@ def map( skip_none_values (bool, optional): Skip None values when creating `target class` obj. Defaults to False. fields_mapping (FieldsMap, optional): Custom mapping. Specify dictionary in format {"field_name": value_object}. Defaults to None. - deepcopy (bool, optional): Applies deepcopy to all child objects when copy into output instance. + use_deepcopy (bool, optional): Apply deepcopy to all child objects when copy from source to target object. Defaults to True. Raises: @@ -213,9 +205,7 @@ def map( raise MappingError(f"Missing mapping type for input type {obj_type}") obj_type_preffix = f"{obj_type.__name__}." - target_cls, target_cls_field_mappings, target_deepcopy = self._mappings[ - obj_type - ] + target_cls, target_cls_field_mappings = self._mappings[obj_type] common_fields_mapping = fields_mapping if target_cls_field_mappings: @@ -233,17 +223,13 @@ def map( **fields_mapping, } # merge two dict into one, fields_mapping has priority - # If deepcopy is not explicitly given, we use target_deepcopy - if deepcopy is None: - deepcopy = target_deepcopy - return self._map_common( obj, target_cls, set(), skip_none_values=skip_none_values, fields_mapping=common_fields_mapping, - deepcopy=deepcopy, + use_deepcopy=use_deepcopy, ) def _get_fields(self, target_cls: Type[T]) -> Iterable[str]: @@ -272,7 +258,7 @@ def _map_subobject( raise CircularReferenceError() if type(obj) in self._mappings: - target_cls, _, _ = self._mappings[type(obj)] + target_cls, _ = self._mappings[type(obj)] result: Any = self._map_common( obj, target_cls, _visited_stack, skip_none_values=skip_none_values ) @@ -310,7 +296,7 @@ def _map_common( _visited_stack: Set[int], skip_none_values: bool = False, fields_mapping: FieldsMap = None, - deepcopy: bool = True, + use_deepcopy: bool = True, ) -> T: """Produces output object mapped from source object and custom arguments. @@ -321,7 +307,7 @@ def _map_common( skip_none_values (bool, optional): Skip None values when creating `target class` obj. Defaults to False. fields_mapping (FieldsMap, optional): Custom mapping. Specify dictionary in format {"field_name": value_object}. Defaults to None. - deepcopy (bool, optional): Applies deepcopy to all child objects when copy into output instance. + use_deepcopy (bool, optional): Apply deepcopy to all child objects when copy from source to target object. Defaults to True. Raises: @@ -354,13 +340,11 @@ def _map_common( value = obj[field_name] # type: ignore [index] if value is not None: - if deepcopy: + if use_deepcopy: mapped_values[field_name] = self._map_subobject( value, _visited_stack, skip_none_values ) - else: - # if deepcopy is disabled, we can act as if value was a primitive type and - # avoid the ._map_subobject() call entirely. + else: # if use_deepcopy is False, simply assign value to target obj. mapped_values[field_name] = value elif not skip_none_values: mapped_values[field_name] = None diff --git a/tests/test_automapper_dict_field.py b/tests/test_automapper_dict_field.py index 3f52b25..c7d559b 100644 --- a/tests/test_automapper_dict_field.py +++ b/tests/test_automapper_dict_field.py @@ -61,8 +61,8 @@ def test_map__with_dict_field(self): self.assertEqual(public_info.products["candies"][1].name, "Snickers") self.assertEqual(public_info.products["candies"][1].brand, "Mars, Incorporated") - def test_deepcopy_disabled(self): - public_info_deep = mapper.to(ShopPublicInfo).map(self.shop, deepcopy=False) + def test_map__use_deepcopy_false(self): + public_info_deep = mapper.to(ShopPublicInfo).map(self.shop, use_deepcopy=False) public_info = mapper.to(ShopPublicInfo).map(self.shop) self.assertIsNot(public_info.products, self.shop.products) @@ -78,13 +78,3 @@ def test_deepcopy_disabled(self): id(public_info_deep.products["magazines"]), id(self.shop.products["magazines"]), ) - - def test_deepcopy_disabled_in_add(self): - self.mapper.add(Shop, ShopPublicInfo, deepcopy=False) - public_info: ShopPublicInfo = self.mapper.map(self.shop) - - self.assertIs(public_info.products, self.shop.products) - - # Manually enable deepcopy on .map() - public_info2: ShopPublicInfo = self.mapper.map(self.shop, deepcopy=True) - self.assertIsNot(public_info2.products, self.shop.products) diff --git a/tests/test_automapper_sample.py b/tests/test_automapper_sample.py index f455ebb..02842ee 100644 --- a/tests/test_automapper_sample.py +++ b/tests/test_automapper_sample.py @@ -104,12 +104,12 @@ def test_map__override_field_value_register(): mapper._mappings.clear() -def test_deepcopy(): +def test_map__check_deepcopy_not_applied_if_use_deepcopy_false(): address = Address(street="Main Street", number=1, zip_code=100001, city="Test City") info = PersonInfo("John Doe", age=35, address=address) public_info = mapper.to(PublicPersonInfo).map(info) assert address is not public_info.address - public_info = mapper.to(PublicPersonInfo).map(info, deepcopy=False) + public_info = mapper.to(PublicPersonInfo).map(info, use_deepcopy=False) assert address is public_info.address From 0463e3722df6012f215b606ecf9a42a8e7b5d5a6 Mon Sep 17 00:00:00 2001 From: Andrii Nikolaienko Date: Tue, 25 Oct 2022 12:43:17 +0300 Subject: [PATCH 7/9] incremented version. modified description and example --- CHANGELOG.md | 4 ++++ README.md | 35 ++++++++++++++++++----------------- pyproject.toml | 2 +- 3 files changed, 23 insertions(+), 18 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 2c15eaa..abba972 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,7 @@ +1.2.0 - 2022/10/25 +* [g-pichler] Ability to disable deepcopy on mapping: `use_deepcopy` flag in `map` method. +* Updated doc comments. + 1.1.3 - 2022/10/07 * [g-pichler] Added support for SQLAlchemy models mapping * Upgraded code checking tool and improved code formatting diff --git a/README.md b/README.md index f1c81f8..1cfc37b 100644 --- a/README.md +++ b/README.md @@ -3,7 +3,7 @@ # py-automapper **Version** -1.1.3 +1.2.0 **Author** anikolaienko @@ -32,6 +32,7 @@ Table of Contents: - [Usage](#usage) - [Different field names](#different-field-names) - [Overwrite field value in mapping](#overwrite-field-value-in-mapping) + - [Disable Deepcopy](#disable-deepcopy) - [Extensions](#extensions) - [Pydantic/FastAPI Support](#pydanticfastapi-support) - [TortoiseORM Support](#tortoiseorm-support) @@ -124,9 +125,10 @@ print(vars(public_user_info)) # {'full_name': 'John Cusack', 'profession': 'engineer'} ``` -## Use of Deepcopy -By default, automapper performs a recursive deepcopy() on all attributes. This makes sure that changes in the attributes of the source -do not affect the target and vice-versa: +## 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. +If you need your target and source class share same instances of child objects, set `use_deepcopy=False` in `map` function. ```python from dataclasses import dataclass @@ -134,10 +136,10 @@ from automapper import mapper @dataclass class Address: - street: str - number: int - zip_code: int - city: str + street: str + number: int + zip_code: int + city: str class PersonInfo: def __init__(self, name: str, age: int, address: Address): @@ -149,20 +151,19 @@ class PublicPersonInfo: def __init__(self, name: str, address: Address): self.name = name self.address = address - + address = Address(street="Main Street", number=1, zip_code=100001, city='Test City') info = PersonInfo('John Doe', age=35, address=address) +# default deepcopy behavior public_info = mapper.to(PublicPersonInfo).map(info) -assert address is not public_info.address -``` +print("Target public_info.address is same as source address: ", address is public_info.address) +# Target public_info.address is same as source address: False -To disable this behavior, you may pass `deepcopy=False` to either `mapper.map()` or to `mapper.add()`. If both are passed, -the argument of the `.map()` call has priority. E.g. - -```python -public_info = mapper.to(PublicPersonInfo).map(info, deepcopy=False) -assert address is public_info.address +# disable deepcopy +public_info = mapper.to(PublicPersonInfo).map(info, use_deepcopy=False) +print("Target public_info.address is same as source address: ", address is public_info.address) +# Target public_info.address is same as source address: True ``` ## Extensions diff --git a/pyproject.toml b/pyproject.toml index 262a539..8b376a1 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [tool.poetry] name = "py-automapper" -version = "1.1.3" +version = "1.2.0" description = "Library for automatically mapping one object to another" authors = ["Andrii Nikolaienko "] license = "MIT" From be0da2fdbadf4ba630e99d4c408b31c6a189cc3d Mon Sep 17 00:00:00 2001 From: Andrii Nikolaienko Date: Tue, 25 Oct 2022 12:47:28 +0300 Subject: [PATCH 8/9] removed extra code --- tests/test_automapper_dict_field.py | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/tests/test_automapper_dict_field.py b/tests/test_automapper_dict_field.py index c7d559b..8bca848 100644 --- a/tests/test_automapper_dict_field.py +++ b/tests/test_automapper_dict_field.py @@ -1,7 +1,7 @@ from typing import Any, Dict from unittest import TestCase -from automapper import create_mapper, mapper +from automapper import mapper class Candy: @@ -21,7 +21,7 @@ def __init__(self, products: Dict[str, Any]): self.products: Dict[str, Any] = products -class AutomapperTest(TestCase): +class AutomapperDictFieldTest(TestCase): def setUp(self) -> None: products = { "magazines": ["Forbes", "Time", "The New Yorker"], @@ -31,7 +31,6 @@ def setUp(self) -> None: ], } self.shop = Shop(products=products, annual_income=10000000) - self.mapper = create_mapper() def test_map__with_dict_field(self): public_info = mapper.to(ShopPublicInfo).map(self.shop) From 647b0be8d5fa454d5c3012db84ad30750c90971f Mon Sep 17 00:00:00 2001 From: Andrii Nikolaienko Date: Tue, 25 Oct 2022 13:11:32 +0300 Subject: [PATCH 9/9] improved error text --- CHANGELOG.md | 1 + automapper/mapper.py | 3 ++- 2 files changed, 3 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index abba972..a55223c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,6 @@ 1.2.0 - 2022/10/25 * [g-pichler] Ability to disable deepcopy on mapping: `use_deepcopy` flag in `map` method. +* [g-pichler] Improved error text when no spec function exists for `target class`. * Updated doc comments. 1.1.3 - 2022/10/07 diff --git a/automapper/mapper.py b/automapper/mapper.py index 5d60d43..c4c0ba2 100644 --- a/automapper/mapper.py +++ b/automapper/mapper.py @@ -242,8 +242,9 @@ def _get_fields(self, target_cls: Type[T]) -> Iterable[str]: if classifier(target_cls): return self._classifier_specs[classifier](target_cls) + target_cls_name = getattr(target_cls, "__name__", type(target_cls)) raise MappingError( - f"No spec function is added for base class of {type(target_cls)}" + f"No spec function is added for base class of {target_cls_name!r}" ) def _map_subobject(