Skip to content

Commit 4c93bf1

Browse files
authored
Merge pull request #10 from g-pichler/deepcopy
Deepcopy
2 parents 90ae460 + 0dde3b5 commit 4c93bf1

File tree

9 files changed

+164
-67
lines changed

9 files changed

+164
-67
lines changed

README.md

Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -124,6 +124,46 @@ print(vars(public_user_info))
124124
# {'full_name': 'John Cusack', 'profession': 'engineer'}
125125
```
126126

127+
## Use of Deepcopy
128+
By default, automapper performs a recursive deepcopy() on all attributes. This makes sure that changes in the attributes of the source
129+
do not affect the target and vice-versa:
130+
131+
```python
132+
from dataclasses import dataclass
133+
from automapper import mapper
134+
135+
@dataclass
136+
class Address:
137+
street: str
138+
number: int
139+
zip_code: int
140+
city: str
141+
142+
class PersonInfo:
143+
def __init__(self, name: str, age: int, address: Address):
144+
self.name = name
145+
self.age = age
146+
self.address = address
147+
148+
class PublicPersonInfo:
149+
def __init__(self, name: str, address: Address):
150+
self.name = name
151+
self.address = address
152+
153+
address = Address(street="Main Street", number=1, zip_code=100001, city='Test City')
154+
info = PersonInfo('John Doe', age=35, address=address)
155+
156+
public_info = mapper.to(PublicPersonInfo).map(info)
157+
assert address is not public_info.address
158+
```
159+
160+
To disable this behavior, you may pass `deepcopy=False` to either `mapper.map()` or to `mapper.add()`. If both are passed,
161+
the argument of the `.map()` call has priority. E.g.
162+
163+
```python
164+
public_info = mapper.to(PublicPersonInfo).map(info, deepcopy=False)
165+
assert address is public_info.address
166+
```
127167

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

automapper/exceptions.py

Lines changed: 1 addition & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,4 @@ class MappingError(Exception):
88

99
class CircularReferenceError(Exception):
1010
def __init__(self, *args: object) -> None:
11-
super().__init__(
12-
"Mapper does not support objects with circular references yet", *args
13-
)
11+
super().__init__("Mapper does not support objects with circular references yet", *args)

automapper/extensions/default.py

Lines changed: 1 addition & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -11,9 +11,7 @@ def __init_method_classifier__(target_cls: Type[T]) -> bool:
1111
return (
1212
hasattr(target_cls, "__init__")
1313
and hasattr(getattr(target_cls, "__init__"), "__annotations__")
14-
and isinstance(
15-
getattr(getattr(target_cls, "__init__"), "__annotations__"), dict
16-
)
14+
and isinstance(getattr(getattr(target_cls, "__init__"), "__annotations__"), dict)
1715
and getattr(getattr(target_cls, "__init__"), "__annotations__")
1816
)
1917

automapper/mapper.py

Lines changed: 30 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -65,26 +65,29 @@ def map(
6565
*,
6666
skip_none_values: bool = False,
6767
fields_mapping: FieldsMap = None,
68+
deepcopy: bool = True,
6869
) -> T:
6970
"""Produces output object mapped from source object and custom arguments
7071
7172
Parameters:
7273
skip_none_values - do not map fields that has None value
7374
fields_mapping - mapping for fields with different names
75+
deepcopy - should we deepcopy all attributes? [default: True]
7476
"""
7577
return self.__mapper._map_common(
7678
obj,
7779
self.__target_cls,
7880
set(),
7981
skip_none_values=skip_none_values,
8082
fields_mapping=fields_mapping,
83+
deepcopy=deepcopy,
8184
)
8285

8386

8487
class Mapper:
8588
def __init__(self) -> None:
8689
"""Initializes internal containers"""
87-
self._mappings: Dict[Type[S], Tuple[T, FieldsMap]] = {} # type: ignore [valid-type]
90+
self._mappings: Dict[Type[S], Tuple[T, FieldsMap, bool]] = {} # type: ignore [valid-type]
8891
self._class_specs: Dict[Type[T], SpecFunction[T]] = {} # type: ignore [valid-type]
8992
self._classifier_specs: Dict[ # type: ignore [valid-type]
9093
ClassifierFunction[T], SpecFunction[T]
@@ -102,9 +105,7 @@ def add_spec(self, classifier: Type[T], spec_func: SpecFunction[T]) -> None:
102105
...
103106

104107
@overload
105-
def add_spec(
106-
self, classifier: ClassifierFunction[T], spec_func: SpecFunction[T]
107-
) -> None:
108+
def add_spec(self, classifier: ClassifierFunction[T], spec_func: SpecFunction[T]) -> None:
108109
"""Add a spec function for all classes identified by classifier function.
109110
110111
Parameters:
@@ -141,6 +142,7 @@ def add(
141142
target_cls: Type[T],
142143
override: bool = False,
143144
fields_mapping: FieldsMap = None,
145+
deepcopy: bool = True,
144146
) -> None:
145147
"""Adds mapping between object of `source class` to an object of `target class`.
146148
@@ -152,35 +154,37 @@ def add(
152154
Target class to map to
153155
override : bool, optional
154156
Override existing `source class` mapping to use new `target class`
157+
deepcopy : bool, optional
158+
Should we deepcopy all attributes? [default: True]
155159
"""
156160
if source_cls in self._mappings and not override:
157161
raise DuplicatedRegistrationError(
158162
f"source_cls {source_cls} was already added for mapping"
159163
)
160-
self._mappings[source_cls] = (target_cls, fields_mapping)
164+
self._mappings[source_cls] = (target_cls, fields_mapping, deepcopy)
161165

162166
def map(
163167
self,
164168
obj: object,
165169
*,
166170
skip_none_values: bool = False,
167171
fields_mapping: FieldsMap = None,
172+
deepcopy: bool = None,
168173
) -> T: # type: ignore [type-var]
169174
"""Produces output object mapped from source object and custom arguments"""
170175
obj_type = type(obj)
171176
if obj_type not in self._mappings:
172177
raise MappingError(f"Missing mapping type for input type {obj_type}")
173178
obj_type_preffix = f"{obj_type.__name__}."
174179

175-
target_cls, target_cls_field_mappings = self._mappings[obj_type]
180+
target_cls, target_cls_field_mappings, target_deepcopy = self._mappings[obj_type]
176181

177182
common_fields_mapping = fields_mapping
178183
if target_cls_field_mappings:
179184
# transform mapping if it's from source class field
180185
common_fields_mapping = {
181186
target_obj_field: getattr(obj, source_field[len(obj_type_preffix) :])
182-
if isinstance(source_field, str)
183-
and source_field.startswith(obj_type_preffix)
187+
if isinstance(source_field, str) and source_field.startswith(obj_type_preffix)
184188
else source_field
185189
for target_obj_field, source_field in target_cls_field_mappings.items()
186190
}
@@ -190,12 +194,17 @@ def map(
190194
**fields_mapping,
191195
} # merge two dict into one, fields_mapping has priority
192196

197+
# If deepcopy is not explicitly given, we use target_deepcopy
198+
if deepcopy is None:
199+
deepcopy = target_deepcopy
200+
193201
return self._map_common(
194202
obj,
195203
target_cls,
196204
set(),
197205
skip_none_values=skip_none_values,
198206
fields_mapping=common_fields_mapping,
207+
deepcopy=deepcopy,
199208
)
200209

201210
def _get_fields(self, target_cls: Type[T]) -> Iterable[str]:
@@ -208,9 +217,7 @@ def _get_fields(self, target_cls: Type[T]) -> Iterable[str]:
208217
if classifier(target_cls):
209218
return self._classifier_specs[classifier](target_cls)
210219

211-
raise MappingError(
212-
f"No spec function is added for base class of {type(target_cls)}"
213-
)
220+
raise MappingError(f"No spec function is added for base class of {type(target_cls)}")
214221

215222
def _map_subobject(
216223
self, obj: S, _visited_stack: Set[int], skip_none_values: bool = False
@@ -224,7 +231,7 @@ def _map_subobject(
224231
raise CircularReferenceError()
225232

226233
if type(obj) in self._mappings:
227-
target_cls, _ = self._mappings[type(obj)]
234+
target_cls, _, _ = self._mappings[type(obj)]
228235
result: Any = self._map_common(
229236
obj, target_cls, _visited_stack, skip_none_values=skip_none_values
230237
)
@@ -234,9 +241,7 @@ def _map_subobject(
234241
if is_sequence(obj):
235242
if isinstance(obj, dict):
236243
result = {
237-
k: self._map_subobject(
238-
v, _visited_stack, skip_none_values=skip_none_values
239-
)
244+
k: self._map_subobject(v, _visited_stack, skip_none_values=skip_none_values)
240245
for k, v in obj.items()
241246
}
242247
else:
@@ -262,12 +267,14 @@ def _map_common(
262267
_visited_stack: Set[int],
263268
skip_none_values: bool = False,
264269
fields_mapping: FieldsMap = None,
270+
deepcopy: bool = True,
265271
) -> T:
266272
"""Produces output object mapped from source object and custom arguments
267273
268274
Parameters:
269275
skip_none_values - do not map fields that has None value
270276
fields_mapping - fields mappings for fields with different names
277+
deepcopy - Should we deepcopy all attributes? [default: True]
271278
"""
272279
obj_id = id(obj)
273280

@@ -293,9 +300,14 @@ def _map_common(
293300
value = obj[field_name] # type: ignore [index]
294301

295302
if value is not None:
296-
mapped_values[field_name] = self._map_subobject(
297-
value, _visited_stack, skip_none_values
298-
)
303+
if deepcopy:
304+
mapped_values[field_name] = self._map_subobject(
305+
value, _visited_stack, skip_none_values
306+
)
307+
else:
308+
# if deepcopy is disabled, we can act as if value was a primitive type and
309+
# avoid the ._map_subobject() call entirely.
310+
mapped_values[field_name] = value
299311
elif not skip_none_values:
300312
mapped_values[field_name] = None
301313

tests/test_automapper.py

Lines changed: 3 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -20,9 +20,7 @@ def __init__(self, num: int, text: str, flag: bool) -> None:
2020

2121
@classmethod
2222
def fields(cls) -> Iterable[str]:
23-
return (
24-
field for field in cls.__init__.__annotations__.keys() if field != "return"
25-
)
23+
return (field for field in cls.__init__.__annotations__.keys() if field != "return")
2624

2725

2826
class AnotherClass:
@@ -68,16 +66,12 @@ def setUp(self):
6866
def test_add_spec__adds_to_internal_collection(self):
6967
self.mapper.add_spec(ParentClass, custom_spec_func)
7068
assert ParentClass in self.mapper._class_specs
71-
assert ["num", "text", "flag"] == self.mapper._class_specs[ParentClass](
72-
ChildClass
73-
)
69+
assert ["num", "text", "flag"] == self.mapper._class_specs[ParentClass](ChildClass)
7470

7571
def test_add_spec__error_on_adding_same_class_spec(self):
7672
self.mapper.add_spec(ParentClass, custom_spec_func)
7773
with pytest.raises(DuplicatedRegistrationError):
78-
self.mapper.add_spec(
79-
ParentClass, lambda concrete_type: ["field1", "field2"]
80-
)
74+
self.mapper.add_spec(ParentClass, lambda concrete_type: ["field1", "field2"])
8175

8276
def test_add_spec__adds_to_internal_collection_for_classifier(self):
8377
self.mapper.add_spec(classifier_func, spec_func)
Lines changed: 51 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
1-
from copy import deepcopy
21
from typing import Any, Dict
2+
from unittest import TestCase
33

4-
from automapper import mapper
4+
from automapper import mapper, create_mapper
55

66

77
class Candy:
@@ -12,36 +12,64 @@ def __init__(self, name: str, brand: str):
1212

1313
class Shop:
1414
def __init__(self, products: Dict[str, Any], annual_income: int):
15-
self.products: Dict[str, Any] = deepcopy(products)
15+
self.products: Dict[str, Any] = products
1616
self.annual_income = annual_income
1717

1818

1919
class ShopPublicInfo:
2020
def __init__(self, products: Dict[str, Any]):
21-
self.products: Dict[str, Any] = deepcopy(products)
21+
self.products: Dict[str, Any] = products
2222

2323

24-
def test_map__with_dict_field():
25-
products = {
26-
"magazines": ["Forbes", "Time", "The New Yorker"],
27-
"candies": [
28-
Candy("Reese's cups", "The Hershey Company"),
29-
Candy("Snickers", "Mars, Incorporated"),
30-
],
31-
}
32-
shop = Shop(products=products, annual_income=10000000)
24+
class AutomapperTest(TestCase):
25+
def setUp(self) -> None:
26+
products = {
27+
"magazines": ["Forbes", "Time", "The New Yorker"],
28+
"candies": [
29+
Candy("Reese's cups", "The Hershey Company"),
30+
Candy("Snickers", "Mars, Incorporated"),
31+
],
32+
}
33+
self.shop = Shop(products=products, annual_income=10000000)
34+
self.mapper = create_mapper()
3335

34-
public_info = mapper.to(ShopPublicInfo).map(shop)
36+
def test_map__with_dict_field(self):
37+
public_info = mapper.to(ShopPublicInfo).map(self.shop)
3538

36-
assert public_info.products["magazines"] == shop.products["magazines"]
37-
assert id(public_info.products["magazines"]) != id(shop.products["magazines"])
39+
self.assertEqual(public_info.products["magazines"], self.shop.products["magazines"])
40+
self.assertNotEqual(
41+
id(public_info.products["magazines"]), id(self.shop.products["magazines"])
42+
)
3843

39-
assert public_info.products["candies"] != shop.products["candies"]
40-
assert public_info.products["candies"][0] != shop.products["candies"][0]
41-
assert public_info.products["candies"][1] != shop.products["candies"][1]
44+
self.assertNotEqual(public_info.products["candies"], self.shop.products["candies"])
45+
self.assertNotEqual(public_info.products["candies"][0], self.shop.products["candies"][0])
46+
self.assertNotEqual(public_info.products["candies"][1], self.shop.products["candies"][1])
4247

43-
assert public_info.products["candies"][0].name == "Reese's cups"
44-
assert public_info.products["candies"][0].brand == "The Hershey Company"
48+
self.assertEqual(public_info.products["candies"][0].name, "Reese's cups")
49+
self.assertEqual(public_info.products["candies"][0].brand, "The Hershey Company")
4550

46-
assert public_info.products["candies"][1].name == "Snickers"
47-
assert public_info.products["candies"][1].brand == "Mars, Incorporated"
51+
self.assertEqual(public_info.products["candies"][1].name, "Snickers")
52+
self.assertEqual(public_info.products["candies"][1].brand, "Mars, Incorporated")
53+
54+
def test_deepcopy_disabled(self):
55+
public_info_deep = mapper.to(ShopPublicInfo).map(self.shop, deepcopy=False)
56+
public_info = mapper.to(ShopPublicInfo).map(self.shop)
57+
58+
self.assertIsNot(public_info.products, self.shop.products)
59+
self.assertEqual(public_info.products["magazines"], self.shop.products["magazines"])
60+
self.assertNotEqual(public_info.products["magazines"], id(self.shop.products["magazines"]))
61+
62+
self.assertIs(public_info_deep.products, self.shop.products)
63+
self.assertEqual(
64+
id(public_info_deep.products["magazines"]), id(self.shop.products["magazines"])
65+
)
66+
67+
def test_deepcopy_disabled_in_add(self):
68+
self.mapper.add(Shop, ShopPublicInfo, deepcopy=False)
69+
public_info = self.mapper.map(self.shop)
70+
71+
self.assertIs(public_info.products, self.shop.products)
72+
73+
# Manually enable deepcopy on .map()
74+
public_info = self.mapper.map(self.shop, deepcopy=True)
75+
self.assertIsNot(public_info.products, self.shop.products)

0 commit comments

Comments
 (0)