Skip to content

Commit 2352b84

Browse files
committed
Create MapPath for mapping nested objects
1 parent 9d8d4e7 commit 2352b84

File tree

7 files changed

+80
-46
lines changed

7 files changed

+80
-46
lines changed

README.md

Lines changed: 11 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -134,7 +134,12 @@ print(vars(public_user_info))
134134
# {'full_name': 'John Cusack', 'profession': 'engineer'}
135135
```
136136

137-
It is possible to easily specify nested field mappings.
137+
It is possible to easily specify nested field mappings. It can be done through "MapPath" object for your fields.
138+
Please be aware that you can´t mix in one mapping string mapping with MapPath mapping. You don´t need to specify source class
139+
from which mappings comes from as code will automatically use ```__name__``` of your source class.
140+
141+
This is supported only if you register mapping through ```add``` method.
142+
138143
```python
139144
class BasicUser:
140145
def __init__(self, name: str, city: str):
@@ -148,9 +153,11 @@ class AdvancedUser:
148153
self.salary = salary
149154

150155
mapper.add(
151-
AdvancedUser, BasicUser, fields_mapping={
152-
"name": "AdvancedUser.user.name",
153-
"city": "AdvancedUser.user.city",
156+
AdvancedUser,
157+
BasicUser,
158+
fields_mapping={
159+
"name": MapPath("user.name"),
160+
"city": MapPath("user.city"),
154161
}
155162
)
156163

automapper/__init__.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@
66
)
77
from .mapper import Mapper
88
from .mapper_initializer import create_mapper
9+
from .path_mapper import MapPath
910

1011
# Global mapper
1112
mapper = create_mapper()

automapper/custom_types.py

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
from typing import Any, Callable, Dict, Iterable, Optional, Type, TypeVar
2+
3+
from automapper.path_mapper import MapPath
4+
5+
# Custom Types
6+
S = TypeVar("S")
7+
T = TypeVar("T")
8+
ClassifierFunction = Callable[[Type[T]], bool]
9+
SpecFunction = Callable[[Type[T]], Iterable[str]]
10+
FieldsMap = Optional[Dict[str | MapPath, Any]]

automapper/exceptions.py

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,10 @@ class MappingError(Exception):
66
pass
77

88

9+
class MapPathMissMatchError(Exception):
10+
pass
11+
12+
913
class CircularReferenceError(Exception):
1014
def __init__(self, *args: object) -> None:
1115
super().__init__(

automapper/mapper.py

Lines changed: 20 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -1,37 +1,18 @@
11
import inspect
22
from copy import deepcopy
33
from functools import reduce
4-
from typing import (
5-
Any,
6-
Callable,
7-
Dict,
8-
Generic,
9-
Iterable,
10-
Optional,
11-
Set,
12-
Tuple,
13-
Type,
14-
TypeVar,
15-
Union,
16-
cast,
17-
overload,
18-
)
4+
from typing import Any, Dict, Generic, Iterable, Set, Tuple, Type, Union, cast, overload
195

6+
from .custom_types import ClassifierFunction, FieldsMap, S, SpecFunction, T
207
from .exceptions import (
218
CircularReferenceError,
229
DuplicatedRegistrationError,
10+
MapPathMissMatchError,
2311
MappingError,
2412
)
2513
from .path_mapper import MapPath
2614
from .utils import is_dictionary, is_enum, is_primitive, is_sequence, object_contains
2715

28-
# Custom Types
29-
S = TypeVar("S")
30-
T = TypeVar("T")
31-
ClassifierFunction = Callable[[Type[T]], bool]
32-
SpecFunction = Callable[[Type[T]], Iterable[str]]
33-
FieldsMap = Optional[Dict[str | MapPath, Any]]
34-
3516

3617
def _try_get_field_value(
3718
field_name: str, original_obj: Any, custom_mapping: FieldsMap
@@ -162,14 +143,28 @@ def add(
162143
163144
Raises:
164145
DuplicatedRegistrationError: Same mapping for `source class` was added.
165-
Only one mapping per source class can exist at a time for now.
166-
You can specify target class manually using `mapper.to(target_cls)` method
167-
or use `override` argument to replace existing mapping.
146+
Only one mapping per source class can exist at a time for now.
147+
You can specify target class manually using `mapper.to(target_cls)` method
148+
or use `override` argument to replace existing mapping.
149+
MapPathMissMatchError: When mixing `MapPath` with string mappings for a single mapping.
168150
"""
169151
if source_cls in self._mappings and not override:
170152
raise DuplicatedRegistrationError(
171153
f"source_cls {source_cls} was already added for mapping"
172154
)
155+
156+
if fields_mapping and any(
157+
isinstance(map_path, MapPath) for map_path in fields_mapping.values()
158+
):
159+
map_paths = fields_mapping.values()
160+
if not all(isinstance(map_path, MapPath) for map_path in map_paths):
161+
raise MapPathMissMatchError(
162+
"It is not allowed to mix MapPath mappings with string mappings."
163+
)
164+
165+
for map_path in map_paths:
166+
map_path.obj_prefix = source_cls.__name__
167+
173168
self._mappings[source_cls] = (target_cls, fields_mapping)
174169

175170
def map(

automapper/path_mapper.py

Lines changed: 11 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -4,16 +4,23 @@ class MapPath:
44
def __init__(self, path: str):
55
if "." not in path:
66
raise ValueError(f"Invalid path: '{path}' does not contain '.'")
7-
87
self.path = path
98
self.attributes = path.split(".")
10-
if not len(self.attributes) >= 2:
9+
if not len(self.attributes) >= 1:
1110
raise ValueError(
1211
f"Invalid path: '{path}'. Can´t reference to object attribute."
1312
)
1413

15-
self.obj_prefix = self.attributes[0]
16-
self.attributes = self.attributes[1:]
14+
self._obj_prefix: str | None = None
15+
16+
@property
17+
def obj_prefix(self):
18+
return self._obj_prefix
19+
20+
@obj_prefix.setter
21+
def obj_prefix(self, src_cls_name: str) -> None:
22+
"""Setter for obj_prefix."""
23+
self._obj_prefix = src_cls_name
1724

1825
def __call__(self):
1926
return self.attributes

tests/test_path_mapper.py

Lines changed: 23 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,6 @@
11
import pytest
2-
from automapper import mapper
3-
from automapper.path_mapper import ( # Replace 'your_module' with the actual module name
4-
MapPath,
5-
)
2+
from automapper import MapPath, mapper
3+
from automapper.exceptions import MapPathMissMatchError
64

75

86
class BasicUser:
@@ -28,8 +26,10 @@ def test_valid_map_path(self):
2826
"""Test that MapPath correctly splits a valid path."""
2927
path = MapPath("some.example.path")
3028
assert path.path == "some.example.path"
31-
assert path.attributes == ["example", "path"] # obj_prefix is excluded
32-
assert path.obj_prefix == "some" # obj_prefix is correctly assigned
29+
assert path.attributes == ["some", "example", "path"]
30+
assert (
31+
path.obj_prefix is None
32+
) # this is set by automatic in the process of mapping.
3333

3434
def test_invalid_map_path_missing_dot(self):
3535
"""Test that MapPath raises ValueError for paths without a dot."""
@@ -41,25 +41,23 @@ def test_invalid_map_path_missing_dot(self):
4141
def test_callable_behavior(self):
4242
"""Test that calling an instance returns the correct split attributes."""
4343
path = MapPath("one.two.three")
44-
assert path() == ["two", "three"]
44+
assert path() == ["one", "two", "three"]
4545

4646
def test_repr(self):
4747
"""Test that __repr__ returns the expected string representation."""
4848
path = MapPath("foo.bar")
49-
assert (
50-
repr(path) == "MapPath(['bar'])"
51-
) # Only attributes are shown, excluding obj_prefix
49+
assert repr(path) == "MapPath(['foo', 'bar'])"
5250

5351

54-
class TestMappingObjectAttributes:
52+
class TestAddMappingWithNestedObjectReference:
5553
def test_use_registered_mapping_with_map_path(self):
5654
try:
5755
mapper.add(
5856
AdvancedUser,
5957
BasicUser,
6058
fields_mapping={
61-
"name": MapPath("AdvancedUser.user.name"),
62-
"city": MapPath("AdvancedUser.user.city"),
59+
"name": MapPath("user.name"),
60+
"city": MapPath("user.city"),
6361
},
6462
)
6563

@@ -83,3 +81,15 @@ def test_map_object_directly_without_adding_map_path_cant_be_resolved(self):
8381
mapper.to(BasicUser).map(advanced_user)
8482
finally:
8583
mapper._mappings.clear()
84+
85+
def test_cant_add_mapping_with_mixed_map_path_and_string_mapping(self):
86+
"""Cant mix MapPath for one field and another field be classic string mapping."""
87+
with pytest.raises(MapPathMissMatchError):
88+
mapper.add(
89+
AdvancedUser,
90+
BasicUser,
91+
fields_mapping={
92+
"name": "AdvancedUser.user.name",
93+
"city": MapPath("AdvancedUser.user.city"),
94+
},
95+
)

0 commit comments

Comments
 (0)