├── .code_quality ├── .black.cfg ├── .flake8 ├── .isort.cfg └── .mypy.ini ├── .coveragerc ├── .github └── workflows │ └── run_code_checks.yml ├── .gitignore ├── .pre-commit-config.yaml ├── .vscode ├── launch.json └── settings.json ├── CHANGELOG.md ├── CONTRIBUTING.md ├── LICENSE ├── Makefile ├── README.md ├── automapper ├── __init__.py ├── exceptions.py ├── extensions │ ├── __init__.py │ ├── default.py │ ├── pydantic.py │ ├── sqlalchemy.py │ └── tortoise.py ├── mapper.py ├── mapper_initializer.py ├── py.typed └── utils.py ├── docs ├── _config.yaml └── index.md ├── logo.png ├── pyproject.toml └── tests ├── __init__.py ├── extensions ├── __init__.py ├── test_default_extention.py ├── test_pydantic_extention.py ├── test_sqlalchemy_extention.py └── test_tortoise_extention.py ├── test_automapper_basics.py ├── test_automapper_dict.py ├── test_automapper_enum.py ├── test_complex_objects_mapping.py ├── test_dict_field_mapping.py ├── test_issue_18_implicit_child_field_mapping.py ├── test_issue_25.py ├── test_predefined_mapping.py ├── test_subscriptable_obj_mapping.py ├── test_try_get_field_values.py └── test_utils.py /.code_quality/.black.cfg: -------------------------------------------------------------------------------- 1 | [black] 2 | line-length = 120 3 | target-version = ['py38'] 4 | include = '\.pyi?$' 5 | -------------------------------------------------------------------------------- /.code_quality/.flake8: -------------------------------------------------------------------------------- 1 | [flake8] 2 | max-line-length = 120 3 | per-file-ignores = 4 | **/__init__.py:F401,F403 5 | ignore = 6 | ; E203 whitespace before ':' 7 | E203 8 | ; W503 line break before binary operator 9 | W503 10 | ; E704 multiple statements on one line 11 | E704 12 | count = True 13 | -------------------------------------------------------------------------------- /.code_quality/.isort.cfg: -------------------------------------------------------------------------------- 1 | [isort] 2 | profile = black 3 | -------------------------------------------------------------------------------- /.code_quality/.mypy.ini: -------------------------------------------------------------------------------- 1 | [mypy] 2 | disallow_any_generics = True 3 | disallow_subclassing_any = True 4 | disallow_untyped_calls = True 5 | 6 | disallow_incomplete_defs = True 7 | disallow_untyped_decorators = True 8 | no_implicit_optional = True 9 | warn_redundant_casts = True 10 | warn_unused_ignores = True 11 | warn_return_any = True 12 | implicit_reexport = True 13 | strict_equality = True 14 | check_untyped_defs = True 15 | ignore_missing_imports = True 16 | show_error_codes = True 17 | 18 | [mypy-tests.*] 19 | disallow_untyped_defs = False 20 | -------------------------------------------------------------------------------- /.coveragerc: -------------------------------------------------------------------------------- 1 | [run] 2 | branch = True 3 | omit = 4 | */__init__.py 5 | tests/* 6 | 7 | [report] 8 | show_missing = True 9 | fail_under = 94 10 | 11 | [html] 12 | directory = docs/coverage 13 | -------------------------------------------------------------------------------- /.github/workflows/run_code_checks.yml: -------------------------------------------------------------------------------- 1 | name: Run code checks 2 | 3 | on: 4 | push: 5 | branches: [main] 6 | pull_request: 7 | branches: 8 | - main 9 | - 'releases/**' 10 | 11 | jobs: 12 | code-checks: 13 | runs-on: ubuntu-24.04 14 | strategy: 15 | matrix: 16 | python-version: ["3.8", "3.9", "3.10", "3.11", "3.12", "3.13"] 17 | 18 | steps: 19 | - name: Checkout 20 | uses: actions/checkout@v4 21 | 22 | - name: Setup Python 23 | uses: actions/setup-python@v5 24 | with: 25 | python-version: ${{ matrix.python-version }} 26 | cache: 'pip' 27 | 28 | - name: Install dev and test dependencies 29 | run: | 30 | pip install .[dev] 31 | pip install .[test] 32 | 33 | - name: Run code quality tools 34 | run: pre-commit run --all-files 35 | 36 | - name: Run unit tests & code coverage checks 37 | run: pytest 38 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Byte-compiled / optimized / DLL files 2 | __pycache__/ 3 | *.py[cod] 4 | *$py.class 5 | 6 | # C extensions 7 | *.so 8 | 9 | # Distribution / packaging 10 | .Python 11 | build/ 12 | develop-eggs/ 13 | dist/ 14 | downloads/ 15 | eggs/ 16 | .eggs/ 17 | lib/ 18 | lib64/ 19 | parts/ 20 | sdist/ 21 | var/ 22 | wheels/ 23 | pip-wheel-metadata/ 24 | share/python-wheels/ 25 | *.egg-info/ 26 | .installed.cfg 27 | *.egg 28 | MANIFEST 29 | 30 | # PyInstaller 31 | # Usually these files are written by a python script from a template 32 | # before PyInstaller builds the exe, so as to inject date/other infos into it. 33 | *.manifest 34 | *.spec 35 | 36 | # Installer logs 37 | pip-log.txt 38 | pip-delete-this-directory.txt 39 | 40 | # Unit test / coverage reports 41 | htmlcov/ 42 | .tox/ 43 | .nox/ 44 | .coverage 45 | .coverage.* 46 | .cache 47 | nosetests.xml 48 | coverage.xml 49 | *.cover 50 | *.py,cover 51 | .hypothesis/ 52 | .pytest_cache/ 53 | 54 | # Translations 55 | *.mo 56 | *.pot 57 | 58 | # Django stuff: 59 | *.log 60 | local_settings.py 61 | db.sqlite3 62 | db.sqlite3-journal 63 | 64 | # Flask stuff: 65 | instance/ 66 | .webassets-cache 67 | 68 | # Scrapy stuff: 69 | .scrapy 70 | 71 | # Sphinx documentation 72 | docs/_build/ 73 | 74 | # PyBuilder 75 | target/ 76 | 77 | # Jupyter Notebook 78 | .ipynb_checkpoints 79 | 80 | # IPython 81 | profile_default/ 82 | ipython_config.py 83 | 84 | # pyenv 85 | .python-version 86 | 87 | # pipenv 88 | # According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control. 89 | # However, in case of collaboration, if having platform-specific dependencies or dependencies 90 | # having no cross-platform support, pipenv may install dependencies that don't work, or not 91 | # install all needed dependencies. 92 | #Pipfile.lock 93 | 94 | # PEP 582; used by e.g. github.com/David-OConnor/pyflow 95 | __pypackages__/ 96 | 97 | # Celery stuff 98 | celerybeat-schedule 99 | celerybeat.pid 100 | 101 | # SageMath parsed files 102 | *.sage.py 103 | 104 | # Environments 105 | .venv* 106 | env.bak/ 107 | venv.bak/ 108 | 109 | # Spyder project settings 110 | .spyderproject 111 | .spyproject 112 | 113 | # Rope project settings 114 | .ropeproject 115 | 116 | # mkdocs documentation 117 | /site 118 | 119 | # mypy 120 | .mypy_cache/ 121 | .dmypy.json 122 | dmypy.json 123 | 124 | # Pyre type checker 125 | .pyre/ 126 | 127 | # Coming in future, but not yet 128 | sphinx-docs/ 129 | 130 | # VS Code IDE 131 | .vscode 132 | -------------------------------------------------------------------------------- /.pre-commit-config.yaml: -------------------------------------------------------------------------------- 1 | # See https://pre-commit.com for more information 2 | # See https://pre-commit.com/hooks.html for more hooks 3 | files: ".py$" 4 | exclude: "sphinx-docs/" 5 | repos: 6 | - repo: https://github.com/pre-commit/pre-commit-hooks 7 | rev: v4.6.0 8 | hooks: 9 | - id: check-merge-conflict 10 | name: Check that merge conflicts are not being committed 11 | - id: trailing-whitespace 12 | name: Remove trailing whitespace at end of line 13 | - id: mixed-line-ending 14 | name: Detect if mixed line ending is used (\r vs. \r\n) 15 | - id: end-of-file-fixer 16 | name: Make sure that there is an empty line at the end 17 | - repo: https://github.com/psf/black 18 | rev: 24.4.2 19 | hooks: 20 | - id: black 21 | name: black 22 | description: "Black: The uncompromising Python code formatter" 23 | entry: black 24 | language: python 25 | require_serial: true 26 | types_or: [python, pyi] 27 | args: [--config=.code_quality/.black.cfg] 28 | - repo: https://github.com/PyCQA/isort 29 | rev: 5.13.2 30 | hooks: 31 | - id: isort 32 | name: Run isort to sort imports in Python files 33 | files: \.py$|\.pyi$ 34 | args: [--settings-path=.code_quality/.isort.cfg] 35 | - repo: https://github.com/PyCQA/flake8 36 | rev: 7.0.0 37 | hooks: 38 | - id: flake8 39 | name: flake8 40 | description: '`flake8` is a command-line utility for enforcing style consistency across Python projects.' 41 | entry: flake8 42 | language: python 43 | types: [python] 44 | require_serial: true 45 | args: [--config=.code_quality/.flake8] 46 | - repo: local 47 | hooks: 48 | - id: mypy 49 | name: mypy 50 | description: 'Optional static typing for Python (installed as [test] dependency)' 51 | entry: mypy 52 | language: python 53 | require_serial: true 54 | types_or: [python, pyi] 55 | args: [--config-file=.code_quality/.mypy.ini] 56 | -------------------------------------------------------------------------------- /.vscode/launch.json: -------------------------------------------------------------------------------- 1 | { 2 | // Use IntelliSense to learn about possible attributes. 3 | // Hover to view descriptions of existing attributes. 4 | // For more information, visit: https://go.microsoft.com/fwlink/?linkid=830387 5 | "version": "0.2.0", 6 | "configurations": [ 7 | { 8 | "name": "Pytest: Run Tests", 9 | "type": "python", 10 | "request": "launch", 11 | "module": "pytest", 12 | "args": ["tests/"], 13 | "console": "integratedTerminal", 14 | "cwd": "${workspaceFolder}", 15 | "python": "${workspaceFolder}/.venv/bin/python", 16 | "justMyCode": false 17 | }, { 18 | "name": "Python: Debug Current Test", 19 | "type": "python", 20 | "request": "launch", 21 | "module": "pytest", 22 | "args": ["${file}"], 23 | "console": "integratedTerminal", 24 | "cwd": "${workspaceFolder}", 25 | "python": "${workspaceFolder}/.venv/bin/python", 26 | "justMyCode": false 27 | } 28 | ] 29 | } -------------------------------------------------------------------------------- /.vscode/settings.json: -------------------------------------------------------------------------------- 1 | { 2 | "python.pythonPath": ".venv/bin/python3.8", 3 | "cSpell.words": [ 4 | "hasattr", 5 | "isclass", 6 | "multimap", 7 | "pyautomapper", 8 | "subobject", 9 | "subobjects" 10 | ], 11 | "python.testing.pytestArgs": [], 12 | "python.testing.unittestEnabled": false, 13 | "python.testing.nosetestsEnabled": false, 14 | "python.testing.pytestEnabled": true 15 | } -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | 2.2.0 - 2025/03/09 2 | * Added support for Python 3.13. 3 | * Upgrade [dev,test] dependencies. 4 | * Update Ubuntu CI/CD image to ubuntu-24.04. 5 | * Added `py.typed` file to support type checking. 6 | * Removed `poetry.lock` as it's not used for a long time now. 7 | 8 | 2.1.0 - 2025/02/04 9 | * Breaking changes: Modified logic that maps Sequence and Dictionary classes. All tests are green but this can cause some unexpected behaviour and new defects. 10 | * Fixed issue #25 with deepcopy for SQLAlchemy classes. 11 | * Improved unit testing. 12 | 13 | 2.0.0 - 2024/05/12 14 | * Moved away from poetry. Changed packaging system using pip, build and twine. 15 | * Upgraded all dependencies. 16 | * Add support for Python 3.12. 17 | * Migrated SQLAlchemy from 1.x to 2.x. 18 | 19 | 1.2.3 - 2022/11/21 20 | * Added automated code checks for different Python versions. 21 | 22 | 1.2.2 - 2022/11/20 23 | * [@soldag] Fixed mapping of string enum types [#17](https://github.com/anikolaienko/py-automapper/pull/17) 24 | 25 | 1.2.1 - 2022/11/13 26 | * Fixed dictionary source mapping to target object. 27 | * Implemented CI checks 28 | 29 | 1.2.0 - 2022/10/25 30 | * [@g-pichler] Ability to disable deepcopy on mapping: `use_deepcopy` flag in `map` method. 31 | * [@g-pichler] Improved error text when no spec function exists for `target class`. 32 | * Updated doc comments. 33 | 34 | 1.1.3 - 2022/10/07 35 | * [@g-pichler] Added support for SQLAlchemy models mapping 36 | * Upgraded code checking tool and improved code formatting 37 | 38 | 1.0.4 - 2022/07/25 39 | * Added better description for usage with Pydantic and TortoiseORM 40 | * Improved type support 41 | 42 | 1.0.3 - 2022/07/24 43 | * Fixed issue with dictionary collection: https://github.com/anikolaienko/py-automapper/issues/4 44 | 45 | 1.0.2 - 2022/07/24 46 | * Bug fix: pass parameters override in MappingWrapper.map 47 | * Added support for mapping fields with different names: https://github.com/anikolaienko/py-automapper/issues/3 48 | 49 | 1.0.1 - 2022/01/05 50 | * Bug fix 51 | 52 | 1.0.0 - 2022/01/05 53 | * Finalized documentation, fixed defects 54 | 55 | 0.1.1 - 2021/07/18 56 | * No changes, set version as Alpha 57 | 58 | 0.1.0 - 2021/07/18 59 | * Implemented base functionality 60 | 61 | 0.0.1 - 2021/06/23 62 | * Initial version -------------------------------------------------------------------------------- /CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | # Contribution guide 2 | Any contribution is welcome. If you noticed a bug or have a feature idea create a new Issue in [Github Issues](https://github.com/anikolaienko/py-automapper/issues). For small fixes it's enough to create PR with description. 3 | 4 | Table of Contents: 5 | - [Dev environment](#dev-environment) 6 | - [Pre-commit](#pre-commit) 7 | - [Run tests](#run-tests) 8 | 9 | # Dev environment 10 | * Install all dependencies: 11 | ```bash 12 | pip install .[dev] 13 | pip install .[test] 14 | ``` 15 | 16 | # Pre-commit 17 | This project is using `pre-commit` checks to format and verify code. Same checks are used on CI as well. Activate `pre-commit` for your local setup: 18 | ```bash 19 | pre-commit install 20 | ``` 21 | After this code checks will run on `git commit` command. 22 | 23 | If some of the `pre-commit` dependencies are not found make sure to activate appropriate virtual environment 24 | 25 | To run `pre-commit` manually use: 26 | ```bash 27 | pre-commit run --all-files 28 | ``` 29 | 30 | # Run tests 31 | To run unit tests use command: 32 | ```bash 33 | make test 34 | ``` 35 | or simply 36 | ```bash 37 | pytest 38 | ``` 39 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2021 Andrii Nikolaienko 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | help: 2 | @echo "Py-Automapper development makefile" 3 | @echo 4 | @echo "usage: make " 5 | @echo "Targets:" 6 | @echo " clean Clean all the cache in repo directory" 7 | @echo " install Ensure dev/test dependencies are installed" 8 | @echo " test Run all tests" 9 | @echo " docs [not-working] Builds the documentation" 10 | @echo " build Build into a package (/dist folder)" 11 | @echo " publish Publish the package to pypi.org" 12 | 13 | clean: 14 | rm -rf build dist .mypy_cache .pytest_cache .coverage py_automapper.egg-info 15 | 16 | install: 17 | pip install .[dev] 18 | pip install .[test] 19 | 20 | test: 21 | pytest 22 | 23 | build: 24 | python -m build 25 | 26 | publish: 27 | twine publish 28 | 29 | docs: install 30 | rm -fR ./build 31 | sphinx-build -M html docs build 32 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | 2 | 3 | # py-automapper 4 | 5 | > [!IMPORTANT] 6 | > Renewing maintanance of this library! 7 | > 8 | > After a long pause, I see the library is still in use and receives more stars. Thank you all who likes and uses it. So, I renew the py-automapper maintanance. 9 | > Expect fixes and new version soon. 10 | 11 | **Build Status** 12 | [![Main branch status](https://github.com/anikolaienko/py-automapper/actions/workflows/run_code_checks.yml/badge.svg?branch=main)](https://github.com/anikolaienko/py-automapper/actions?query=branch%3Amain) 13 | 14 | --- 15 | 16 | Table of Contents: 17 | - [Versions](#versions) 18 | - [About](#about) 19 | - [Contribute](#contribute) 20 | - [Usage](#usage) 21 | - [Installation](#installation) 22 | - [Get started](#get-started) 23 | - [Map dictionary source to target object](#map-dictionary-source-to-target-object) 24 | - [Different field names](#different-field-names) 25 | - [Overwrite field value in mapping](#overwrite-field-value-in-mapping) 26 | - [Disable Deepcopy](#disable-deepcopy) 27 | - [Extensions](#extensions) 28 | - [Pydantic/FastAPI Support](#pydanticfastapi-support) 29 | - [TortoiseORM Support](#tortoiseorm-support) 30 | - [SQLAlchemy Support](#sqlalchemy-support) 31 | - [Create your own extension (Advanced)](#create-your-own-extension-advanced) 32 | 33 | # Versions 34 | Check [CHANGELOG.md](/CHANGELOG.md) 35 | 36 | # About 37 | 38 | **Python auto mapper** is useful for multilayer architecture which requires constant mapping between objects from separate layers (data layer, presentation layer, etc). 39 | 40 | Inspired by: [object-mapper](https://github.com/marazt/object-mapper) 41 | 42 | 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). 43 | 44 | # Contribute 45 | Read [CONTRIBUTING.md](/CONTRIBUTING.md) guide. 46 | 47 | # Usage 48 | ## Installation 49 | Install package: 50 | ```bash 51 | pip install py-automapper 52 | ``` 53 | 54 | ## Get started 55 | Let's say we have domain model `UserInfo` and its API representation `PublicUserInfo` without exposing user `age`: 56 | ```python 57 | class UserInfo: 58 | def __init__(self, name: str, profession: str, age: int): 59 | self.name = name 60 | self.profession = profession 61 | self.age = age 62 | 63 | class PublicUserInfo: 64 | def __init__(self, name: str, profession: str): 65 | self.name = name 66 | self.profession = profession 67 | 68 | user_info = UserInfo("John Malkovich", "engineer", 35) 69 | ``` 70 | To create `PublicUserInfo` object: 71 | ```python 72 | from automapper import mapper 73 | 74 | public_user_info = mapper.to(PublicUserInfo).map(user_info) 75 | 76 | print(vars(public_user_info)) 77 | # {'name': 'John Malkovich', 'profession': 'engineer'} 78 | ``` 79 | You can register which class should map to which first: 80 | ```python 81 | # Register 82 | mapper.add(UserInfo, PublicUserInfo) 83 | 84 | public_user_info = mapper.map(user_info) 85 | 86 | print(vars(public_user_info)) 87 | # {'name': 'John Malkovich', 'profession': 'engineer'} 88 | ``` 89 | 90 | ## Map dictionary source to target object 91 | If source object is dictionary: 92 | ```python 93 | source = { 94 | "name": "John Carter", 95 | "profession": "hero" 96 | } 97 | public_info = mapper.to(PublicUserInfo).map(source) 98 | 99 | print(vars(public_info)) 100 | # {'name': 'John Carter', 'profession': 'hero'} 101 | ``` 102 | 103 | ## Different field names 104 | If your target class field name is different from source class. 105 | ```python 106 | class PublicUserInfo: 107 | def __init__(self, full_name: str, profession: str): 108 | self.full_name = full_name # UserInfo has `name` instead 109 | self.profession = profession 110 | ``` 111 | Simple map: 112 | ```python 113 | public_user_info = mapper.to(PublicUserInfo).map(user_info, fields_mapping={ 114 | "full_name": user_info.name 115 | }) 116 | ``` 117 | Preregister and map. Source field should start with class name followed by period sign and field name: 118 | ```python 119 | mapper.add(UserInfo, PublicUserInfo, fields_mapping={"full_name": "UserInfo.name"}) 120 | public_user_info = mapper.map(user_info) 121 | 122 | print(vars(public_user_info)) 123 | # {'full_name': 'John Malkovich', 'profession': 'engineer'} 124 | ``` 125 | 126 | ## Overwrite field value in mapping 127 | Very easy if you want to field just have different value, you provide a new value: 128 | ```python 129 | public_user_info = mapper.to(PublicUserInfo).map(user_info, fields_mapping={ 130 | "full_name": "John Cusack" 131 | }) 132 | 133 | print(vars(public_user_info)) 134 | # {'full_name': 'John Cusack', 'profession': 'engineer'} 135 | ``` 136 | 137 | ## Disable Deepcopy 138 | By default, py-automapper performs a recursive `copy.deepcopy()` call on all attributes when copying from source object into target class instance. 139 | This makes sure that changes in the attributes of the source do not affect the target and vice versa. 140 | If you need your target and source class share same instances of child objects, set `use_deepcopy=False` in `map` function. 141 | 142 | ```python 143 | from dataclasses import dataclass 144 | from automapper import mapper 145 | 146 | @dataclass 147 | class Address: 148 | street: str 149 | number: int 150 | zip_code: int 151 | city: str 152 | 153 | class PersonInfo: 154 | def __init__(self, name: str, age: int, address: Address): 155 | self.name = name 156 | self.age = age 157 | self.address = address 158 | 159 | class PublicPersonInfo: 160 | def __init__(self, name: str, address: Address): 161 | self.name = name 162 | self.address = address 163 | 164 | address = Address(street="Main Street", number=1, zip_code=100001, city='Test City') 165 | info = PersonInfo('John Doe', age=35, address=address) 166 | 167 | # default deepcopy behavior 168 | public_info = mapper.to(PublicPersonInfo).map(info) 169 | print("Target public_info.address is same as source address: ", address is public_info.address) 170 | # Target public_info.address is same as source address: False 171 | 172 | # disable deepcopy 173 | public_info = mapper.to(PublicPersonInfo).map(info, use_deepcopy=False) 174 | print("Target public_info.address is same as source address: ", address is public_info.address) 175 | # Target public_info.address is same as source address: True 176 | ``` 177 | 178 | ## Extensions 179 | `py-automapper` has few predefined extensions for mapping support to classes for frameworks: 180 | * [FastAPI](https://github.com/tiangolo/fastapi) and [Pydantic](https://github.com/samuelcolvin/pydantic) 181 | * [TortoiseORM](https://github.com/tortoise/tortoise-orm) 182 | * [SQLAlchemy](https://www.sqlalchemy.org/) 183 | 184 | ## Pydantic/FastAPI Support 185 | Out of the box Pydantic models support: 186 | ```python 187 | from pydantic import BaseModel 188 | from typing import List 189 | from automapper import mapper 190 | 191 | class UserInfo(BaseModel): 192 | id: int 193 | full_name: str 194 | public_name: str 195 | hobbies: List[str] 196 | 197 | class PublicUserInfo(BaseModel): 198 | id: int 199 | public_name: str 200 | hobbies: List[str] 201 | 202 | obj = UserInfo( 203 | id=2, 204 | full_name="Danny DeVito", 205 | public_name="dannyd", 206 | hobbies=["acting", "comedy", "swimming"] 207 | ) 208 | 209 | result = mapper.to(PublicUserInfo).map(obj) 210 | # same behaviour with preregistered mapping 211 | 212 | print(vars(result)) 213 | # {'id': 2, 'public_name': 'dannyd', 'hobbies': ['acting', 'comedy', 'swimming']} 214 | ``` 215 | 216 | ## TortoiseORM Support 217 | Out of the box TortoiseORM models support: 218 | ```python 219 | from tortoise import Model, fields 220 | from automapper import mapper 221 | 222 | class UserInfo(Model): 223 | id = fields.IntField(primary_key=True) 224 | full_name = fields.TextField() 225 | public_name = fields.TextField() 226 | hobbies = fields.JSONField() 227 | 228 | class PublicUserInfo(Model): 229 | id = fields.IntField(primary_key=True) 230 | public_name = fields.TextField() 231 | hobbies = fields.JSONField() 232 | 233 | obj = UserInfo( 234 | id=2, 235 | full_name="Danny DeVito", 236 | public_name="dannyd", 237 | hobbies=["acting", "comedy", "swimming"], 238 | using_db=True 239 | ) 240 | 241 | result = mapper.to(PublicUserInfo).map(obj) 242 | # same behaviour with preregistered mapping 243 | 244 | # filtering out protected fields that start with underscore "_..." 245 | print({key: value for key, value in vars(result) if not key.startswith("_")}) 246 | # {'id': 2, 'public_name': 'dannyd', 'hobbies': ['acting', 'comedy', 'swimming']} 247 | ``` 248 | 249 | ## SQLAlchemy Support 250 | Out of the box SQLAlchemy models support: 251 | ```python 252 | from sqlalchemy.orm import declarative_base 253 | from sqlalchemy import Column, Integer, String 254 | from automapper import mapper 255 | 256 | Base = declarative_base() 257 | 258 | class UserInfo(Base): 259 | __tablename__ = "users" 260 | id = Column(Integer, primary_key=True) 261 | full_name = Column(String) 262 | public_name = Column(String) 263 | hobbies = Column(String) 264 | def __repr__(self): 265 | return "" % ( 266 | self.full_name, 267 | self.public_name, 268 | self.hobbies, 269 | ) 270 | 271 | class PublicUserInfo(Base): 272 | __tablename__ = 'public_users' 273 | id = Column(Integer, primary_key=True) 274 | public_name = Column(String) 275 | hobbies = Column(String) 276 | 277 | obj = UserInfo( 278 | id=2, 279 | full_name="Danny DeVito", 280 | public_name="dannyd", 281 | hobbies="acting, comedy, swimming", 282 | ) 283 | 284 | result = mapper.to(PublicUserInfo).map(obj) 285 | # same behaviour with preregistered mapping 286 | 287 | # filtering out protected fields that start with underscore "_..." 288 | print({key: value for key, value in vars(result) if not key.startswith("_")}) 289 | # {'id': 2, 'public_name': 'dannyd', 'hobbies': "acting, comedy, swimming"} 290 | ``` 291 | 292 | ## Create your own extension (Advanced) 293 | 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. 294 | 295 | **What does extension do?** To know what fields in Target class are available for mapping, `py-automapper` needs to know how to extract the list of fields. There is no generic way to do that for all Python objects. For this purpose `py-automapper` uses extensions. 296 | 297 | 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. 298 | 299 | You can create your own extension and register in `mapper`: 300 | ```python 301 | from automapper import mapper 302 | 303 | class TargetClass: 304 | def __init__(self, **kwargs): 305 | self.name = kwargs["name"] 306 | self.age = kwargs["age"] 307 | 308 | @staticmethod 309 | def get_fields(cls): 310 | return ["name", "age"] 311 | 312 | source_obj = {"name": "Andrii", "age": 30} 313 | 314 | try: 315 | # Map object 316 | target_obj = mapper.to(TargetClass).map(source_obj) 317 | except Exception as e: 318 | print(f"Exception: {repr(e)}") 319 | # Output: 320 | # Exception: KeyError('name') 321 | 322 | # mapper could not find list of fields from BaseClass 323 | # let's register extension for class BaseClass and all inherited ones 324 | mapper.add_spec(TargetClass, TargetClass.get_fields) 325 | target_obj = mapper.to(TargetClass).map(source_obj) 326 | 327 | print(f"Name: {target_obj.name}; Age: {target_obj.age}") 328 | ``` 329 | 330 | 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: 331 | ```python 332 | from typing import Type, TypeVar 333 | 334 | from automapper import Mapper 335 | 336 | # Create your own Mapper object without any predefined extensions 337 | mapper = Mapper() 338 | 339 | class TargetClass: 340 | def __init__(self, **kwargs): 341 | self.data = kwargs.copy() 342 | 343 | @classmethod 344 | def fields(cls): 345 | return ["name", "age", "profession"] 346 | 347 | source_obj = {"name": "Andrii", "age": 30, "profession": None} 348 | 349 | try: 350 | target_obj = mapper.to(TargetClass).map(source_obj) 351 | except Exception as e: 352 | print(f"Exception: {repr(e)}") 353 | # Output: 354 | # Exception: MappingError("No spec function is added for base class of ") 355 | 356 | # Instead of using base class, we define spec for all classes that have `fields` property 357 | T = TypeVar("T") 358 | 359 | def class_has_fields_property(target_cls: Type[T]) -> bool: 360 | return callable(getattr(target_cls, "fields", None)) 361 | 362 | mapper.add_spec(class_has_fields_property, lambda t: getattr(t, "fields")()) 363 | 364 | target_obj = mapper.to(TargetClass).map(source_obj) 365 | print(f"Name: {target_obj.data['name']}; Age: {target_obj.data['age']}; Profession: {target_obj.data['profession']}") 366 | # Output: 367 | # Name: Andrii; Age: 30; Profession: None 368 | 369 | # Skip `None` value 370 | target_obj = mapper.to(TargetClass).map(source_obj, skip_none_values=True) 371 | print(f"Name: {target_obj.data['name']}; Age: {target_obj.data['age']}; Has profession: {hasattr(target_obj, 'profession')}") 372 | # Output: 373 | # Name: Andrii; Age: 30; Has profession: False 374 | ``` 375 | -------------------------------------------------------------------------------- /automapper/__init__.py: -------------------------------------------------------------------------------- 1 | # flake8: noqa: F401 2 | from .exceptions import ( 3 | CircularReferenceError, 4 | DuplicatedRegistrationError, 5 | MappingError, 6 | ) 7 | from .mapper import Mapper 8 | from .mapper_initializer import create_mapper 9 | 10 | # Global mapper 11 | mapper = create_mapper() 12 | -------------------------------------------------------------------------------- /automapper/exceptions.py: -------------------------------------------------------------------------------- 1 | class DuplicatedRegistrationError(ValueError): 2 | pass 3 | 4 | 5 | class MappingError(Exception): 6 | pass 7 | 8 | 9 | class CircularReferenceError(Exception): 10 | def __init__(self, *args: object) -> None: 11 | super().__init__( 12 | "Mapper does not support objects with circular references yet", *args 13 | ) 14 | -------------------------------------------------------------------------------- /automapper/extensions/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/anikolaienko/py-automapper/0e91f49317fee83abae96af91f7a2122955c872e/automapper/extensions/__init__.py -------------------------------------------------------------------------------- /automapper/extensions/default.py: -------------------------------------------------------------------------------- 1 | from typing import Iterable, Type, TypeVar 2 | 3 | from automapper import Mapper 4 | 5 | T = TypeVar("T") 6 | _IGNORED_FIELDS = ("return", "args", "kwargs") 7 | 8 | 9 | def __init_method_classifier__(target_cls: Type[T]) -> bool: 10 | """Default classifier for classes with described fields in `__init__` method""" 11 | return ( 12 | hasattr(target_cls, "__init__") 13 | and hasattr(getattr(target_cls, "__init__"), "__annotations__") 14 | and isinstance( 15 | getattr(getattr(target_cls, "__init__"), "__annotations__"), dict 16 | ) 17 | and getattr(getattr(target_cls, "__init__"), "__annotations__") 18 | ) 19 | 20 | 21 | def __init_method_spec_func__(target_cls: Type[T]) -> Iterable[str]: 22 | """Default spec function for classes with described fields in `__init__` method. 23 | If __init__ of the target class accepts `*args` or `**kwargs` 24 | then current spec function won't work properly and another spec_func should be added 25 | """ 26 | return ( 27 | field 28 | for field in target_cls.__init__.__annotations__.keys() 29 | if field not in _IGNORED_FIELDS 30 | ) 31 | 32 | 33 | def extend(mapper: Mapper) -> None: 34 | mapper.add_spec(__init_method_classifier__, __init_method_spec_func__) 35 | -------------------------------------------------------------------------------- /automapper/extensions/pydantic.py: -------------------------------------------------------------------------------- 1 | from typing import Iterable, Type 2 | 3 | from automapper import Mapper 4 | from pydantic import BaseModel 5 | 6 | 7 | def spec_function(target_cls: Type[BaseModel]) -> Iterable[str]: 8 | return (field_name for field_name in target_cls.model_fields) 9 | 10 | 11 | def extend(mapper: Mapper) -> None: 12 | mapper.add_spec(BaseModel, spec_function) 13 | -------------------------------------------------------------------------------- /automapper/extensions/sqlalchemy.py: -------------------------------------------------------------------------------- 1 | from typing import Iterable, Type 2 | 3 | from automapper import Mapper 4 | from sqlalchemy.inspection import inspect 5 | from sqlalchemy.orm import DeclarativeBase 6 | 7 | 8 | def sqlalchemy_spec_decide(obj_type: Type[object]) -> bool: 9 | return inspect(obj_type, raiseerr=False) is not None 10 | 11 | 12 | def spec_function(target_cls: Type[DeclarativeBase]) -> Iterable[str]: 13 | inspector = inspect(target_cls) 14 | attrs = [x.key for x in inspector.attrs] 15 | return attrs 16 | 17 | 18 | def extend(mapper: Mapper) -> None: 19 | mapper.add_spec(sqlalchemy_spec_decide, spec_function) 20 | -------------------------------------------------------------------------------- /automapper/extensions/tortoise.py: -------------------------------------------------------------------------------- 1 | from typing import Iterable, Type 2 | 3 | from automapper import Mapper 4 | from tortoise import Model 5 | 6 | 7 | def spec_function(target_cls: Type[Model]) -> Iterable[str]: 8 | return (field_name for field_name in target_cls._meta.fields_map) 9 | 10 | 11 | def extend(mapper: Mapper) -> None: 12 | mapper.add_spec(Model, spec_function) 13 | -------------------------------------------------------------------------------- /automapper/mapper.py: -------------------------------------------------------------------------------- 1 | import inspect 2 | from copy import deepcopy 3 | from typing import ( 4 | Any, 5 | Callable, 6 | Dict, 7 | Generic, 8 | Iterable, 9 | Optional, 10 | Set, 11 | Tuple, 12 | Type, 13 | TypeVar, 14 | Union, 15 | cast, 16 | overload, 17 | ) 18 | 19 | from .exceptions import ( 20 | CircularReferenceError, 21 | DuplicatedRegistrationError, 22 | MappingError, 23 | ) 24 | from .utils import is_dictionary, is_enum, is_primitive, is_sequence, object_contains 25 | 26 | # Custom Types 27 | S = TypeVar("S") 28 | T = TypeVar("T") 29 | ClassifierFunction = Callable[[Type[T]], bool] 30 | SpecFunction = Callable[[Type[T]], Iterable[str]] 31 | FieldsMap = Optional[Dict[str, Any]] 32 | 33 | 34 | def _try_get_field_value( 35 | field_name: str, original_obj: Any, custom_mapping: FieldsMap 36 | ) -> Tuple[bool, Any]: 37 | if field_name in (custom_mapping or {}): 38 | return True, custom_mapping[field_name] # type: ignore [index] 39 | if hasattr(original_obj, field_name): 40 | return True, getattr(original_obj, field_name) 41 | if object_contains(original_obj, field_name): 42 | return True, original_obj[field_name] 43 | return False, None 44 | 45 | 46 | class MappingWrapper(Generic[T]): 47 | """Internal wrapper for supporting syntax: 48 | ``` 49 | mapper.to(TargetClass).map(SourceObject) 50 | ``` 51 | """ 52 | 53 | def __init__(self, mapper: "Mapper", target_cls: Type[T]) -> None: 54 | """Stores mapper and target class for using into `map` method""" 55 | self.__target_cls = target_cls 56 | self.__mapper = mapper 57 | 58 | def map( 59 | self, 60 | obj: S, 61 | *, 62 | skip_none_values: bool = False, 63 | fields_mapping: FieldsMap = None, 64 | use_deepcopy: bool = True, 65 | ) -> T: 66 | """Produces output object mapped from source object and custom arguments. 67 | 68 | Args: 69 | obj (S): _description_ 70 | skip_none_values (bool, optional): Skip None values when creating `target class` obj. Defaults to False. 71 | fields_mapping (FieldsMap, optional): Custom mapping. 72 | Specify dictionary in format {"field_name": value_object}. Defaults to None. 73 | use_deepcopy (bool, optional): Apply deepcopy to all child objects when copy from source to target object. 74 | Defaults to True. 75 | 76 | Raises: 77 | CircularReferenceError: Circular references in `source class` object are not allowed yet. 78 | 79 | Returns: 80 | T: instance of `target class` with mapped values from `source class` or custom `fields_mapping` dictionary. 81 | """ 82 | return self.__mapper._map_common( 83 | obj, 84 | self.__target_cls, 85 | set(), 86 | skip_none_values=skip_none_values, 87 | custom_mapping=fields_mapping, 88 | use_deepcopy=use_deepcopy, 89 | ) 90 | 91 | 92 | class Mapper: 93 | def __init__(self) -> None: 94 | """Initializes internal containers""" 95 | self._mappings: Dict[Type[S], Tuple[T, FieldsMap]] = {} # type: ignore [valid-type] 96 | self._class_specs: Dict[Type[T], SpecFunction[T]] = {} # type: ignore [valid-type] 97 | self._classifier_specs: Dict[ # type: ignore [valid-type] 98 | ClassifierFunction[T], SpecFunction[T] 99 | ] = {} 100 | 101 | @overload 102 | def add_spec(self, classifier: Type[T], spec_func: SpecFunction[T]) -> None: 103 | """Add a spec function for all classes in inherited from base class. 104 | 105 | Args: 106 | classifier (ClassifierFunction[T]): base class to identify all descendant classes. 107 | spec_func (SpecFunction[T]): get list of fields (List[str]) for `target class` to be passed in constructor. 108 | """ 109 | ... 110 | 111 | @overload 112 | def add_spec( 113 | self, classifier: ClassifierFunction[T], spec_func: SpecFunction[T] 114 | ) -> None: 115 | """Add a spec function for all classes identified by classifier function. 116 | 117 | Args: 118 | classifier (ClassifierFunction[T]): boolean predicate that identifies a group of classes 119 | by certain characteristics: if class has a specific method or a field, etc. 120 | spec_func (SpecFunction[T]): get list of fields (List[str]) for `target class` to be passed in constructor. 121 | """ 122 | ... 123 | 124 | def add_spec( 125 | self, 126 | classifier: Union[Type[T], ClassifierFunction[T]], 127 | spec_func: SpecFunction[T], 128 | ) -> None: 129 | if inspect.isclass(classifier): 130 | if classifier in self._class_specs: 131 | raise DuplicatedRegistrationError( 132 | f"Spec function for base class: {classifier} was already added" 133 | ) 134 | self._class_specs[cast(Type[T], classifier)] = spec_func 135 | elif callable(classifier): 136 | if classifier in self._classifier_specs: 137 | raise DuplicatedRegistrationError( 138 | f"Spec function for classifier {classifier} was already added" 139 | ) 140 | self._classifier_specs[cast(ClassifierFunction[T], classifier)] = spec_func 141 | else: 142 | raise ValueError("Incorrect type of the classifier argument") 143 | 144 | def add( 145 | self, 146 | source_cls: Type[S], 147 | target_cls: Type[T], 148 | override: bool = False, 149 | fields_mapping: FieldsMap = None, 150 | ) -> None: 151 | """Adds mapping between object of `source class` to an object of `target class`. 152 | 153 | Args: 154 | source_cls (Type[S]): Source class to map from 155 | target_cls (Type[T]): Target class to map to 156 | override (bool, optional): Override existing `source class` mapping to use new `target class`. 157 | Defaults to False. 158 | fields_mapping (FieldsMap, optional): Custom mapping. 159 | Specify dictionary in format {"field_name": value_object}. Defaults to None. 160 | 161 | Raises: 162 | DuplicatedRegistrationError: Same mapping for `source class` was added. 163 | Only one mapping per source class can exist at a time for now. 164 | You can specify target class manually using `mapper.to(target_cls)` method 165 | or use `override` argument to replace existing mapping. 166 | """ 167 | if source_cls in self._mappings and not override: 168 | raise DuplicatedRegistrationError( 169 | f"source_cls {source_cls} was already added for mapping" 170 | ) 171 | self._mappings[source_cls] = (target_cls, fields_mapping) 172 | 173 | def map( 174 | self, 175 | obj: object, 176 | *, 177 | skip_none_values: bool = False, 178 | fields_mapping: FieldsMap = None, 179 | use_deepcopy: bool = True, 180 | ) -> T: # type: ignore [type-var] 181 | """Produces output object mapped from source object and custom arguments 182 | 183 | Args: 184 | obj (object): Source object to map to `target class`. 185 | skip_none_values (bool, optional): Skip None values when creating `target class` obj. Defaults to False. 186 | fields_mapping (FieldsMap, optional): Custom mapping. 187 | Specify dictionary in format {"field_name": value_object}. Defaults to None. 188 | use_deepcopy (bool, optional): Apply deepcopy to all child objects when copy from source to target object. 189 | Defaults to True. 190 | 191 | Raises: 192 | MappingError: No `target class` specified to be mapped into. 193 | Register mappings using `mapped.add(...)` or specify `target class` using `mapper.to(target_cls).map()`. 194 | CircularReferenceError: Circular references in `source class` object are not allowed yet. 195 | 196 | Returns: 197 | T: instance of `target class` with mapped values from `source class` or custom `fields_mapping` dictionary. 198 | """ 199 | obj_type = type(obj) 200 | if obj_type not in self._mappings: 201 | raise MappingError(f"Missing mapping type for input type {obj_type}") 202 | obj_type_prefix = f"{obj_type.__name__}." 203 | 204 | target_cls, target_cls_field_mappings = self._mappings[obj_type] 205 | 206 | common_fields_mapping = fields_mapping 207 | if target_cls_field_mappings: 208 | # transform mapping if it's from source class field 209 | common_fields_mapping = { 210 | target_obj_field: ( 211 | getattr(obj, source_field[len(obj_type_prefix) :]) 212 | if isinstance(source_field, str) 213 | and source_field.startswith(obj_type_prefix) 214 | else source_field 215 | ) 216 | for target_obj_field, source_field in target_cls_field_mappings.items() 217 | } 218 | if fields_mapping: 219 | common_fields_mapping = { 220 | **common_fields_mapping, 221 | **fields_mapping, 222 | } # merge two dict into one, fields_mapping has priority 223 | 224 | return self._map_common( 225 | obj, 226 | target_cls, 227 | set(), 228 | skip_none_values=skip_none_values, 229 | custom_mapping=common_fields_mapping, 230 | use_deepcopy=use_deepcopy, 231 | ) 232 | 233 | def _get_fields(self, target_cls: Type[T]) -> Iterable[str]: 234 | """Retrieved list of fields for initializing target class object""" 235 | for base_class in self._class_specs: 236 | if issubclass(target_cls, base_class): 237 | return self._class_specs[base_class](target_cls) 238 | 239 | for classifier in reversed(self._classifier_specs): 240 | if classifier(target_cls): 241 | return self._classifier_specs[classifier](target_cls) 242 | 243 | target_cls_name = getattr(target_cls, "__name__", type(target_cls)) 244 | raise MappingError( 245 | f"No spec function is added for base class of {target_cls_name!r}" 246 | ) 247 | 248 | def _map_subobject( 249 | self, obj: S, _visited_stack: Set[int], skip_none_values: bool = False 250 | ) -> Any: 251 | """Maps subobjects recursively""" 252 | if is_primitive(obj) or is_enum(obj): 253 | return obj 254 | 255 | obj_id = id(obj) 256 | if obj_id in _visited_stack: 257 | raise CircularReferenceError() 258 | 259 | if type(obj) in self._mappings: 260 | target_cls, _ = self._mappings[type(obj)] 261 | result: Any = self._map_common( 262 | obj, target_cls, _visited_stack, skip_none_values=skip_none_values 263 | ) 264 | else: 265 | _visited_stack.add(obj_id) 266 | 267 | if is_dictionary(obj): 268 | result = type(obj)( # type: ignore [call-arg] 269 | { 270 | k: self._map_subobject( 271 | v, _visited_stack, skip_none_values=skip_none_values 272 | ) 273 | for k, v in obj.items() # type: ignore [attr-defined] 274 | } 275 | ) 276 | elif is_sequence(obj): 277 | result = type(obj)( # type: ignore [call-arg] 278 | [ 279 | self._map_subobject( 280 | x, _visited_stack, skip_none_values=skip_none_values 281 | ) 282 | for x in cast(Iterable[Any], obj) 283 | ] 284 | ) 285 | else: 286 | result = deepcopy(obj) 287 | 288 | _visited_stack.remove(obj_id) 289 | 290 | return result 291 | 292 | def _map_common( 293 | self, 294 | obj: S, 295 | target_cls: Type[T], 296 | _visited_stack: Set[int], 297 | skip_none_values: bool = False, 298 | custom_mapping: FieldsMap = None, 299 | use_deepcopy: bool = True, 300 | ) -> T: 301 | """Produces output object mapped from source object and custom arguments. 302 | 303 | Args: 304 | obj (S): Source object to map to `target class`. 305 | target_cls (Type[T]): Target class to map to. 306 | _visited_stack (Set[int]): Visited child objects. To avoid infinite recursive calls. 307 | skip_none_values (bool, optional): Skip None values when creating `target class` obj. Defaults to False. 308 | custom_mapping (FieldsMap, optional): Custom mapping. 309 | Specify dictionary in format {"field_name": value_object}. Defaults to None. 310 | use_deepcopy (bool, optional): Apply deepcopy to all child objects when copy from source to target object. 311 | Defaults to True. 312 | 313 | Raises: 314 | CircularReferenceError: Circular references in `source class` object are not allowed yet. 315 | 316 | Returns: 317 | T: Instance of `target class` with mapped fields. 318 | """ 319 | obj_id = id(obj) 320 | 321 | if obj_id in _visited_stack: 322 | raise CircularReferenceError() 323 | _visited_stack.add(obj_id) 324 | 325 | target_cls_fields = self._get_fields(target_cls) 326 | 327 | mapped_values: Dict[str, Any] = {} 328 | for field_name in target_cls_fields: 329 | value_found, value = _try_get_field_value(field_name, obj, custom_mapping) 330 | if not value_found: 331 | continue 332 | 333 | if value is not None: 334 | if use_deepcopy: 335 | mapped_values[field_name] = self._map_subobject( 336 | value, _visited_stack, skip_none_values 337 | ) 338 | else: # if use_deepcopy is False, simply assign value to target obj. 339 | mapped_values[field_name] = value 340 | elif not skip_none_values: 341 | mapped_values[field_name] = None 342 | 343 | _visited_stack.remove(obj_id) 344 | 345 | return cast(target_cls, target_cls(**mapped_values)) # type: ignore [valid-type] 346 | 347 | def to(self, target_cls: Type[T]) -> MappingWrapper[T]: 348 | """Specify `target class` to which map `source class` object. 349 | 350 | Args: 351 | target_cls (Type[T]): Target class. 352 | 353 | Returns: 354 | MappingWrapper[T]: Mapping wrapper. Use `map` method to perform mapping now. 355 | """ 356 | return MappingWrapper[T](self, target_cls) 357 | -------------------------------------------------------------------------------- /automapper/mapper_initializer.py: -------------------------------------------------------------------------------- 1 | import glob 2 | import importlib 3 | import importlib.util 4 | import logging 5 | from os.path import basename, dirname, isfile, join 6 | 7 | from . import Mapper 8 | 9 | __DEFAULT_EXTENSION__ = "default" 10 | __EXTENSIONS_FOLDER__ = "extensions" 11 | __PACKAGE_PATH__ = __package__ + "." + __EXTENSIONS_FOLDER__ 12 | 13 | log = logging.getLogger("automapper") 14 | 15 | 16 | def create_mapper() -> Mapper: 17 | """Returns a Mapper instance with preloaded extensions""" 18 | mapper = Mapper() 19 | extensions = glob.glob(join(dirname(__file__), __EXTENSIONS_FOLDER__, "*.py")) 20 | for extension in extensions: 21 | if isfile(extension) and not extension.endswith("__init__.py"): 22 | module_name = basename(extension)[:-3] 23 | if ( 24 | module_name == __DEFAULT_EXTENSION__ 25 | or importlib.util.find_spec(module_name) is not None 26 | ): 27 | try: 28 | extension_package = importlib.import_module( 29 | __PACKAGE_PATH__ + "." + module_name 30 | ) 31 | extension_package.extend(mapper) 32 | except Exception: 33 | log.exception( 34 | f"Found module {module_name} but could not load extension for it." 35 | ) 36 | return mapper 37 | -------------------------------------------------------------------------------- /automapper/py.typed: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/anikolaienko/py-automapper/0e91f49317fee83abae96af91f7a2122955c872e/automapper/py.typed -------------------------------------------------------------------------------- /automapper/utils.py: -------------------------------------------------------------------------------- 1 | from enum import Enum 2 | from typing import Any, Dict, Sequence 3 | 4 | __PRIMITIVE_TYPES = {int, float, complex, str, bytes, bytearray, bool} 5 | 6 | 7 | def is_sequence(obj: Any) -> bool: 8 | """Check if object implements `__iter__` method""" 9 | return isinstance(obj, Sequence) 10 | 11 | 12 | def is_dictionary(obj: Any) -> bool: 13 | """Check is object is of type dictionary""" 14 | return isinstance(obj, Dict) 15 | 16 | 17 | def is_subscriptable(obj: Any) -> bool: 18 | """Check if object implements `__getitem__` method""" 19 | return hasattr(obj, "__getitem__") 20 | 21 | 22 | def object_contains(obj: Any, field_name: str) -> bool: 23 | return is_subscriptable(obj) and field_name in obj 24 | 25 | 26 | def is_primitive(obj: Any) -> bool: 27 | """Check if object type is primitive""" 28 | return type(obj) in __PRIMITIVE_TYPES 29 | 30 | 31 | def is_enum(obj: Any) -> bool: 32 | """Check if object type is enum""" 33 | return issubclass(type(obj), Enum) 34 | -------------------------------------------------------------------------------- /docs/_config.yaml: -------------------------------------------------------------------------------- 1 | theme: jekyll-theme-slate -------------------------------------------------------------------------------- /docs/index.md: -------------------------------------------------------------------------------- 1 | ## py-automapper 2 | 3 | The idea came from a need on the project to map types of "different nature" between each other. Namely, if we take TortoiseORM Model class and Python dataclass there's no, known to me, single method to retrieve list of fields required by the class. Current autommapper allows to create mappings between these two classes and allows to write extensions for supporting other classes as well. 4 | 5 | For now check out [README.md](https://github.com/anikolaienko/py-automapper) for documentation. 6 | 7 | ### Support/Contribute 8 | 9 | If you have any questions or ideas check out existing issues list or create a new one on [Issues page](https://github.com/anikolaienko/py-automapper/issues). 10 | -------------------------------------------------------------------------------- /logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/anikolaienko/py-automapper/0e91f49317fee83abae96af91f7a2122955c872e/logo.png -------------------------------------------------------------------------------- /pyproject.toml: -------------------------------------------------------------------------------- 1 | [build-system] 2 | requires = ["setuptools >= 61.0"] 3 | build-backend = "setuptools.build_meta" 4 | 5 | [project] 6 | name = "py-automapper" 7 | version = "2.2.0" 8 | description = "Library for automatically mapping one object to another" 9 | authors = [ 10 | {name = "Andrii Nikolaienko", email = "anikolaienko14@gmail.com"} 11 | ] 12 | maintainers = [ 13 | {name = "Andrii Nikolaienko", email = "anikolaienko14@gmail.com"} 14 | ] 15 | requires-python = ">= 3.8" 16 | license = {file = "LICENSE"} 17 | readme = "README.md" 18 | keywords = ["utils", "dto", "object-mapper", "mapping", "development"] 19 | classifiers = [ 20 | "License :: OSI Approved :: MIT License", 21 | "Development Status :: 5 - Production/Stable", 22 | "Intended Audience :: Developers", 23 | "Programming Language :: Python :: 3", 24 | "Programming Language :: Python :: 3.8", 25 | "Programming Language :: Python :: 3.9", 26 | "Programming Language :: Python :: 3.10", 27 | "Programming Language :: Python :: 3.11", 28 | "Programming Language :: Python :: 3.12", 29 | "Programming Language :: Python :: 3.13", 30 | "Programming Language :: Python :: 3 :: Only", 31 | "Programming Language :: Python :: Implementation :: CPython", 32 | "Programming Language :: Python :: Implementation :: PyPy", 33 | "Topic :: Software Development :: Libraries :: Python Modules", 34 | "Topic :: Software Development :: Build Tools", 35 | ] 36 | 37 | [project.urls] 38 | Homepage = "https://github.com/anikolaienko/py-automapper" 39 | Repository = "https://github.com/anikolaienko/py-automapper.git" 40 | Issues = "https://github.com/anikolaienko/py-automapper/issues" 41 | Changelog = "https://github.com/anikolaienko/py-automapper/blob/main/CHANGELOG.md" 42 | 43 | [project.optional-dependencies] 44 | dev = [ 45 | "tortoise-orm~=0.23.0", 46 | "pydantic~=2.10.6", 47 | "SQLAlchemy~=2.0.38", 48 | "twine~=6.1.0", 49 | "Sphinx~=7.1.2" 50 | ] 51 | test = [ 52 | "pre-commit~=3.5.0", 53 | "pytest~=8.3.5", 54 | "pytest-cov~=5.0.0", 55 | "mypy~=1.14.1" 56 | ] 57 | 58 | [tool.pytest.ini_options] 59 | addopts = "--cov=automapper --cov-report html" 60 | testpaths = [ 61 | "tests" 62 | ] 63 | -------------------------------------------------------------------------------- /tests/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/anikolaienko/py-automapper/0e91f49317fee83abae96af91f7a2122955c872e/tests/__init__.py -------------------------------------------------------------------------------- /tests/extensions/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/anikolaienko/py-automapper/0e91f49317fee83abae96af91f7a2122955c872e/tests/extensions/__init__.py -------------------------------------------------------------------------------- /tests/extensions/test_default_extention.py: -------------------------------------------------------------------------------- 1 | from collections import namedtuple 2 | from typing import Any, Iterable, Protocol, Type, TypeVar, cast, runtime_checkable 3 | from unittest import TestCase 4 | 5 | from automapper import Mapper 6 | from automapper.extensions.default import extend 7 | 8 | T = TypeVar("T") 9 | 10 | 11 | class ClassWithoutInitAttrDef: 12 | def __init__(self, **kwargs: Any) -> None: 13 | self.data = kwargs.copy() 14 | 15 | @classmethod 16 | def fields(cls) -> Iterable[str]: 17 | return ["text", "num"] 18 | 19 | 20 | @runtime_checkable 21 | class ClassWithFieldsMethodProtocol(Protocol): 22 | def fields(self) -> Iterable[str]: ... 23 | 24 | 25 | def classifier_func(target_cls: Type[T]) -> bool: 26 | return callable(getattr(target_cls, "fields", None)) 27 | 28 | 29 | def spec_func(target_cls: Type[T]) -> Iterable[str]: 30 | return cast(ClassWithFieldsMethodProtocol, target_cls).fields() 31 | 32 | 33 | class DefaultExtensionTest(TestCase): 34 | def setUp(self): 35 | self.mapper = Mapper() 36 | extend(self.mapper) 37 | 38 | def test_default_extension__does_not_work_for_kwargs_in_init(self): 39 | obj = self.mapper.to(ClassWithoutInitAttrDef).map({"text": "text", "num": 1}) 40 | assert len(obj.data) == 0 41 | 42 | def test_custom_spec_for_class__works_for_kwargs_in_init(self): 43 | self.mapper.add_spec(ClassWithoutInitAttrDef, spec_func) 44 | source = namedtuple("SourceObj", ["text", "num"])("text_msg", 11) # type: ignore [call-arg] 45 | 46 | obj = self.mapper.to(ClassWithoutInitAttrDef).map(source) 47 | 48 | assert obj.data.get("text") == "text_msg" 49 | assert obj.data.get("num") == 11 50 | 51 | def test_custom_spec_with_classifier__works_for_kwargs_in_init(self): 52 | self.mapper.add_spec(classifier_func, spec_func) 53 | source = namedtuple("SourceObj", ["text", "num"])("text_msg", 11) # type: ignore [call-arg] 54 | 55 | obj = self.mapper.to(ClassWithoutInitAttrDef).map(source) 56 | 57 | assert obj.data.get("text") == "text_msg" 58 | assert obj.data.get("num") == 11 59 | -------------------------------------------------------------------------------- /tests/extensions/test_pydantic_extention.py: -------------------------------------------------------------------------------- 1 | from typing import List 2 | from unittest import TestCase 3 | 4 | import pytest 5 | from automapper import Mapper, MappingError 6 | from automapper import mapper as default_mapper 7 | from pydantic import BaseModel 8 | 9 | 10 | class UserInfo(BaseModel): 11 | id: int 12 | full_name: str 13 | public_name: str 14 | hobbies: List[str] 15 | 16 | 17 | class PublicUserInfo(BaseModel): 18 | id: int 19 | public_name: str 20 | hobbies: List[str] 21 | 22 | 23 | class PydanticExtensionTest(TestCase): 24 | """These scenario are known for FastAPI Framework models and Pydantic models in general.""" 25 | 26 | def setUp(self) -> None: 27 | self.mapper = Mapper() 28 | 29 | def test_map__fails_for_pydantic_mapping(self): 30 | obj = UserInfo( 31 | id=2, 32 | full_name="Danny DeVito", 33 | public_name="dannyd", 34 | hobbies=["acting", "comedy", "swimming"], 35 | ) 36 | with pytest.raises(MappingError): 37 | self.mapper.to(PublicUserInfo).map(obj) 38 | 39 | def test_map__global_mapper_works_with_provided_pydantic_extension(self): 40 | obj = UserInfo( 41 | id=2, 42 | full_name="Danny DeVito", 43 | public_name="dannyd", 44 | hobbies=["acting", "comedy", "swimming"], 45 | ) 46 | 47 | result = default_mapper.to(PublicUserInfo).map(obj) 48 | 49 | assert result.id == 2 50 | assert result.public_name == "dannyd" 51 | assert set(result.hobbies) == set(["acting", "comedy", "swimming"]) 52 | with pytest.raises(AttributeError): 53 | getattr(result, "full_name") 54 | -------------------------------------------------------------------------------- /tests/extensions/test_sqlalchemy_extention.py: -------------------------------------------------------------------------------- 1 | from unittest import TestCase 2 | 3 | import pytest 4 | from automapper import Mapper, MappingError 5 | from automapper import mapper as default_mapper 6 | from sqlalchemy import Column, Integer, String 7 | from sqlalchemy.orm import DeclarativeBase 8 | 9 | 10 | class Base(DeclarativeBase): 11 | pass 12 | 13 | 14 | class UserInfo(Base): 15 | __tablename__ = "users" 16 | id = Column(Integer, primary_key=True) 17 | full_name = Column(String) 18 | public_name = Column(String) 19 | hobbies = Column(String) 20 | 21 | def __repr__(self): 22 | return "" % ( 23 | self.full_name, 24 | self.public_name, 25 | self.hobbies, 26 | ) 27 | 28 | 29 | class PublicUserInfo(Base): 30 | __tablename__ = "public_users" 31 | id = Column(Integer, primary_key=True) 32 | public_name = Column(String) 33 | hobbies = Column(String) 34 | 35 | 36 | class SQLalchemyORMExtensionTest(TestCase): 37 | """These scenarios are known for ORM systems. 38 | e.g. Model classes in Tortoise ORM 39 | """ 40 | 41 | def setUp(self) -> None: 42 | self.mapper = Mapper() 43 | 44 | def test_map__fails_for_sqlalchemy_mapping(self): 45 | obj = UserInfo( 46 | id=2, 47 | full_name="Danny DeVito", 48 | public_name="dannyd", 49 | hobbies="acting, comedy, swimming", 50 | ) 51 | with pytest.raises(MappingError): 52 | self.mapper.to(PublicUserInfo).map(obj) 53 | 54 | def test_map__global_mapper_works_with_provided_sqlalchemy_extension(self): 55 | obj = UserInfo( 56 | id=2, 57 | full_name="Danny DeVito", 58 | public_name="dannyd", 59 | hobbies="acting, comedy, swimming", 60 | ) 61 | 62 | result = default_mapper.to(PublicUserInfo).map(obj) 63 | 64 | assert result.id == 2 65 | assert result.public_name == "dannyd" 66 | assert result.hobbies == "acting, comedy, swimming" 67 | with pytest.raises(AttributeError): 68 | getattr(result, "full_name") 69 | -------------------------------------------------------------------------------- /tests/extensions/test_tortoise_extention.py: -------------------------------------------------------------------------------- 1 | from typing import Any 2 | from unittest import TestCase 3 | 4 | import pytest 5 | from automapper import Mapper, MappingError 6 | from automapper import mapper as default_mapper 7 | from tortoise import Model, fields 8 | 9 | 10 | class UserInfo(Model): 11 | id = fields.IntField(primary_key=True) 12 | full_name = fields.TextField() 13 | public_name = fields.TextField() 14 | hobbies: Any = fields.JSONField() 15 | 16 | 17 | class PublicUserInfo(Model): 18 | id = fields.IntField(primary_key=True) 19 | public_name = fields.TextField() 20 | hobbies: Any = fields.JSONField() 21 | 22 | 23 | class TortoiseORMExtensionTest(TestCase): 24 | """These scenario are known for ORM systems. 25 | e.g. Model classes in Tortoise ORM 26 | """ 27 | 28 | def setUp(self) -> None: 29 | self.mapper = Mapper() 30 | 31 | def test_map__fails_for_tortoise_mapping(self): 32 | obj = UserInfo( 33 | id=2, 34 | full_name="Danny DeVito", 35 | public_name="dannyd", 36 | hobbies=["acting", "comedy", "swimming"], 37 | ) 38 | with pytest.raises(MappingError): 39 | self.mapper.to(PublicUserInfo).map(obj) 40 | 41 | def test_map__global_mapper_works_with_provided_tortoise_extension(self): 42 | obj = UserInfo( 43 | id=2, 44 | full_name="Danny DeVito", 45 | public_name="dannyd", 46 | hobbies=["acting", "comedy", "swimming"], 47 | using_db=True, 48 | ) 49 | 50 | result = default_mapper.to(PublicUserInfo).map(obj) 51 | 52 | assert result.id == 2 53 | assert result.public_name == "dannyd" 54 | assert set(result.hobbies) == set(["acting", "comedy", "swimming"]) 55 | with pytest.raises(AttributeError): 56 | getattr(result, "full_name") 57 | -------------------------------------------------------------------------------- /tests/test_automapper_basics.py: -------------------------------------------------------------------------------- 1 | from dataclasses import dataclass 2 | 3 | from automapper import mapper 4 | 5 | 6 | class UserInfo: 7 | def __init__(self, name: str, age: int, profession: str): 8 | self.name = name 9 | self.age = age 10 | self.profession = profession 11 | 12 | 13 | class PublicUserInfo: 14 | def __init__(self, name: str, profession: str): 15 | self.name = name 16 | self.profession = profession 17 | 18 | 19 | class PublicUserInfoDiff: 20 | def __init__(self, full_name: str, profession: str): 21 | self.full_name = full_name 22 | self.profession = profession 23 | 24 | 25 | @dataclass 26 | class Address: 27 | street: str 28 | number: int 29 | zip_code: int 30 | city: str 31 | 32 | 33 | class PersonInfo: 34 | def __init__(self, name: str, age: int, address: Address): 35 | self.name = name 36 | self.age = age 37 | self.address = address 38 | 39 | 40 | class PublicPersonInfo: 41 | def __init__(self, name: str, address: Address): 42 | self.name = name 43 | self.address = address 44 | 45 | 46 | def test_map__field_with_same_name(): 47 | user_info = UserInfo("John Malkovich", 35, "engineer") 48 | public_user_info = mapper.to(PublicUserInfo).map( 49 | user_info, fields_mapping={"full_name": user_info.name} 50 | ) 51 | 52 | assert public_user_info.name == "John Malkovich" 53 | assert public_user_info.profession == "engineer" 54 | 55 | 56 | def test_map__field_with_different_name(): 57 | user_info = UserInfo("John Malkovich", 35, "engineer") 58 | public_user_info: PublicUserInfoDiff = mapper.to(PublicUserInfoDiff).map( 59 | user_info, fields_mapping={"full_name": user_info.name} 60 | ) 61 | 62 | assert public_user_info.full_name == "John Malkovich" 63 | assert public_user_info.profession == "engineer" 64 | 65 | 66 | def test_map__field_with_different_name_register(): 67 | try: 68 | mapper.add( 69 | UserInfo, PublicUserInfoDiff, fields_mapping={"full_name": "UserInfo.name"} 70 | ) 71 | 72 | user_info = UserInfo("John Malkovich", 35, "engineer") 73 | public_user_info: PublicUserInfoDiff = mapper.map(user_info) 74 | 75 | assert public_user_info.full_name == "John Malkovich" 76 | assert public_user_info.profession == "engineer" 77 | finally: 78 | mapper._mappings.clear() 79 | 80 | 81 | def test_map__override_field_value(): 82 | try: 83 | user_info = UserInfo("John Malkovich", 35, "engineer") 84 | public_user_info = mapper.to(PublicUserInfo).map( 85 | user_info, fields_mapping={"name": "John Cusack"} 86 | ) 87 | 88 | assert public_user_info.name == "John Cusack" 89 | assert public_user_info.profession == "engineer" 90 | finally: 91 | mapper._mappings.clear() 92 | 93 | 94 | def test_map__override_field_value_register(): 95 | try: 96 | mapper.add(UserInfo, PublicUserInfo, fields_mapping={"name": "John Cusack"}) 97 | 98 | user_info = UserInfo("John Malkovich", 35, "engineer") 99 | public_user_info: PublicUserInfo = mapper.map(user_info) 100 | 101 | assert public_user_info.name == "John Cusack" 102 | assert public_user_info.profession == "engineer" 103 | finally: 104 | mapper._mappings.clear() 105 | 106 | 107 | def test_map__check_deepcopy_not_applied_if_use_deepcopy_false(): 108 | address = Address(street="Main Street", number=1, zip_code=100001, city="Test City") 109 | info = PersonInfo("John Doe", age=35, address=address) 110 | 111 | public_info = mapper.to(PublicPersonInfo).map(info) 112 | assert address is not public_info.address 113 | 114 | public_info = mapper.to(PublicPersonInfo).map(info, use_deepcopy=False) 115 | assert address is public_info.address 116 | -------------------------------------------------------------------------------- /tests/test_automapper_dict.py: -------------------------------------------------------------------------------- 1 | from collections import OrderedDict 2 | from dataclasses import dataclass 3 | from typing import Dict 4 | 5 | from automapper import mapper 6 | 7 | 8 | @dataclass 9 | class Teacher: 10 | teacher: str 11 | 12 | 13 | class Student: 14 | def __init__(self, name: str, classes: Dict[str, Teacher]): 15 | self.name = name 16 | self.classes = classes 17 | self.ordered_classes = OrderedDict(classes) 18 | 19 | 20 | class PublicUserInfo: 21 | def __init__( 22 | self, 23 | name: str, 24 | classes: Dict[str, Teacher], 25 | ordered_classes: Dict[str, Teacher], 26 | ): 27 | self.name = name 28 | self.classes = classes 29 | self.ordered_classes = ordered_classes 30 | 31 | 32 | def test_map__dict_and_ordereddict_are_mapped_correctly_to_same_types(): 33 | classes = {"math": Teacher("Ms G"), "art": Teacher("Mr A")} 34 | student = Student("Tim", classes) 35 | 36 | public_info = mapper.to(PublicUserInfo).map(student) 37 | 38 | assert public_info.name is student.name 39 | 40 | assert public_info.classes == student.classes 41 | assert public_info.classes is not student.classes 42 | assert isinstance(public_info.classes, Dict) 43 | 44 | assert public_info.ordered_classes == student.ordered_classes 45 | assert public_info.ordered_classes is not student.ordered_classes 46 | assert isinstance(public_info.ordered_classes, OrderedDict) 47 | -------------------------------------------------------------------------------- /tests/test_automapper_enum.py: -------------------------------------------------------------------------------- 1 | from enum import Enum, IntEnum 2 | 3 | from automapper import mapper 4 | 5 | 6 | class StringEnum(str, Enum): 7 | Value1 = "value1" 8 | Value2 = "value2" 9 | Value3 = "value3" 10 | 11 | 12 | class IntValueEnum(IntEnum): 13 | Value1 = 1 14 | Value2 = 2 15 | Value3 = 3 16 | 17 | 18 | class TupleEnum(Enum): 19 | Value1 = ("value", "1") 20 | Value2 = ("value", "2") 21 | Value3 = ("value", "3") 22 | 23 | 24 | class SourceClass: 25 | def __init__( 26 | self, string_value: StringEnum, int_value: IntValueEnum, tuple_value: TupleEnum 27 | ) -> None: 28 | self.string_value = string_value 29 | self.int_value = int_value 30 | self.tuple_value = tuple_value 31 | 32 | 33 | class TargetClass: 34 | def __init__( 35 | self, string_value: StringEnum, int_value: IntValueEnum, tuple_value: TupleEnum 36 | ) -> None: 37 | self.string_value = string_value 38 | self.int_value = int_value 39 | self.tuple_value = tuple_value 40 | 41 | 42 | def test_map__enum(): 43 | src = SourceClass(StringEnum.Value1, IntValueEnum.Value2, TupleEnum.Value3) 44 | dst = mapper.to(TargetClass).map(src) 45 | 46 | assert dst.string_value == StringEnum.Value1 47 | assert dst.int_value == IntValueEnum.Value2 48 | assert dst.tuple_value == TupleEnum.Value3 49 | -------------------------------------------------------------------------------- /tests/test_complex_objects_mapping.py: -------------------------------------------------------------------------------- 1 | from typing import Any, Iterable, Optional, Protocol, TypeVar 2 | from unittest import TestCase 3 | 4 | import pytest 5 | from automapper import CircularReferenceError, create_mapper 6 | 7 | T = TypeVar("T") 8 | 9 | 10 | class ParentClass: 11 | def __init__(self, num: int, text: str) -> None: 12 | self.num = num 13 | self.text = text 14 | 15 | 16 | class ChildClass(ParentClass): 17 | def __init__(self, num: int, text: str, flag: bool) -> None: 18 | super().__init__(num, text) 19 | self.flag = flag 20 | 21 | @classmethod 22 | def fields(cls) -> Iterable[str]: 23 | return ( 24 | field for field in cls.__init__.__annotations__.keys() if field != "return" 25 | ) 26 | 27 | 28 | class AnotherClass: 29 | def __init__(self, text: str, num: int) -> None: 30 | self.text = text 31 | self.num = num 32 | 33 | 34 | class ClassWithoutInitAttrDef: 35 | def __init__(self, **kwargs: Any) -> None: 36 | self.data = kwargs.copy() 37 | 38 | @classmethod 39 | def fields(cls) -> Iterable[str]: 40 | return ["text", "num"] 41 | 42 | 43 | class ClassWithFieldsMethodProtocol(Protocol): 44 | def fields(self) -> Iterable[str]: ... 45 | 46 | 47 | class ComplexClass: 48 | def __init__(self, obj: ChildClass, text: str) -> None: 49 | self.obj = obj 50 | self.text = text 51 | 52 | 53 | class AnotherComplexClass: 54 | def __init__(self, text: str, obj: ChildClass) -> None: 55 | self.text = text 56 | self.obj = obj 57 | 58 | 59 | class ComplexObjWithCircularRef: 60 | def __init__(self, child: "WrapperClass"): 61 | self.child = child 62 | 63 | 64 | class WrapperClass: 65 | def __init__(self, num: int, circular_ref_obj: Optional[ComplexObjWithCircularRef]): 66 | self.num = num 67 | self.circular_ref_obj = circular_ref_obj 68 | 69 | 70 | class MappingComplexObjTest(TestCase): 71 | def setUp(self): 72 | self.mapper = create_mapper() 73 | 74 | def test_map__complext_obj(self): 75 | complex_obj = ComplexClass( 76 | obj=ChildClass(15, "nested_obj_msg", True), text="obj_msg" 77 | ) 78 | self.mapper.add(ChildClass, AnotherClass) 79 | self.mapper.add(ComplexClass, AnotherComplexClass) 80 | 81 | result: AnotherComplexClass = self.mapper.map(complex_obj) 82 | 83 | assert isinstance(result, AnotherComplexClass) 84 | assert isinstance(result.obj, AnotherClass) 85 | assert result.obj.text == "nested_obj_msg" 86 | assert result.obj.num == 15 87 | assert result.text == "obj_msg" 88 | 89 | def test_map__complext_obj_with_circular_ref(self): 90 | wrapper_obj = WrapperClass(15, None) 91 | source = ComplexObjWithCircularRef(wrapper_obj) 92 | 93 | self.mapper.add(ComplexObjWithCircularRef, ComplexObjWithCircularRef) 94 | self.mapper.add(WrapperClass, WrapperClass) 95 | 96 | result: ComplexObjWithCircularRef = self.mapper.map(source) 97 | assert result.child.num == 15 98 | 99 | # adding circular ref 100 | wrapper_obj.circular_ref_obj = source 101 | 102 | with pytest.raises(CircularReferenceError): 103 | result = self.mapper.map(source) 104 | -------------------------------------------------------------------------------- /tests/test_dict_field_mapping.py: -------------------------------------------------------------------------------- 1 | from typing import Any, Dict 2 | from unittest import TestCase 3 | 4 | from automapper import mapper 5 | 6 | 7 | class Candy: 8 | def __init__(self, name: str, brand: str): 9 | self.name = name 10 | self.brand = brand 11 | 12 | 13 | class Shop: 14 | def __init__(self, products: Dict[str, Any], annual_income: int): 15 | self.products: Dict[str, Any] = products 16 | self.annual_income = annual_income 17 | 18 | 19 | class ShopPublicInfo: 20 | def __init__(self, products: Dict[str, Any]): 21 | self.products: Dict[str, Any] = products 22 | 23 | 24 | class AutomapperDictFieldTest(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 | 35 | def test_map__with_dict_field(self): 36 | public_info = mapper.to(ShopPublicInfo).map(self.shop) 37 | 38 | self.assertEqual( 39 | public_info.products["magazines"], self.shop.products["magazines"] 40 | ) 41 | self.assertNotEqual( 42 | id(public_info.products["magazines"]), id(self.shop.products["magazines"]) 43 | ) 44 | 45 | self.assertNotEqual( 46 | public_info.products["candies"], self.shop.products["candies"] 47 | ) 48 | self.assertNotEqual( 49 | public_info.products["candies"][0], self.shop.products["candies"][0] 50 | ) 51 | self.assertNotEqual( 52 | public_info.products["candies"][1], self.shop.products["candies"][1] 53 | ) 54 | 55 | self.assertEqual(public_info.products["candies"][0].name, "Reese's cups") 56 | self.assertEqual( 57 | public_info.products["candies"][0].brand, "The Hershey Company" 58 | ) 59 | 60 | self.assertEqual(public_info.products["candies"][1].name, "Snickers") 61 | self.assertEqual(public_info.products["candies"][1].brand, "Mars, Incorporated") 62 | 63 | def test_map__use_deepcopy_false(self): 64 | public_info_deep = mapper.to(ShopPublicInfo).map(self.shop, use_deepcopy=False) 65 | public_info = mapper.to(ShopPublicInfo).map(self.shop) 66 | 67 | self.assertIsNot(public_info.products, self.shop.products) 68 | self.assertEqual( 69 | public_info.products["magazines"], self.shop.products["magazines"] 70 | ) 71 | self.assertNotEqual( 72 | public_info.products["magazines"], id(self.shop.products["magazines"]) 73 | ) 74 | 75 | self.assertIs(public_info_deep.products, self.shop.products) 76 | self.assertEqual( 77 | id(public_info_deep.products["magazines"]), 78 | id(self.shop.products["magazines"]), 79 | ) 80 | -------------------------------------------------------------------------------- /tests/test_issue_18_implicit_child_field_mapping.py: -------------------------------------------------------------------------------- 1 | from dataclasses import dataclass 2 | from typing import Optional 3 | 4 | from automapper import mapper 5 | 6 | 7 | @dataclass 8 | class UserDomain: 9 | id: int 10 | name: str 11 | email: str 12 | 13 | 14 | @dataclass 15 | class InputDomain: 16 | description: str 17 | user: UserDomain 18 | 19 | 20 | @dataclass 21 | class OutputModel: 22 | description: str 23 | user: UserDomain 24 | user_id: Optional[int] = None 25 | 26 | 27 | def test_map__implicit_mapping_of_child_obj_field_to_parent_obj_field_not_supported(): 28 | user = UserDomain(id=123, name="carlo", email="mail_carlo") 29 | domain = InputDomain(description="todo_carlo", user=user) 30 | 31 | mapper.add(InputDomain, OutputModel) 32 | model_with_none_user_id: OutputModel = mapper.map(domain) 33 | 34 | # Implicit field mapping between parent and child objects is not supported 35 | # InputDomain.user.user_id should not map to OutputModel.user_id implicitly 36 | assert model_with_none_user_id.user_id is None 37 | 38 | # Workaround: use explicit mapping 39 | todo_model_complete: OutputModel = mapper.map( 40 | domain, fields_mapping={"user_id": domain.user.id} 41 | ) 42 | 43 | assert todo_model_complete.user_id == 123 44 | -------------------------------------------------------------------------------- /tests/test_issue_25.py: -------------------------------------------------------------------------------- 1 | from typing import Optional 2 | 3 | from automapper import mapper 4 | from pydantic import BaseModel 5 | 6 | 7 | class Address(BaseModel): 8 | street: Optional[str] 9 | number: Optional[int] 10 | zip_code: Optional[int] 11 | city: Optional[str] 12 | 13 | 14 | class PersonInfo(BaseModel): 15 | name: Optional[str] = None 16 | age: Optional[int] = None 17 | address: Optional[Address] = None 18 | 19 | 20 | class PublicPersonInfo(BaseModel): 21 | name: Optional[str] = None 22 | address: Optional[Address] = None 23 | 24 | 25 | def test_map__without_deepcopy_mapped_objects_should_be_the_same(): 26 | address = Address(street="Main Street", number=1, zip_code=100001, city="Test City") 27 | info = PersonInfo(name="John Doe", age=35, address=address) 28 | 29 | # default deepcopy behavior 30 | public_info = mapper.to(PublicPersonInfo).map(info) 31 | assert ( 32 | public_info.address is not address 33 | ), "Address mapped object is same as origin." 34 | 35 | # disable deepcopy 36 | public_info = mapper.to(PublicPersonInfo).map(info, use_deepcopy=False) 37 | assert ( 38 | public_info.address is info.address 39 | ), "Address mapped object is not same as origin." 40 | -------------------------------------------------------------------------------- /tests/test_predefined_mapping.py: -------------------------------------------------------------------------------- 1 | from typing import Any, Iterable, Optional, Protocol, Type, TypeVar, cast 2 | from unittest import TestCase 3 | 4 | import pytest 5 | from automapper import DuplicatedRegistrationError, MappingError, create_mapper 6 | 7 | T = TypeVar("T") 8 | 9 | 10 | class ParentClass: 11 | def __init__(self, num: int, text: str) -> None: 12 | self.num = num 13 | self.text = text 14 | 15 | 16 | class ChildClass(ParentClass): 17 | def __init__(self, num: int, text: str, flag: bool) -> None: 18 | super().__init__(num, text) 19 | self.flag = flag 20 | 21 | @classmethod 22 | def fields(cls) -> Iterable[str]: 23 | return ( 24 | field for field in cls.__init__.__annotations__.keys() if field != "return" 25 | ) 26 | 27 | 28 | class AnotherClass: 29 | def __init__(self, text: Optional[str], num: int) -> None: 30 | self.text = text 31 | self.num = num 32 | 33 | 34 | class ClassWithoutInitAttrDef: 35 | def __init__(self, **kwargs: Any) -> None: 36 | self.data = kwargs.copy() 37 | 38 | @classmethod 39 | def fields(cls) -> Iterable[str]: 40 | return ["text", "num"] 41 | 42 | 43 | class ClassWithFieldsMethodProtocol(Protocol): 44 | def fields(self) -> Iterable[str]: ... 45 | 46 | 47 | def custom_spec_func(concrete_class: Type[T]) -> Iterable[str]: 48 | fields = [] 49 | for val_name in concrete_class.__init__.__annotations__: 50 | if val_name != "return": 51 | fields.append(val_name) 52 | return fields 53 | 54 | 55 | def classifier_func(target_cls: Type[T]) -> bool: 56 | return callable(getattr(target_cls, "fields", None)) 57 | 58 | 59 | def spec_func(target_cls: Type[T]) -> Iterable[str]: 60 | return cast(ClassWithFieldsMethodProtocol, target_cls).fields() 61 | 62 | 63 | class AutomapperTest(TestCase): 64 | def setUp(self): 65 | self.mapper = create_mapper() 66 | 67 | def test_add_spec__adds_to_internal_collection(self): 68 | self.mapper.add_spec(ParentClass, custom_spec_func) 69 | assert ParentClass in self.mapper._class_specs 70 | assert ["num", "text", "flag"] == self.mapper._class_specs[ParentClass]( 71 | ChildClass 72 | ) 73 | 74 | def test_add_spec__error_on_adding_same_class_spec(self): 75 | self.mapper.add_spec(ParentClass, custom_spec_func) 76 | with pytest.raises(DuplicatedRegistrationError): 77 | self.mapper.add_spec( 78 | ParentClass, lambda concrete_type: ["field1", "field2"] 79 | ) 80 | 81 | def test_add_spec__adds_to_internal_collection_for_classifier(self): 82 | self.mapper.add_spec(classifier_func, spec_func) 83 | assert classifier_func in self.mapper._classifier_specs 84 | assert ["text", "num"] == self.mapper._classifier_specs[classifier_func]( 85 | ClassWithoutInitAttrDef 86 | ) 87 | 88 | def test_add_spec__error_on_duplicated_registration(self): 89 | self.mapper.add_spec(classifier_func, spec_func) 90 | with pytest.raises(DuplicatedRegistrationError): 91 | self.mapper.add_spec(classifier_func, spec_func) 92 | 93 | def test_add__appends_class_to_class_mapping(self): 94 | with pytest.raises(MappingError): 95 | self.mapper.map(ChildClass) 96 | 97 | self.mapper.add_spec(AnotherClass, custom_spec_func) 98 | self.mapper.add(ChildClass, AnotherClass) 99 | result: AnotherClass = self.mapper.map(ChildClass(10, "test_message", True)) 100 | 101 | assert isinstance(result, AnotherClass) 102 | assert result.text == "test_message" 103 | assert result.num == 10 104 | 105 | def test_add__error_on_adding_same_source_class(self): 106 | class TempAnotherClass: 107 | pass 108 | 109 | self.mapper.add(ChildClass, AnotherClass) 110 | with pytest.raises(DuplicatedRegistrationError): 111 | self.mapper.add(ChildClass, TempAnotherClass) 112 | 113 | def test_to__mapper_works_with_provided_init_extension(self): 114 | source_obj = ChildClass(10, "test_text", False) 115 | 116 | result = self.mapper.to(AnotherClass).map(source_obj) 117 | 118 | assert isinstance(result, AnotherClass) 119 | assert result.text == "test_text" 120 | 121 | def test_map__skip_none_values_from_source_object(self): 122 | self.mapper.add_spec(classifier_func, spec_func) 123 | 124 | obj = self.mapper.to(ClassWithoutInitAttrDef).map( 125 | AnotherClass(None, 11), skip_none_values=True 126 | ) 127 | 128 | assert "text" not in obj.data 129 | assert "num" in obj.data 130 | assert obj.data.get("num") == 11 131 | 132 | def test_map__pass_none_values_from_source_object(self): 133 | self.mapper.add_spec(classifier_func, spec_func) 134 | 135 | obj = self.mapper.to(ClassWithoutInitAttrDef).map(AnotherClass(None, 11)) 136 | 137 | assert "text" in obj.data 138 | assert "num" in obj.data 139 | assert obj.data.get("text") is None 140 | assert obj.data.get("num") == 11 141 | -------------------------------------------------------------------------------- /tests/test_subscriptable_obj_mapping.py: -------------------------------------------------------------------------------- 1 | from automapper import mapper 2 | 3 | 4 | class PublicUserInfo(object): 5 | def __init__(self, name: str, profession: str): 6 | self.name = name 7 | self.profession = profession 8 | 9 | 10 | def test_map__dict_to_object(): 11 | original = {"name": "John Carter", "age": 35, "profession": "hero"} 12 | 13 | public_info = mapper.to(PublicUserInfo).map(original) 14 | 15 | assert hasattr(public_info, "name") and public_info.name == "John Carter" 16 | assert hasattr(public_info, "profession") and public_info.profession == "hero" 17 | assert not hasattr(public_info, "age") 18 | -------------------------------------------------------------------------------- /tests/test_try_get_field_values.py: -------------------------------------------------------------------------------- 1 | from dataclasses import dataclass 2 | 3 | from automapper.mapper import _try_get_field_value 4 | 5 | 6 | @dataclass 7 | class DummyClass: 8 | map_field: str 9 | 10 | 11 | def test_try_get_field_value__if_in_custom_mapping(): 12 | is_found, mapped_value = _try_get_field_value("field1", None, {"field1": 123}) 13 | 14 | assert is_found 15 | assert mapped_value == 123 16 | 17 | 18 | def test_try_get_field_value__if_origin_has_same_field_attr(): 19 | is_found, mapped_value = _try_get_field_value( 20 | "map_field", DummyClass("Hello world"), None 21 | ) 22 | 23 | assert is_found 24 | assert mapped_value == "Hello world" 25 | 26 | 27 | def test_try_get_field_value__if_origin_contains_same_field_as_item(): 28 | is_found, mapped_value = _try_get_field_value( 29 | "map_field", {"map_field": "Hello world. Again"}, None 30 | ) 31 | 32 | assert is_found 33 | assert mapped_value == "Hello world. Again" 34 | 35 | 36 | def test_try_get_field_value__if_field_not_found(): 37 | is_found, mapped_value = _try_get_field_value("field1", None, None) 38 | 39 | assert not is_found 40 | assert mapped_value is None 41 | -------------------------------------------------------------------------------- /tests/test_utils.py: -------------------------------------------------------------------------------- 1 | from collections import OrderedDict 2 | from enum import Enum 3 | 4 | from automapper.utils import ( 5 | is_dictionary, 6 | is_enum, 7 | is_primitive, 8 | is_sequence, 9 | is_subscriptable, 10 | object_contains, 11 | ) 12 | 13 | 14 | def test_is_sequence__list_is_sequence(): 15 | assert is_sequence([1, 2]) 16 | 17 | 18 | def test_is_sequence__tuple_is_sequence(): 19 | assert is_sequence((1, 2, 3)) 20 | 21 | 22 | def test_is_sequence__dict_is_not_a_sequence(): 23 | assert not is_sequence({"a": 1}) 24 | 25 | 26 | def test_is_dictionary__dict_is_of_type_dictionary(): 27 | assert is_dictionary({"a1": 1}) 28 | 29 | 30 | def test_is_dictionary__ordered_dict_is_of_type_dictionary(): 31 | assert is_dictionary(OrderedDict({"a1": 1})) 32 | 33 | 34 | def test_is_subscriptable__dict_is_subscriptable(): 35 | assert is_subscriptable({"a": 1}) 36 | 37 | 38 | def test_is_subscriptable__custom_class_can_be_subscriptable(): 39 | class A: 40 | def __getitem__(self): 41 | yield 1 42 | 43 | assert is_subscriptable(A()) 44 | 45 | 46 | def test_object_contains__dict_contains_field(): 47 | assert object_contains({"a1": 1, "b2": 2}, "a1") 48 | 49 | 50 | def test_object_contains__dict_does_not_contain_field(): 51 | assert not object_contains({"a1": 1, "b2": 2}, "c3") 52 | 53 | 54 | def test_is_primitive__int_is_primitive(): 55 | assert is_primitive(1) 56 | 57 | 58 | def test_is_primitive__float_is_primitive(): 59 | assert is_primitive(1.2) 60 | 61 | 62 | def test_is_primitive__str_is_primitive(): 63 | assert is_primitive("hello") 64 | 65 | 66 | def test_is_primitive__bool_is_primitive(): 67 | assert is_primitive(False) 68 | 69 | 70 | def test_is_enum__object_is_enum(): 71 | class EnumValue(Enum): 72 | A = "A" 73 | B = "B" 74 | 75 | assert is_enum(EnumValue("A")) 76 | 77 | 78 | def test_is_enum__dict_is_not_enum(): 79 | assert not is_enum({"A": 1, "B": 2}) 80 | --------------------------------------------------------------------------------