Skip to content

Commit

Permalink
Merge pull request #12 from anikolaienko/feature/deepcopy
Browse files Browse the repository at this point in the history
Deepcopy feature
  • Loading branch information
anikolaienko authored Oct 25, 2022
2 parents 90ae460 + 647b0be commit b12f14e
Show file tree
Hide file tree
Showing 6 changed files with 232 additions and 62 deletions.
5 changes: 5 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -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
Expand Down
43 changes: 42 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@
# py-automapper

**Version**
1.1.3
1.2.0

**Author**
anikolaienko
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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:
Expand Down
134 changes: 96 additions & 38 deletions automapper/mapper.py
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand All @@ -65,19 +65,31 @@ 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,
self.__target_cls,
set(),
skip_none_values=skip_none_values,
fields_mapping=fields_mapping,
use_deepcopy=use_deepcopy,
)


Expand All @@ -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.
"""
...

Expand All @@ -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.
"""
...

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

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

Expand All @@ -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)
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.1.3"
version = "1.2.0"
description = "Library for automatically mapping one object to another"
authors = ["Andrii Nikolaienko <[email protected]>"]
license = "MIT"
Expand Down
Loading

0 comments on commit b12f14e

Please sign in to comment.