Skip to content

Commit

Permalink
Browse files Browse the repository at this point in the history
…into main
  • Loading branch information
anikolaienko committed Jan 5, 2022
2 parents d4406ce + a9868bc commit b9cf7f5
Show file tree
Hide file tree
Showing 12 changed files with 560 additions and 71 deletions.
26 changes: 26 additions & 0 deletions .pre-commit-config.yaml
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
3 changes: 3 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,3 +1,6 @@
1.0.0 - 2022/01/05
* Finalized documentation, fixed defects

0.1.1 - 2021/07/18
* No changes, set version as Alpha

Expand Down
218 changes: 179 additions & 39 deletions README.md
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
```
6 changes: 5 additions & 1 deletion automapper/__init__.py
Original file line number Diff line number Diff line change
@@ -1,7 +1,11 @@
# flake8: noqa: F401
from .mapper import Mapper

from .exceptions import DuplicatedRegistrationError, MappingError, CircularReferenceError
from .exceptions import (
DuplicatedRegistrationError,
MappingError,
CircularReferenceError,
)

from .mapper_initializer import create_mapper

Expand Down
4 changes: 3 additions & 1 deletion automapper/exceptions.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,4 +8,6 @@ class MappingError(Exception):

class CircularReferenceError(Exception):
def __init__(self, *args: object) -> None:
super().__init__("Mapper does not support objects with circular references yet", *args)
super().__init__(
"Mapper does not support objects with circular references yet", *args
)
4 changes: 3 additions & 1 deletion automapper/extensions/default.py
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,9 @@ def __init_method_classifier__(target_cls: Type[T]) -> bool:
return (
hasattr(target_cls, "__init__")
and hasattr(getattr(target_cls, "__init__"), "__annotations__")
and isinstance(getattr(getattr(target_cls, "__init__"), "__annotations__"), dict)
and isinstance(
getattr(getattr(target_cls, "__init__"), "__annotations__"), dict
)
)


Expand Down
Loading

0 comments on commit b9cf7f5

Please sign in to comment.