diff --git a/.coveragerc b/.coveragerc new file mode 100644 index 0000000..5f56f4e --- /dev/null +++ b/.coveragerc @@ -0,0 +1,12 @@ +[run] +branch = True +omit = + */__init__.py + tests/* + +[report] +show_missing = True +fail_under = 94 + +[html] +directory = docs/coverage diff --git a/.github/workflows/run_code_checks.yml b/.github/workflows/run_code_checks.yml index bdd4a01..7215413 100644 --- a/.github/workflows/run_code_checks.yml +++ b/.github/workflows/run_code_checks.yml @@ -35,4 +35,7 @@ jobs: run: poetry run pre-commit run --all-files - name: Run unit tests - run: poetry run pytest tests/ + run: poetry run coverage run -m pytest tests/ + + - name: Check unit test coverage + run: poetry run coverage report diff --git a/.vscode/settings.json b/.vscode/settings.json index 9d79c6c..48b771d 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -4,7 +4,9 @@ "hasattr", "isclass", "multimap", - "pyautomapper" + "pyautomapper", + "subobject", + "subobjects" ], "python.testing.pytestArgs": [], "python.testing.unittestEnabled": false, diff --git a/CHANGELOG.md b/CHANGELOG.md index a55223c..ed64f41 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,7 @@ +1.2.1 - 2022/11/13 +* Fixed dictionary source mapping to target object. +* Implemented CI checks + 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`. diff --git a/README.md b/README.md index aab9d6d..8960f95 100644 --- a/README.md +++ b/README.md @@ -13,6 +13,9 @@ Table of Contents: - [About](#about) - [Contribute](#contribute) - [Usage](#usage) + - [Installation](#installation) + - [Get started](#get-started) + - [Map dictionary source to target object](#map-dictionary-source-to-target-object) - [Different field names](#different-field-names) - [Overwrite field value in mapping](#overwrite-field-value-in-mapping) - [Disable Deepcopy](#disable-deepcopy) @@ -37,25 +40,27 @@ The major advantage of py-automapper is its extensibility, that allows it to map Read [CONTRIBUTING.md](/CONTRIBUTING.md) guide. # Usage +## Installation Install package: ```bash pip install py-automapper ``` -Let's say we have domain model `UserInfo` and its API representation `PublicUserInfo` with `age` field missing: +## Get started +Let's say we have domain model `UserInfo` and its API representation `PublicUserInfo` without exposing user `age`: ```python class UserInfo: - def __init__(self, name: str, age: int, profession: str): + def __init__(self, name: str, profession: str, age: int): self.name = name - self.age = age self.profession = profession + self.age = age class PublicUserInfo: def __init__(self, name: str, profession: str): self.name = name self.profession = profession -user_info = UserInfo("John Malkovich", 35, "engineer") +user_info = UserInfo("John Malkovich", "engineer", 35) ``` To create `PublicUserInfo` object: ```python @@ -77,6 +82,19 @@ print(vars(public_user_info)) # {'name': 'John Malkovich', 'profession': 'engineer'} ``` +## Map dictionary source to target object +If source object is dictionary: +```python +source = { + "name": "John Carter", + "profession": "hero" +} +public_info = mapper.to(PublicUserInfo).map(source) + +print(vars(public_info)) +# {'name': 'John Carter', 'profession': 'hero'} +``` + ## Different field names If your target class field name is different from source class. ```python diff --git a/automapper/mapper.py b/automapper/mapper.py index c4c0ba2..b4f8b57 100644 --- a/automapper/mapper.py +++ b/automapper/mapper.py @@ -38,8 +38,12 @@ def _is_sequence(obj: Any) -> bool: def _is_subscriptable(obj: Any) -> bool: - """Check if object implements `__get_item__` method""" - return hasattr(obj, "__get_item__") + """Check if object implements `__getitem__` method""" + return hasattr(obj, "__getitem__") + + +def _object_contains(obj: Any, field_name: str) -> bool: + return _is_subscriptable(obj) and field_name in obj def _is_primitive(obj: Any) -> bool: @@ -47,6 +51,18 @@ def _is_primitive(obj: Any) -> bool: return type(obj) in __PRIMITIVE_TYPES +def _try_get_field_value( + field_name: str, original_obj: Any, custom_mapping: FieldsMap +) -> Tuple[bool, Any]: + if field_name in (custom_mapping or {}): + return True, custom_mapping[field_name] # type: ignore [index] + if hasattr(original_obj, field_name): + return True, getattr(original_obj, field_name) + if _object_contains(original_obj, field_name): + return True, original_obj[field_name] + return False, None + + class MappingWrapper(Generic[T]): """Internal wrapper for supporting syntax: ``` @@ -88,7 +104,7 @@ def map( self.__target_cls, set(), skip_none_values=skip_none_values, - fields_mapping=fields_mapping, + custom_mapping=fields_mapping, use_deepcopy=use_deepcopy, ) @@ -203,7 +219,7 @@ def map( 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__}." + obj_type_prefix = f"{obj_type.__name__}." target_cls, target_cls_field_mappings = self._mappings[obj_type] @@ -211,9 +227,9 @@ def map( 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) :]) + target_obj_field: getattr(obj, source_field[len(obj_type_prefix) :]) if isinstance(source_field, str) - and source_field.startswith(obj_type_preffix) + and source_field.startswith(obj_type_prefix) else source_field for target_obj_field, source_field in target_cls_field_mappings.items() } @@ -228,7 +244,7 @@ def map( target_cls, set(), skip_none_values=skip_none_values, - fields_mapping=common_fields_mapping, + custom_mapping=common_fields_mapping, use_deepcopy=use_deepcopy, ) @@ -296,7 +312,7 @@ def _map_common( target_cls: Type[T], _visited_stack: Set[int], skip_none_values: bool = False, - fields_mapping: FieldsMap = None, + custom_mapping: FieldsMap = None, use_deepcopy: bool = True, ) -> T: """Produces output object mapped from source object and custom arguments. @@ -306,7 +322,7 @@ def _map_common( 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. + custom_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. @@ -326,29 +342,20 @@ def _map_common( target_cls_fields = self._get_fields(target_cls) mapped_values: Dict[str, Any] = {} - is_obj_subscriptable = _is_subscriptable(obj) for field_name in target_cls_fields: - if ( - (fields_mapping and field_name in fields_mapping) - or hasattr(obj, field_name) - or (is_obj_subscriptable and field_name in obj) # type: ignore [operator] - ): - if fields_mapping and field_name in fields_mapping: - value = fields_mapping[field_name] - elif hasattr(obj, field_name): - value = getattr(obj, field_name) - else: - value = obj[field_name] # type: ignore [index] - - if value is not None: - 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 + value_found, value = _try_get_field_value(field_name, obj, custom_mapping) + if not value_found: + continue + + if value is not None: + 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 _visited_stack.remove(obj_id) diff --git a/pyproject.toml b/pyproject.toml index a5a26fc..0344362 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [tool.poetry] name = "py-automapper" -version = "1.2.0" +version = "1.2.1" description = "Library for automatically mapping one object to another" authors = ["Andrii Nikolaienko "] license = "MIT" diff --git a/tests/test_subscriptable_obj_mapping.py b/tests/test_subscriptable_obj_mapping.py new file mode 100644 index 0000000..e3adffc --- /dev/null +++ b/tests/test_subscriptable_obj_mapping.py @@ -0,0 +1,17 @@ +from automapper import mapper + + +class PublicUserInfo(object): + def __init__(self, name: str, profession: str): + self.name = name + self.profession = profession + + +def test_map__dict_to_object(): + original = {"name": "John Carter", "age": 35, "profession": "hero"} + + public_info = mapper.to(PublicUserInfo).map(original) + + assert hasattr(public_info, "name") and public_info.name == "John Carter" + assert hasattr(public_info, "profession") and public_info.profession == "hero" + assert not hasattr(public_info, "age")