Skip to content

Commit b12f14e

Browse files
authored
Merge pull request #12 from anikolaienko/feature/deepcopy
Deepcopy feature
2 parents 90ae460 + 647b0be commit b12f14e

6 files changed

+232
-62
lines changed

CHANGELOG.md

+5
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,8 @@
1+
1.2.0 - 2022/10/25
2+
* [g-pichler] Ability to disable deepcopy on mapping: `use_deepcopy` flag in `map` method.
3+
* [g-pichler] Improved error text when no spec function exists for `target class`.
4+
* Updated doc comments.
5+
16
1.1.3 - 2022/10/07
27
* [g-pichler] Added support for SQLAlchemy models mapping
38
* Upgraded code checking tool and improved code formatting

README.md

+42-1
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@
33
# py-automapper
44

55
**Version**
6-
1.1.3
6+
1.2.0
77

88
**Author**
99
anikolaienko
@@ -32,6 +32,7 @@ Table of Contents:
3232
- [Usage](#usage)
3333
- [Different field names](#different-field-names)
3434
- [Overwrite field value in mapping](#overwrite-field-value-in-mapping)
35+
- [Disable Deepcopy](#disable-deepcopy)
3536
- [Extensions](#extensions)
3637
- [Pydantic/FastAPI Support](#pydanticfastapi-support)
3738
- [TortoiseORM Support](#tortoiseorm-support)
@@ -124,6 +125,46 @@ print(vars(public_user_info))
124125
# {'full_name': 'John Cusack', 'profession': 'engineer'}
125126
```
126127

128+
## Disable Deepcopy
129+
By default, py-automapper performs a recursive `copy.deepcopy()` call on all attributes when copying from source object into target class instance.
130+
This makes sure that changes in the attributes of the source do not affect the target and vice versa.
131+
If you need your target and source class share same instances of child objects, set `use_deepcopy=False` in `map` function.
132+
133+
```python
134+
from dataclasses import dataclass
135+
from automapper import mapper
136+
137+
@dataclass
138+
class Address:
139+
street: str
140+
number: int
141+
zip_code: int
142+
city: str
143+
144+
class PersonInfo:
145+
def __init__(self, name: str, age: int, address: Address):
146+
self.name = name
147+
self.age = age
148+
self.address = address
149+
150+
class PublicPersonInfo:
151+
def __init__(self, name: str, address: Address):
152+
self.name = name
153+
self.address = address
154+
155+
address = Address(street="Main Street", number=1, zip_code=100001, city='Test City')
156+
info = PersonInfo('John Doe', age=35, address=address)
157+
158+
# default deepcopy behavior
159+
public_info = mapper.to(PublicPersonInfo).map(info)
160+
print("Target public_info.address is same as source address: ", address is public_info.address)
161+
# Target public_info.address is same as source address: False
162+
163+
# disable deepcopy
164+
public_info = mapper.to(PublicPersonInfo).map(info, use_deepcopy=False)
165+
print("Target public_info.address is same as source address: ", address is public_info.address)
166+
# Target public_info.address is same as source address: True
167+
```
127168

128169
## Extensions
129170
`py-automapper` has few predefined extensions for mapping support to classes for frameworks:

automapper/mapper.py

+96-38
Original file line numberDiff line numberDiff line change
@@ -32,17 +32,17 @@
3232
__PRIMITIVE_TYPES = {int, float, complex, str, bytes, bytearray, bool}
3333

3434

35-
def is_sequence(obj: Any) -> bool:
35+
def _is_sequence(obj: Any) -> bool:
3636
"""Check if object implements `__iter__` method"""
3737
return hasattr(obj, "__iter__")
3838

3939

40-
def is_subscriptable(obj: Any) -> bool:
40+
def _is_subscriptable(obj: Any) -> bool:
4141
"""Check if object implements `__get_item__` method"""
4242
return hasattr(obj, "__get_item__")
4343

4444

45-
def is_primitive(obj: Any) -> bool:
45+
def _is_primitive(obj: Any) -> bool:
4646
"""Check if object type is primitive"""
4747
return type(obj) in __PRIMITIVE_TYPES
4848

@@ -65,19 +65,31 @@ def map(
6565
*,
6666
skip_none_values: bool = False,
6767
fields_mapping: FieldsMap = None,
68+
use_deepcopy: bool = True,
6869
) -> T:
69-
"""Produces output object mapped from source object and custom arguments
70+
"""Produces output object mapped from source object and custom arguments.
71+
72+
Args:
73+
obj (S): _description_
74+
skip_none_values (bool, optional): Skip None values when creating `target class` obj. Defaults to False.
75+
fields_mapping (FieldsMap, optional): Custom mapping.
76+
Specify dictionary in format {"field_name": value_object}. Defaults to None.
77+
use_deepcopy (bool, optional): Apply deepcopy to all child objects when copy from source to target object.
78+
Defaults to True.
7079
71-
Parameters:
72-
skip_none_values - do not map fields that has None value
73-
fields_mapping - mapping for fields with different names
80+
Raises:
81+
CircularReferenceError: Circular references in `source class` object are not allowed yet.
82+
83+
Returns:
84+
T: instance of `target class` with mapped values from `source class` or custom `fields_mapping` dictionary.
7485
"""
7586
return self.__mapper._map_common(
7687
obj,
7788
self.__target_cls,
7889
set(),
7990
skip_none_values=skip_none_values,
8091
fields_mapping=fields_mapping,
92+
use_deepcopy=use_deepcopy,
8193
)
8294

8395

@@ -94,10 +106,9 @@ def __init__(self) -> None:
94106
def add_spec(self, classifier: Type[T], spec_func: SpecFunction[T]) -> None:
95107
"""Add a spec function for all classes in inherited from base class.
96108
97-
Parameters:
98-
* classifier - base class to identify all descendant classes
99-
* spec_func - returns a list of fields (List[str]) for target class
100-
that are accepted in constructor
109+
Args:
110+
classifier (ClassifierFunction[T]): base class to identify all descendant classes.
111+
spec_func (SpecFunction[T]): get list of fields (List[str]) for `target class` to be passed in constructor.
101112
"""
102113
...
103114

@@ -107,11 +118,10 @@ def add_spec(
107118
) -> None:
108119
"""Add a spec function for all classes identified by classifier function.
109120
110-
Parameters:
111-
* classifier - boolean predicate that identifies a group of classes
112-
by certain characteristics: if class has a specific method or a field, etc.
113-
* spec_func - returns a list of fields (List[str]) for target class
114-
that are accepted in constructor
121+
Args:
122+
classifier (ClassifierFunction[T]): boolean predicate that identifies a group of classes
123+
by certain characteristics: if class has a specific method or a field, etc.
124+
spec_func (SpecFunction[T]): get list of fields (List[str]) for `target class` to be passed in constructor.
115125
"""
116126
...
117127

@@ -144,14 +154,19 @@ def add(
144154
) -> None:
145155
"""Adds mapping between object of `source class` to an object of `target class`.
146156
147-
Parameters
148-
----------
149-
source_cls : Type
150-
Source class to map from
151-
target_cls : Type
152-
Target class to map to
153-
override : bool, optional
154-
Override existing `source class` mapping to use new `target class`
157+
Args:
158+
source_cls (Type[S]): Source class to map from
159+
target_cls (Type[T]): Target class to map to
160+
override (bool, optional): Override existing `source class` mapping to use new `target class`.
161+
Defaults to False.
162+
fields_mapping (FieldsMap, optional): Custom mapping.
163+
Specify dictionary in format {"field_name": value_object}. Defaults to None.
164+
165+
Raises:
166+
DuplicatedRegistrationError: Same mapping for `source class` was added.
167+
Only one mapping per source class can exist at a time for now.
168+
You can specify target class manually using `mapper.to(target_cls)` method
169+
or use `override` argument to replace existing mapping.
155170
"""
156171
if source_cls in self._mappings and not override:
157172
raise DuplicatedRegistrationError(
@@ -165,8 +180,26 @@ def map(
165180
*,
166181
skip_none_values: bool = False,
167182
fields_mapping: FieldsMap = None,
183+
use_deepcopy: bool = True,
168184
) -> T: # type: ignore [type-var]
169-
"""Produces output object mapped from source object and custom arguments"""
185+
"""Produces output object mapped from source object and custom arguments
186+
187+
Args:
188+
obj (object): Source object to map to `target class`.
189+
skip_none_values (bool, optional): Skip None values when creating `target class` obj. Defaults to False.
190+
fields_mapping (FieldsMap, optional): Custom mapping.
191+
Specify dictionary in format {"field_name": value_object}. Defaults to None.
192+
use_deepcopy (bool, optional): Apply deepcopy to all child objects when copy from source to target object.
193+
Defaults to True.
194+
195+
Raises:
196+
MappingError: No `target class` specified to be mapped into.
197+
Register mappings using `mapped.add(...)` or specify `target class` using `mapper.to(target_cls).map()`.
198+
CircularReferenceError: Circular references in `source class` object are not allowed yet.
199+
200+
Returns:
201+
T: instance of `target class` with mapped values from `source class` or custom `fields_mapping` dictionary.
202+
"""
170203
obj_type = type(obj)
171204
if obj_type not in self._mappings:
172205
raise MappingError(f"Missing mapping type for input type {obj_type}")
@@ -196,6 +229,7 @@ def map(
196229
set(),
197230
skip_none_values=skip_none_values,
198231
fields_mapping=common_fields_mapping,
232+
use_deepcopy=use_deepcopy,
199233
)
200234

201235
def _get_fields(self, target_cls: Type[T]) -> Iterable[str]:
@@ -208,15 +242,16 @@ def _get_fields(self, target_cls: Type[T]) -> Iterable[str]:
208242
if classifier(target_cls):
209243
return self._classifier_specs[classifier](target_cls)
210244

245+
target_cls_name = getattr(target_cls, "__name__", type(target_cls))
211246
raise MappingError(
212-
f"No spec function is added for base class of {type(target_cls)}"
247+
f"No spec function is added for base class of {target_cls_name!r}"
213248
)
214249

215250
def _map_subobject(
216251
self, obj: S, _visited_stack: Set[int], skip_none_values: bool = False
217252
) -> Any:
218253
"""Maps subobjects recursively"""
219-
if is_primitive(obj):
254+
if _is_primitive(obj):
220255
return obj
221256

222257
obj_id = id(obj)
@@ -231,7 +266,7 @@ def _map_subobject(
231266
else:
232267
_visited_stack.add(obj_id)
233268

234-
if is_sequence(obj):
269+
if _is_sequence(obj):
235270
if isinstance(obj, dict):
236271
result = {
237272
k: self._map_subobject(
@@ -262,12 +297,25 @@ def _map_common(
262297
_visited_stack: Set[int],
263298
skip_none_values: bool = False,
264299
fields_mapping: FieldsMap = None,
300+
use_deepcopy: bool = True,
265301
) -> T:
266-
"""Produces output object mapped from source object and custom arguments
267-
268-
Parameters:
269-
skip_none_values - do not map fields that has None value
270-
fields_mapping - fields mappings for fields with different names
302+
"""Produces output object mapped from source object and custom arguments.
303+
304+
Args:
305+
obj (S): Source object to map to `target class`.
306+
target_cls (Type[T]): Target class to map to.
307+
_visited_stack (Set[int]): Visited child objects. To avoid infinite recursive calls.
308+
skip_none_values (bool, optional): Skip None values when creating `target class` obj. Defaults to False.
309+
fields_mapping (FieldsMap, optional): Custom mapping.
310+
Specify dictionary in format {"field_name": value_object}. Defaults to None.
311+
use_deepcopy (bool, optional): Apply deepcopy to all child objects when copy from source to target object.
312+
Defaults to True.
313+
314+
Raises:
315+
CircularReferenceError: Circular references in `source class` object are not allowed yet.
316+
317+
Returns:
318+
T: Instance of `target class` with mapped fields.
271319
"""
272320
obj_id = id(obj)
273321

@@ -278,7 +326,7 @@ def _map_common(
278326
target_cls_fields = self._get_fields(target_cls)
279327

280328
mapped_values: Dict[str, Any] = {}
281-
is_obj_subscriptable = is_subscriptable(obj)
329+
is_obj_subscriptable = _is_subscriptable(obj)
282330
for field_name in target_cls_fields:
283331
if (
284332
(fields_mapping and field_name in fields_mapping)
@@ -293,9 +341,12 @@ def _map_common(
293341
value = obj[field_name] # type: ignore [index]
294342

295343
if value is not None:
296-
mapped_values[field_name] = self._map_subobject(
297-
value, _visited_stack, skip_none_values
298-
)
344+
if use_deepcopy:
345+
mapped_values[field_name] = self._map_subobject(
346+
value, _visited_stack, skip_none_values
347+
)
348+
else: # if use_deepcopy is False, simply assign value to target obj.
349+
mapped_values[field_name] = value
299350
elif not skip_none_values:
300351
mapped_values[field_name] = None
301352

@@ -304,5 +355,12 @@ def _map_common(
304355
return cast(target_cls, target_cls(**mapped_values)) # type: ignore [valid-type]
305356

306357
def to(self, target_cls: Type[T]) -> MappingWrapper[T]:
307-
"""Specify target class to map source object to"""
358+
"""Specify `target class` to which map `source class` object.
359+
360+
Args:
361+
target_cls (Type[T]): Target class.
362+
363+
Returns:
364+
MappingWrapper[T]: Mapping wrapper. Use `map` method to perform mapping now.
365+
"""
308366
return MappingWrapper[T](self, target_cls)

pyproject.toml

+1-1
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
[tool.poetry]
22
name = "py-automapper"
3-
version = "1.1.3"
3+
version = "1.2.0"
44
description = "Library for automatically mapping one object to another"
55
authors = ["Andrii Nikolaienko <[email protected]>"]
66
license = "MIT"

0 commit comments

Comments
 (0)