-
Notifications
You must be signed in to change notification settings - Fork 11
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
Merge branch 'main' of https://github.com/anikolaienko/py-automapper …
…into main
- Loading branch information
Showing
12 changed files
with
560 additions
and
71 deletions.
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,26 @@ | ||
# See https://pre-commit.com for more information | ||
# See https://pre-commit.com/hooks.html for more hooks | ||
repos: | ||
- repo: local | ||
hooks: | ||
- id: black | ||
name: black | ||
description: "Black: The uncompromising Python code formatter" | ||
entry: black | ||
language: python | ||
require_serial: true | ||
types_or: [python, pyi] | ||
- id: flake8 | ||
name: flake8 | ||
description: '`flake8` is a command-line utility for enforcing style consistency across Python projects.' | ||
entry: flake8 | ||
language: python | ||
types: [python] | ||
require_serial: true | ||
- id: mypy | ||
name: mypy | ||
description: 'Mypy is a static type checker for Python 3' | ||
entry: mypy | ||
language: python | ||
types: [python] | ||
require_serial: true |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -1,63 +1,203 @@ | ||
<img src="logo.png" align="left" style="width:128px; margin-right: 20px;" /> | ||
|
||
# py-automapper | ||
Python object auto mapper | ||
|
||
Current mapper can be useful for multilayer architecture which requires constant mapping between objects from separate layers (data layer, presentation layer, etc). | ||
**Version** | ||
1.0.0 | ||
|
||
**Author** | ||
anikolaienko | ||
|
||
**Copyright** | ||
anikolaienko | ||
|
||
**License** | ||
The MIT License (MIT) | ||
|
||
**Last updated** | ||
5 Jan 2022 | ||
|
||
**Package Download** | ||
https://pypi.python.org/pypi/py-automapper | ||
|
||
**Build Status** | ||
TODO | ||
|
||
--- | ||
|
||
## Versions | ||
Check [CHANGELOG.md](/CHANGELOG.md) | ||
|
||
## About | ||
|
||
**Python auto mapper** is useful for multilayer architecture which requires constant mapping between objects from separate layers (data layer, presentation layer, etc). | ||
|
||
For more information read the [documentation](https://anikolaienko.github.io/py-automapper). | ||
Inspired by: [object-mapper](https://github.com/marazt/object-mapper) | ||
|
||
## Usage example: | ||
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 | ||
Install package: | ||
```bash | ||
pip install py-automapper | ||
``` | ||
|
||
Simple mapping: | ||
```python | ||
from automapper import mapper | ||
|
||
# Add automatic mappings | ||
class SourceClass: | ||
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): | ||
self.name = name | ||
self.age = age | ||
|
||
# Register mapping | ||
mapper.add(SourceClass, TargetClass) | ||
|
||
# Map object of SourceClass to output object of TargetClass | ||
mapper.map(obj) | ||
source_obj = SourceClass("Andrii", 30, "software developer") | ||
|
||
# Map object to AnotherTargetClass not added to mapping collection | ||
mapper.to(AnotherTargetClass).map(obj) | ||
# Map object | ||
target_obj = mapper.map(source_obj) | ||
|
||
# Override specific fields or provide missing ones | ||
mapper.map(obj, field1=value1, field2=value2) | ||
# or one time mapping without registering in mapper | ||
target_obj = mapper.to(TargetClass).map(source_obj) | ||
|
||
# Don't map None values to target object | ||
mapper.map(obj, skip_none_values = True) | ||
print(f"Name: {target_obj.name}; Age: {target_obj.age}; has profession: {hasattr(target_obj, 'profession')}") | ||
|
||
# Output: | ||
# Name: Andrii; age: 30; has profession: False | ||
``` | ||
|
||
## Advanced features | ||
## Override fields | ||
If you want to override some field and/or add mapping for field not existing in SourceClass: | ||
```python | ||
from automapper import Mapper | ||
from typing import List | ||
from automapper import mapper | ||
|
||
# Create your own Mapper object without any predefined extensions | ||
mapper = Mapper() | ||
class SourceClass: | ||
def __init__(self, name: str, age: int): | ||
self.name = name | ||
self.age = age | ||
|
||
# Add your own extension for extracting list of fields from class | ||
# for all classes inherited from base class | ||
mapper.add_spec( | ||
BaseClass, | ||
lambda child_class: child_class.get_fields_function() | ||
) | ||
|
||
# Add your own extension for extracting list of fields from class | ||
# for all classes that can be identified in verification function | ||
mapper.add_spec( | ||
lambda cls: hasattr(cls, "get_fields_function"), | ||
lambda cls: cls.get_fields_function() | ||
) | ||
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) | ||
|
||
source_obj = SourceClass("Andrii", 30) | ||
hobbies = ["Diving", "Languages", "Sports"] | ||
|
||
# Override `age` and provide missing field `hobbies` | ||
target_obj = mapper.map(source_obj, age=25, hobbies=hobbies) | ||
|
||
print(f"Name: {target_obj.name}; Age: {target_obj.age}; hobbies: {target_obj.hobbies}") | ||
# Output: | ||
# Name: Andrii; Age: 25; hobbies: ['Diving', 'Languages', 'Sports'] | ||
|
||
# Modifying initial `hobbies` object will not modify `target_obj` | ||
hobbies.pop() | ||
|
||
print(f"Hobbies: {hobbies}") | ||
print(f"Target hobbies: {target_obj.hobbies}") | ||
|
||
# Output: | ||
# Hobbies: ['Diving', 'Languages'] | ||
# Target hobbies: ['Diving', 'Languages', 'Sports'] | ||
``` | ||
|
||
## Extensions | ||
`py-automapper` has few predefined extensions for mapping to classes for frameworks: | ||
* [FastAPI](https://github.com/tiangolo/fastapi) and [Pydantic](https://github.com/samuelcolvin/pydantic) | ||
* [TortoiseORM](https://github.com/tortoise/tortoise-orm) | ||
|
||
When you first time import `mapper` from `automapper` it checks default extensions and if modules are found for these extensions, then they will be automatically loaded for default `mapper` object. | ||
|
||
What does extension do? To know what fields in Target class are available for mapping `py-automapper` need to extract the list of these fields. There is no generic way to do that for all Python objects. For this purpose `py-automapper` uses extensions. | ||
|
||
List of default extensions can be found in [/automapper/extensions](/automapper/extensions) folder. You can take a look how it's done for a class with `__init__` method or for Pydantic or TortoiseORM models. | ||
|
||
You can create your own extension and register in `mapper`: | ||
```python | ||
from automapper import mapper | ||
|
||
class TargetClass: | ||
def __init__(self, **kwargs): | ||
self.name = kwargs["name"] | ||
self.age = kwargs["age"] | ||
|
||
@staticmethod | ||
def get_fields(cls): | ||
return ["name", "age"] | ||
|
||
source_obj = {"name": "Andrii", "age": 30} | ||
|
||
try: | ||
# Map object | ||
target_obj = mapper.to(TargetClass).map(source_obj) | ||
except Exception as e: | ||
print(f"Exception: {repr(e)}") | ||
# Output: | ||
# Exception: KeyError('name') | ||
|
||
# mapper could not find list of fields from BaseClass | ||
# let's register extension for class BaseClass and all inherited ones | ||
mapper.add_spec(TargetClass, TargetClass.get_fields) | ||
target_obj = mapper.to(TargetClass).map(source_obj) | ||
|
||
print(f"Name: {target_obj.name}; Age: {target_obj.age}") | ||
``` | ||
For more information about extensions check out existing extensions in `automapper/extensions` folder | ||
|
||
## Not yet implemented features | ||
You can also create your own clean Mapper without any extensions and define extension for very specific classes, e.g. if class accepts `kwargs` parameter in `__init__` method and you want to copy only specific fields. Next example is a bit complex but probably rarely will be needed: | ||
```python | ||
from typing import Type, TypeVar | ||
|
||
# TODO: multiple from classes | ||
mapper.add(FromClassA, FromClassB, ToClassC) | ||
from automapper import Mapper | ||
|
||
# TODO: add custom mappings for fields | ||
mapper.add(ClassA, ClassB, {"Afield1": "Bfield1", "Afield2": "Bfield2"}) | ||
# Create your own Mapper object without any predefined extensions | ||
mapper = Mapper() | ||
|
||
# TODO: Advanced: map multiple objects to output type | ||
mapper.multimap(obj1, obj2) | ||
mapper.to(TargetType).multimap(obj1, obj2) | ||
class TargetClass: | ||
def __init__(self, **kwargs): | ||
self.data = kwargs.copy() | ||
|
||
@classmethod | ||
def fields(cls): | ||
return ["name", "age", "profession"] | ||
|
||
source_obj = {"name": "Andrii", "age": 30, "profession": None} | ||
|
||
try: | ||
target_obj = mapper.to(TargetClass).map(source_obj) | ||
except Exception as e: | ||
print(f"Exception: {repr(e)}") | ||
# Output: | ||
# Exception: MappingError("No spec function is added for base class of <class 'type'>") | ||
|
||
# Instead of using base class, we define spec for all classes that have `fields` property | ||
T = TypeVar("T") | ||
|
||
def class_has_fields_property(target_cls: Type[T]) -> bool: | ||
return callable(getattr(target_cls, "fields", None)) | ||
|
||
mapper.add_spec(class_has_fields_property, lambda t: getattr(t, "fields")()) | ||
|
||
target_obj = mapper.to(TargetClass).map(source_obj) | ||
print(f"Name: {target_obj.data['name']}; Age: {target_obj.data['age']}; Profession: {target_obj.data['profession']}") | ||
# Output: | ||
# Name: Andrii; Age: 30; Profession: None | ||
|
||
# Skip `None` value | ||
target_obj = mapper.to(TargetClass).map(source_obj, skip_none_values=True) | ||
print(f"Name: {target_obj.data['name']}; Age: {target_obj.data['age']}; Has profession: {hasattr(target_obj, 'profession')}") | ||
# Output: | ||
# Name: Andrii; Age: 30; Has profession: False | ||
``` |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Oops, something went wrong.