Skip to content

Commit

Permalink
new release: added custom field mapping
Browse files Browse the repository at this point in the history
  • Loading branch information
anikolaienko committed Jul 24, 2022
1 parent fd16cd1 commit 28d22dc
Show file tree
Hide file tree
Showing 6 changed files with 229 additions and 84 deletions.
8 changes: 8 additions & 0 deletions .flake8
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
[flake8]
max-line-length = 120
per-file-ignores =
**/__init__.py:F401,F403
ignore =
E203 ; https://black.readthedocs.io/en/stable/the_black_code_style/current_style.html#slices
W503 ; https://github.com/psf/black/issues/52
count = True
21 changes: 12 additions & 9 deletions .vscode/launch.json
Original file line number Diff line number Diff line change
Expand Up @@ -6,21 +6,24 @@
"configurations": [
{
"name": "Pytest: Run Tests",
"module": "python",
"args": ["pytest"],
"type": "python",
"request": "launch",
"module": "pytest",
"args": ["tests/"],
"console": "integratedTerminal",
"cwd": "${workspaceFolder}",
"envFile": "${workspaceFolder}/.env",
"python": "${workspaceFolder}/.venv/bin/python",
"justMyCode": false
}, {
"name": "Debug test",
"name": "Python: Debug Current Test",
"type": "python",
"request": "test",
"console": "externalTerminal",
"justMyCode": false,
"stopOnEntry": true,
"envFile": "${workspaceFolder}/.env.test",
"request": "launch",
"module": "pytest",
"args": ["${file}"],
"console": "integratedTerminal",
"cwd": "${workspaceFolder}",
"python": "${workspaceFolder}/.venv/bin/python",
"justMyCode": false
}
]
}
7 changes: 7 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,3 +1,10 @@
1.0.2 - 2022/07/24
* Bug fix: pass parameters override in MappingWrapper.map
* Added support for mapping fields with different names

1.0.1 - 2022/01/05
* Bug fix

1.0.0 - 2022/01/05
* Finalized documentation, fixed defects

Expand Down
118 changes: 62 additions & 56 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@
# py-automapper

**Version**
1.0.1
1.0.2

**Author**
anikolaienko
Expand All @@ -15,7 +15,7 @@ anikolaienko
The MIT License (MIT)

**Last updated**
5 Jan 2022
24 Jul 2022

**Package Download**
https://pypi.python.org/pypi/py-automapper
Expand All @@ -25,93 +25,99 @@ TODO

---

## Versions
Table of Contents:
- [py-automapper](#py-automapper)
- [Versions](#versions)
- [About](#about)
- [Usage](#usage)
- [Different field names](#different-field-names)
- [Overwrite field value in mapping](#overwrite-field-value-in-mapping)
- [Extensions](#extensions)

# Versions
Check [CHANGELOG.md](/CHANGELOG.md)

## About
# About

**Python auto mapper** is useful for multilayer architecture which requires constant mapping between objects from separate layers (data layer, presentation layer, etc).

Inspired by: [object-mapper](https://github.com/marazt/object-mapper)

The major advantage of py-automapper is its extensibility, that allows it to map practically any type, discover custom class fields and customize mapping rules. Read more in [documentation](https://anikolaienko.github.io/py-automapper).

## Usage
# Usage
Install package:
```bash
pip install py-automapper
```

Simple mapping:
Let's say we have domain model `UserInfo` and its API representation `PublicUserInfo` with `age` field missing:
```python
from automapper import mapper

class SourceClass:
class UserInfo:
def __init__(self, name: str, age: int, profession: str):
self.name = name
self.age = age
self.profession = profession

class TargetClass:
def __init__(self, name: str, age: int):
class PublicUserInfo:
def __init__(self, name: str, profession: str):
self.name = name
self.age = age

# Register mapping
mapper.add(SourceClass, TargetClass)

source_obj = SourceClass("Andrii", 30, "software developer")

# Map object
target_obj = mapper.map(source_obj)

# or one time mapping without registering in mapper
target_obj = mapper.to(TargetClass).map(source_obj)

print(f"Name: {target_obj.name}; Age: {target_obj.age}; has profession: {hasattr(target_obj, 'profession')}")
self.profession = profession

# Output:
# Name: Andrii; age: 30; has profession: False
user_info = UserInfo("John Malkovich", 35, "engineer")
```

## Override fields
If you want to override some field and/or add mapping for field not existing in SourceClass:
To create `PublicUserInfo` object:
```python
from typing import List
from automapper import mapper

class SourceClass:
def __init__(self, name: str, age: int):
self.name = name
self.age = age
public_user_info = mapper.to(PublicUserInfo).map(user_info)

class TargetClass:
def __init__(self, name: str, age: int, hobbies: List[str]):
self.name = name
self.age = age
self.hobbies = hobbies

mapper.add(SourceClass, TargetClass)
print(vars(public_user_info))
# {'name': 'John Malkovich', 'profession': 'engineer'}
```
You can register which class should map to which first:
```python
# Register
mapper.add(UserInfo, PublicUserInfo)

source_obj = SourceClass("Andrii", 30)
hobbies = ["Diving", "Languages", "Sports"]
public_user_info = mapper.map(user_info)

# Override `age` and provide missing field `hobbies`
target_obj = mapper.map(source_obj, age=25, hobbies=hobbies)
print(vars(public_user_info))
# {'name': 'John Malkovich', 'profession': 'engineer'}
```

print(f"Name: {target_obj.name}; Age: {target_obj.age}; hobbies: {target_obj.hobbies}")
# Output:
# Name: Andrii; Age: 25; hobbies: ['Diving', 'Languages', 'Sports']
## Different field names
If your target class field name is different from source class.
```python
class PublicUserInfo:
def __init__(self, full_name: str, profession: str):
self.full_name = full_name # UserInfo has `name` instead
self.profession = profession
```
Simple map:
```python
public_user_info = mapper.to(PublicUserInfo).map(user_info, fields_mapping={
"full_name": user_info.name
})
```
Preregister and map:
```python
mapper.add(UserInfo, PublicUserInfo, fields_mapping={"full_name": "UserInfo.name"})
public_user_info = mapper.map(user_info)

# Modifying initial `hobbies` object will not modify `target_obj`
hobbies.pop()
print(vars(public_user_info))
# {'full_name': 'John Malkovich', 'profession': 'engineer'}
```

print(f"Hobbies: {hobbies}")
print(f"Target hobbies: {target_obj.hobbies}")
## Overwrite field value in mapping
Very easy if you want to field just have different value, you provide a new value:
```python
public_user_info = mapper.to(PublicUserInfo).map(user_info, fields_mapping={
"full_name": "John Cusack"
})

# Output:
# Hobbies: ['Diving', 'Languages']
# Target hobbies: ['Diving', 'Languages', 'Sports']
print(vars(public_user_info))
# {'full_name': 'John Cusack', 'profession': 'engineer'}
```

## Extensions
Expand Down
78 changes: 59 additions & 19 deletions automapper/mapper.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
from typing import (
Any,
Optional,
Tuple,
Union,
Type,
TypeVar,
Expand All @@ -25,6 +27,7 @@
T = TypeVar("T")
ClassifierFunction = Callable[[Type[T]], bool]
SpecFunction = Callable[[Type[T]], Iterable[str]]
FieldsMap = Optional[Dict[str, Any]]

__PRIMITIVE_TYPES = {int, float, complex, str, bytes, bytearray, bool}

Expand Down Expand Up @@ -56,22 +59,32 @@ def __init__(self, mapper: "Mapper", target_cls: Type[T]) -> None:
self.__target_cls = target_cls
self.__mapper = mapper

def map(self, obj: S, skip_none_values: bool = False, **kwargs: Any) -> T:
def map(
self,
obj: S,
*,
skip_none_values: bool = False,
fields_mapping: FieldsMap = None,
) -> T:
"""Produces output object mapped from source object and custom arguments
Parameters:
skip_none_values - do not map fields that has None value
**kwargs - custom mappings and fields overrides
fields_mapping - mapping for fields with different names
"""
return self.__mapper._map_common(
obj, self.__target_cls, set(), skip_none_values=skip_none_values
obj,
self.__target_cls,
set(),
skip_none_values=skip_none_values,
fields_mapping=fields_mapping,
)


class Mapper:
def __init__(self) -> None:
"""Initializes internal containers"""
self._mappings: Dict[Type[S], Type[T]] = {} # type: ignore [valid-type]
self._mappings: Dict[Type[S], Tuple[T, FieldsMap]] = {} # type: ignore [valid-type]
self._class_specs: Dict[Type[T], SpecFunction[T]] = {} # type: ignore [valid-type]
self._classifier_specs: Dict[ # type: ignore [valid-type]
ClassifierFunction[T], SpecFunction[T]
Expand Down Expand Up @@ -123,7 +136,11 @@ def add_spec(
raise ValueError("Incorrect type of the classifier argument")

def add(
self, source_cls: Type[S], target_cls: Type[T], override: bool = False
self,
source_cls: Type[S],
target_cls: Type[T],
override: bool = False,
fields_mapping: FieldsMap = None,
) -> None:
"""Adds mapping between object of `source class` to an object of `target class`.
Expand All @@ -140,20 +157,45 @@ def add(
raise DuplicatedRegistrationError(
f"source_cls {source_cls} was already added for mapping"
)
self._mappings[source_cls] = target_cls
self._mappings[source_cls] = (target_cls, fields_mapping)

def map(self, obj: object, skip_none_values: bool = False, **kwargs: Any) -> T:
def map(
self,
obj: object,
*,
skip_none_values: bool = False,
fields_mapping: FieldsMap = None,
) -> T:
"""Produces output object mapped from source object and custom arguments"""
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__}."

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) :])
if isinstance(source_field, str)
and source_field.startswith(obj_type_preffix)
else source_field
for target_obj_field, source_field in target_cls_field_mappings.items()
}
if fields_mapping:
common_fields_mapping = {
**common_fields_mapping,
**fields_mapping,
} # merge two dict into one, fields_mapping has priority

return self._map_common(
obj,
self._mappings[obj_type],
target_cls,
set(),
skip_none_values=skip_none_values,
**kwargs,
fields_mapping=common_fields_mapping,
)

def _get_fields(self, target_cls: Type[T]) -> Iterable[str]:
Expand Down Expand Up @@ -182,11 +224,9 @@ def _map_subobject(
raise CircularReferenceError()

if type(obj) in self._mappings:
result = self._map_common(
obj,
self._mappings[type(obj)],
_visited_stack,
skip_none_values=skip_none_values,
target_cls, _ = self._mappings[type(obj)]
result: Any = self._map_common(
obj, target_cls, _visited_stack, skip_none_values=skip_none_values
)
else:
_visited_stack.add(obj_id)
Expand Down Expand Up @@ -221,13 +261,13 @@ def _map_common(
target_cls: Type[T],
_visited_stack: Set[int],
skip_none_values: bool = False,
**kwargs: Any,
fields_mapping: FieldsMap = None,
) -> T:
"""Produces output object mapped from source object and custom arguments
Parameters:
skip_none_values - do not map fields that has None value
**kwargs - custom mappings and fields overrides
fields_mapping - fields mappings for fields with different names
"""
obj_id = id(obj)

Expand All @@ -241,12 +281,12 @@ def _map_common(
is_obj_subscriptable = is_subscriptable(obj)
for field_name in target_cls_fields:
if (
field_name in kwargs
(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 field_name in kwargs:
value = kwargs[field_name]
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:
Expand Down
Loading

0 comments on commit 28d22dc

Please sign in to comment.