Skip to content

Commit

Permalink
Merge pull request #16 from anikolaienko/bugfix/mapping_dict_to_obj
Browse files Browse the repository at this point in the history
fixed mapping dict to obj, simplified code
  • Loading branch information
anikolaienko authored Nov 13, 2022
2 parents b9ce4be + 161a6c9 commit 6653f86
Show file tree
Hide file tree
Showing 8 changed files with 101 additions and 38 deletions.
12 changes: 12 additions & 0 deletions .coveragerc
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
[run]
branch = True
omit =
*/__init__.py
tests/*

[report]
show_missing = True
fail_under = 94

[html]
directory = docs/coverage
5 changes: 4 additions & 1 deletion .github/workflows/run_code_checks.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
4 changes: 3 additions & 1 deletion .vscode/settings.json
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,9 @@
"hasattr",
"isclass",
"multimap",
"pyautomapper"
"pyautomapper",
"subobject",
"subobjects"
],
"python.testing.pytestArgs": [],
"python.testing.unittestEnabled": false,
Expand Down
4 changes: 4 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -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`.
Expand Down
26 changes: 22 additions & 4 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand All @@ -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
Expand All @@ -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
Expand Down
69 changes: 38 additions & 31 deletions automapper/mapper.py
Original file line number Diff line number Diff line change
Expand Up @@ -38,15 +38,31 @@ 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:
"""Check if object type is primitive"""
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:
```
Expand Down Expand Up @@ -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,
)

Expand Down Expand Up @@ -203,17 +219,17 @@ 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]

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) :])
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()
}
Expand All @@ -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,
)

Expand Down Expand Up @@ -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.
Expand All @@ -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.
Expand All @@ -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)

Expand Down
2 changes: 1 addition & 1 deletion pyproject.toml
Original file line number Diff line number Diff line change
@@ -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 <[email protected]>"]
license = "MIT"
Expand Down
17 changes: 17 additions & 0 deletions tests/test_subscriptable_obj_mapping.py
Original file line number Diff line number Diff line change
@@ -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")

0 comments on commit 6653f86

Please sign in to comment.