diff --git a/CHANGELOG.md b/CHANGELOG.md index 2c15eaa..a55223c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,8 @@ +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 * [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 dc2d4a3..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,6 +125,46 @@ print(vars(public_user_info)) # {'full_name': 'John Cusack', 'profession': 'engineer'} ``` +## 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 +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) + +# default deepcopy behavior +public_info = mapper.to(PublicPersonInfo).map(info) +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 + +# 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 `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..c4c0ba2 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 @@ -65,12 +65,23 @@ def map( *, skip_none_values: bool = False, fields_mapping: FieldsMap = None, + use_deepcopy: bool = True, ) -> T: - """Produces output object mapped from source object and custom arguments + """Produces output object mapped from source object and custom arguments. + + 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. + use_deepcopy (bool, optional): Apply deepcopy to all child objects when copy from source to target object. + Defaults to True. - Parameters: - skip_none_values - do not map fields that has None value - fields_mapping - mapping for fields with different names + 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, @@ -78,6 +89,7 @@ def map( set(), skip_none_values=skip_none_values, fields_mapping=fields_mapping, + use_deepcopy=use_deepcopy, ) @@ -94,10 +106,9 @@ 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. """ ... @@ -107,11 +118,10 @@ def add_spec( ) -> 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. """ ... @@ -144,14 +154,19 @@ 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` + 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. + + 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( @@ -165,8 +180,26 @@ def map( *, skip_none_values: bool = False, fields_mapping: FieldsMap = None, + use_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. + use_deepcopy (bool, optional): Apply deepcopy to all child objects when copy from source to target object. + 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}") @@ -196,6 +229,7 @@ def map( set(), skip_none_values=skip_none_values, fields_mapping=common_fields_mapping, + use_deepcopy=use_deepcopy, ) def _get_fields(self, target_cls: Type[T]) -> Iterable[str]: @@ -208,15 +242,16 @@ 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( 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) @@ -231,7 +266,7 @@ 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( @@ -262,12 +297,25 @@ def _map_common( _visited_stack: Set[int], skip_none_values: bool = False, fields_mapping: FieldsMap = None, + 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 - fields mappings for fields with different names + """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. + use_deepcopy (bool, optional): Apply deepcopy to all child objects when copy from source to target object. + 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) @@ -278,7 +326,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) @@ -293,9 +341,12 @@ 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 use_deepcopy: + mapped_values[field_name] = self._map_subobject( + value, _visited_stack, skip_none_values + ) + 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 @@ -304,5 +355,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/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" diff --git a/tests/test_automapper_dict_field.py b/tests/test_automapper_dict_field.py index c63ec49..8bca848 100644 --- a/tests/test_automapper_dict_field.py +++ b/tests/test_automapper_dict_field.py @@ -1,5 +1,5 @@ -from copy import deepcopy from typing import Any, Dict +from unittest import TestCase from automapper import mapper @@ -12,36 +12,68 @@ 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 AutomapperDictFieldTest(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) - 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_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) + 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"]), + ) diff --git a/tests/test_automapper_sample.py b/tests/test_automapper_sample.py index a320a5f..02842ee 100644 --- a/tests/test_automapper_sample.py +++ b/tests/test_automapper_sample.py @@ -1,3 +1,5 @@ +from dataclasses import dataclass + from automapper import mapper @@ -20,6 +22,27 @@ def __init__(self, full_name: str, profession: str): self.profession = profession +@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 +102,14 @@ def test_map__override_field_value_register(): assert public_user_info.profession == "engineer" finally: mapper._mappings.clear() + + +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, use_deepcopy=False) + assert address is public_info.address