├── .github ├── ISSUE_TEMPLATE.md ├── TEST_FAIL_TEMPLATE.md ├── dependabot.yml └── workflows │ └── ci.yml ├── .github_changelog_generator ├── .gitignore ├── .pre-commit-config.yaml ├── LICENSE ├── README.md ├── pyproject.toml ├── src └── pydantic_compat │ ├── __init__.py │ ├── _shared.py │ ├── _v1 │ ├── __init__.py │ ├── decorators.py │ └── mixin.py │ ├── _v2 │ ├── __init__.py │ ├── decorators.py │ └── mixin.py │ └── py.typed └── tests ├── test_base_model.py ├── test_decorators.py └── test_fields.py /.github/ISSUE_TEMPLATE.md: -------------------------------------------------------------------------------- 1 | * pydantic-compat version: 2 | * Python version: 3 | * Operating System: 4 | 5 | ### Description 6 | 7 | Describe what you were trying to get done. 8 | Tell us what happened, what went wrong, and what you expected to happen. 9 | 10 | ### What I Did 11 | 12 | ``` 13 | Paste the command(s) you ran and the output. 14 | If there was a crash, please include the traceback here. 15 | ``` 16 | -------------------------------------------------------------------------------- /.github/TEST_FAIL_TEMPLATE.md: -------------------------------------------------------------------------------- 1 | --- 2 | title: "{{ env.TITLE }}" 3 | labels: [bug] 4 | --- 5 | The {{ workflow }} workflow failed on {{ date | date("YYYY-MM-DD HH:mm") }} UTC 6 | 7 | The most recent failing test was on {{ env.PLATFORM }} py{{ env.PYTHON }} 8 | with commit: {{ sha }} 9 | 10 | Full run: https://github.com/{{ repo }}/actions/runs/{{ env.RUN_ID }} 11 | 12 | (This post will be updated if another test fails, as long as this issue remains open.) 13 | -------------------------------------------------------------------------------- /.github/dependabot.yml: -------------------------------------------------------------------------------- 1 | # https://docs.github.com/github/administering-a-repository/configuration-options-for-dependency-updates 2 | 3 | version: 2 4 | updates: 5 | - package-ecosystem: "github-actions" 6 | directory: "/" 7 | schedule: 8 | interval: "weekly" 9 | commit-message: 10 | prefix: "ci(dependabot):" 11 | -------------------------------------------------------------------------------- /.github/workflows/ci.yml: -------------------------------------------------------------------------------- 1 | name: CI 2 | 3 | concurrency: 4 | group: ${{ github.workflow }}-${{ github.ref }} 5 | cancel-in-progress: true 6 | 7 | on: 8 | push: 9 | branches: 10 | - main 11 | tags: 12 | - "v*" 13 | pull_request: 14 | workflow_dispatch: 15 | schedule: 16 | - cron: "0 0 * * 0" # every week (for --pre release tests) 17 | 18 | jobs: 19 | check-manifest: 20 | runs-on: ubuntu-latest 21 | steps: 22 | - uses: actions/checkout@v4 23 | - run: pipx run check-manifest 24 | 25 | test: 26 | uses: pyapp-kit/workflows/.github/workflows/test-pyrepo.yml@v2 27 | secrets: inherit 28 | with: 29 | os: ${{ matrix.os }} 30 | python-version: ${{ matrix.python-version }} 31 | pip-post-installs: ${{ matrix.pydantic }} 32 | pip-install-pre-release: ${{ github.event_name == 'schedule' }} 33 | report-failures: ${{ github.event_name == 'schedule' }} 34 | strategy: 35 | fail-fast: false 36 | matrix: 37 | python-version: ["3.7", "3.8", "3.9", "3.10", "3.11", "3.12-dev"] 38 | os: [ubuntu-latest] 39 | pydantic: ["'pydantic<1.9'", "'pydantic<2.0'", "'pydantic>=2.0'"] 40 | 41 | test-ome-types: 42 | uses: pyapp-kit/workflows/.github/workflows/test-dependents.yml@v2 43 | with: 44 | python-version: ${{ matrix.python-version }} 45 | dependency-repo: tlambert03/ome-types 46 | dependency-extras: "test,dev" 47 | post-install-cmd: pip install ${{ matrix.pydantic }} 48 | strategy: 49 | matrix: 50 | python-version: ["3.8", "3.12"] 51 | pydantic: ["'pydantic<2'", "'pydantic>=2'"] 52 | 53 | deploy: 54 | name: Deploy 55 | needs: test 56 | if: success() && startsWith(github.ref, 'refs/tags/') && github.event_name != 'schedule' 57 | runs-on: ubuntu-latest 58 | 59 | permissions: 60 | id-token: write 61 | contents: write 62 | 63 | steps: 64 | - uses: actions/checkout@v4 65 | with: 66 | fetch-depth: 0 67 | 68 | - name: Set up Python 69 | uses: actions/setup-python@v5 70 | with: 71 | python-version: "3.x" 72 | 73 | - name: install 74 | run: | 75 | python -m pip install build 76 | python -m build 77 | 78 | - name: 🚢 Publish to PyPI 79 | uses: pypa/gh-action-pypi-publish@release/v1 80 | 81 | - uses: softprops/action-gh-release@v2 82 | with: 83 | generate_release_notes: true 84 | files: "./dist/*" 85 | -------------------------------------------------------------------------------- /.github_changelog_generator: -------------------------------------------------------------------------------- 1 | user=pyapp-kit 2 | project=pydantic-compat 3 | issues=false 4 | exclude-labels=duplicate,question,invalid,wontfix,hide 5 | add-sections={"tests":{"prefix":"**Tests & CI:**","labels":["tests"]}, "documentation":{"prefix":"**Documentation:**", "labels":["documentation"]}} 6 | exclude-tags-regex=.*rc 7 | -------------------------------------------------------------------------------- /.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 | env/ 12 | build/ 13 | develop-eggs/ 14 | dist/ 15 | downloads/ 16 | eggs/ 17 | .eggs/ 18 | lib/ 19 | lib64/ 20 | parts/ 21 | sdist/ 22 | var/ 23 | wheels/ 24 | *.egg-info/ 25 | .installed.cfg 26 | *.egg 27 | 28 | # PyInstaller 29 | # Usually these files are written by a python script from a template 30 | # before PyInstaller builds the exe, so as to inject date/other infos into it. 31 | *.manifest 32 | *.spec 33 | 34 | # Installer logs 35 | pip-log.txt 36 | pip-delete-this-directory.txt 37 | 38 | # Unit test / coverage reports 39 | htmlcov/ 40 | .tox/ 41 | .coverage 42 | .coverage.* 43 | .cache 44 | nosetests.xml 45 | coverage.xml 46 | *.cover 47 | .hypothesis/ 48 | .pytest_cache/ 49 | 50 | # Translations 51 | *.mo 52 | *.pot 53 | 54 | # Django stuff: 55 | *.log 56 | local_settings.py 57 | 58 | # Flask stuff: 59 | instance/ 60 | .webassets-cache 61 | 62 | # Scrapy stuff: 63 | .scrapy 64 | 65 | # Sphinx documentation 66 | docs/_build/ 67 | 68 | # PyBuilder 69 | target/ 70 | 71 | # Jupyter Notebook 72 | .ipynb_checkpoints 73 | 74 | # pyenv 75 | .python-version 76 | 77 | # celery beat schedule file 78 | celerybeat-schedule 79 | 80 | # SageMath parsed files 81 | *.sage.py 82 | 83 | # dotenv 84 | .env 85 | 86 | # virtualenv 87 | .venv 88 | venv/ 89 | ENV/ 90 | 91 | # Spyder project settings 92 | .spyderproject 93 | .spyproject 94 | 95 | # Rope project settings 96 | .ropeproject 97 | 98 | # mkdocs documentation 99 | /site 100 | 101 | # mypy 102 | .mypy_cache/ 103 | 104 | # IDE settings 105 | .vscode/ 106 | -------------------------------------------------------------------------------- /.pre-commit-config.yaml: -------------------------------------------------------------------------------- 1 | ci: 2 | autoupdate_schedule: monthly 3 | autofix_commit_msg: "style(pre-commit.ci): auto fixes [...]" 4 | autoupdate_commit_msg: "ci(pre-commit.ci): autoupdate" 5 | 6 | repos: 7 | - repo: https://github.com/astral-sh/ruff-pre-commit 8 | rev: v0.8.1 9 | hooks: 10 | - id: ruff 11 | args: [--fix] 12 | 13 | - repo: https://github.com/psf/black 14 | rev: 24.10.0 15 | hooks: 16 | - id: black 17 | 18 | - repo: https://github.com/abravalheri/validate-pyproject 19 | rev: v0.23 20 | hooks: 21 | - id: validate-pyproject 22 | 23 | - repo: https://github.com/pre-commit/mirrors-mypy 24 | rev: v1.13.0 25 | hooks: 26 | - id: mypy 27 | files: "^src/" 28 | additional_dependencies: 29 | - pydantic 30 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Copyright (c) 2023, Talley Lambert 2 | 3 | Redistribution and use in source and binary forms, with or without 4 | modification, are permitted provided that the following conditions are met: 5 | 6 | 1. Redistributions of source code must retain the above copyright notice, this 7 | list of conditions and the following disclaimer. 8 | 9 | 2. Redistributions in binary form must reproduce the above copyright notice, 10 | this list of conditions and the following disclaimer in the documentation 11 | and/or other materials provided with the distribution. 12 | 13 | 3. Neither the name of the copyright holder nor the names of its 14 | contributors may be used to endorse or promote products derived from 15 | this software without specific prior written permission. 16 | 17 | THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" 18 | AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE 19 | IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE 20 | DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE 21 | FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL 22 | DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR 23 | SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER 24 | CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, 25 | OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE 26 | OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. 27 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # pydantic-compat 2 | 3 | [![GitHub](https://img.shields.io/github/license/pyapp-kit/pydantic-compat) 4 | ](https://github.com/pyapp-kit/pydantic-compat/raw/main/LICENSE) 5 | [![PyPI](https://img.shields.io/pypi/v/pydantic-compat.svg?color=green)](https://pypi.org/project/pydantic-compat) 6 | [![Python Version](https://img.shields.io/pypi/pyversions/pydantic-compat.svg?color=green)](https://python.org) 7 | [![CI](https://github.com/pyapp-kit/pydantic-compat/actions/workflows/ci.yml/badge.svg)](https://github.com/pyapp-kit/pydantic-compat/actions/workflows/ci.yml) 8 | [![codecov](https://codecov.io/gh/pyapp-kit/pydantic-compat/branch/main/graph/badge.svg)](https://codecov.io/gh/pyapp-kit/pydantic-compat) 9 | 10 | ## Motivation 11 | 12 | Pydantic 2 was a major release that completely changed the pydantic API. 13 | 14 | For applications, this is not a big deal, as they can pin to whatever version of 15 | pydantic they need. But for libraries that want to exist in a broader 16 | environment, pinning to a specific version of pydantic is not always an option 17 | (as it limits the ability to co-exist with other libraries). 18 | 19 | This package provides (unofficial) compatibility mixins and function adaptors for pydantic 20 | v1-v2 cross compatibility. It allows you to use either v1 or v2 API names, 21 | regardless of the pydantic version installed. (Prefer using v2 names when possible). 22 | 23 | Tests are run on Pydantic v1.8 and up 24 | 25 | The API conversion is not exhaustive, but suffices for many of the use cases 26 | I have come across. It is in use by the following libraries: 27 | 28 | - [ome-types](https://github.com/tlambert03/ome-types) 29 | - [app-model](https://github.com/pyapp-kit/app-model) 30 | - [useq-schema](https://github.com/pymmcore-plus/useq-schema) 31 | 32 | Feel free to open an issue or PR if you find it useful, but lacking features 33 | you need. 34 | 35 | ## What does it do? 36 | 37 | Not much! :joy: 38 | 39 | Mostly it serves to translate names from one API to another. It backports 40 | the v2 API to v1 (so you can v2 names in a pydantic1 runtime), 41 | and forwards the v1 API to v2 (so you can use v1 names in a v2 runtime 42 | without deprecation warnings). 43 | 44 | > While pydantic2 does offer deprecated access to the v1 API, if you explicitly 45 | > wish to support pydantic1 without your users seeing deprecation warnings, 46 | > then you need to do a lot of name adaptation depending on the runtime 47 | > pydantic version. This package does that for you. 48 | 49 | It does _not_ do any significantly complex translation of API logic. 50 | For custom types, you will still likely need to add class methods to 51 | support both versions of pydantic. 52 | 53 | It also does not prevent you from needing to know a what's changing 54 | under the hood in pydantic 2. You should be running tests on both 55 | versions of pydantic to ensure your library works as expected. This 56 | library just makes it much easier to support both versions in a single 57 | codebase without a lot of ugly conditionals and boilerplate. 58 | 59 | ## Usage 60 | 61 | ```py 62 | from pydantic import BaseModel 63 | from pydantic_compat import PydanticCompatMixin 64 | from pydantic_compat import field_validator # or 'validator' 65 | from pydantic_compat import model_validator # or 'root_validator' 66 | 67 | class MyModel(PydanticCompatMixin, BaseModel): 68 | x: int 69 | y: int = 2 70 | 71 | # prefer v2 dict, but v1 class Config is supported 72 | model_config = {'frozen': True} 73 | 74 | @field_validator('x', mode='after') 75 | def _check_x(cls, v): 76 | if v != 42: 77 | raise ValueError("That's not the answer!") 78 | return v 79 | 80 | @model_validator('x', mode='after') 81 | def _check_x(cls, v: MyModel): 82 | # ... 83 | return v 84 | ``` 85 | 86 | You can now use the following attributes and methods regardless of the 87 | pydantic version installed (without deprecation warnings): 88 | 89 | | v1 name | v2 name | 90 | | --------------------------- | --------------------------- | 91 | | `obj.dict()` | `obj.model_dump()` | 92 | | `obj.json()` | `obj.model_dump_json()` | 93 | | `obj.copy()` | `obj.model_copy()` | 94 | | `Model.construct` | `Model.model_construct` | 95 | | `Model.schema` | `Model.model_json_schema` | 96 | | `Model.validate` | `Model.model_validate` | 97 | | `Model.parse_obj` | `Model.model_validate` | 98 | | `Model.parse_raw` | `Model.model_validate_json` | 99 | | `Model.update_forward_refs` | `Model.model_rebuild` | 100 | | `Model.__fields__` | `Model.model_fields` | 101 | | `Model.__fields_set__` | `Model.model_fields_set` | 102 | 103 | 104 | ## `Field` notes 105 | 106 | - `pydantic_compat.Field` will remove outdated fields (`const`) and translate 107 | fields with new names: 108 | | v1 name | v2 name | 109 | | ---------------- | ------------------- | 110 | | `min_items` | `min_length` | 111 | | `max_items` | `max_length` | 112 | | `regex` | `pattern` | 113 | | `allow_mutation` | `not frozen` | 114 | | `` | `json_schema_extra['']` | 115 | - Don't use `var = Field(..., const='val')`, use `var: Literal['val'] = 'val'` 116 | it works in both v1 and v2 117 | - No attempt is made to convert between v1's `unique_items` and v2's `Set[]` 118 | semantics. See for 119 | discussion. 120 | 121 | ## API rules 122 | 123 | - both V1 and V2 names may be used (regardless of pydantic version), but 124 | usage of V2 names are strongly recommended. 125 | - But the API must match the pydantic version matching the name you are using. 126 | For example, if you are using `pydantic_compat.field_validator` then the 127 | signature must match the pydantic (v2) `field_validator` signature (regardless) 128 | of the pydantic version installed. Similarly, if you choose to use 129 | `pydantic_compat.validator` then the signature must match the pydantic 130 | (v1) `validator` signature. 131 | 132 | ## Notable differences 133 | 134 | - `BaseModel.__fields__` in v1 is a dict of `{'field_name' -> ModelField}` 135 | whereas in v2 `BaseModel.model_fields` is a dict of `{'field_name' -> 136 | FieldInfo}`. `FieldInfo` is a much simpler object that ModelField, so it is 137 | difficult to directly support complicated v1 usage of `__fields__`. 138 | `pydantic-compat` simply provides a name addaptor that lets you access many of 139 | the attributes you may have accessed on `ModelField` in v1 while operating in 140 | a v2 world, but `ModelField` methods will not be made available. You'll need 141 | to update your usage accordingly. 142 | 143 | - in V2, `pydantic.model_validator(..., mode='after')` passes a model _instance_ 144 | to the validator function, whereas `pydantic.v1.root_validator(..., 145 | pre=False)` passes a dict of `{'field_name' -> validated_value}` to the 146 | validator function. In pydantic-compat, both decorators follow the semantics 147 | of their corresponding pydantic versions, _but_ `root_validator` gains 148 | parameter `construct_object: bool=False` that matches the `model_validator` 149 | behavior (only when `mode=='after'`). If you want that behavior though, prefer 150 | using `model_validator` directly. 151 | 152 | ## TODO: 153 | 154 | - Serialization decorators 155 | -------------------------------------------------------------------------------- /pyproject.toml: -------------------------------------------------------------------------------- 1 | # https://peps.python.org/pep-0517/ 2 | [build-system] 3 | requires = ["hatchling", "hatch-vcs"] 4 | build-backend = "hatchling.build" 5 | 6 | # https://peps.python.org/pep-0621/ 7 | [project] 8 | name = "pydantic-compat" 9 | description = "Compatibility layer for pydantic v1/v2" 10 | readme = "README.md" 11 | requires-python = ">=3.7" 12 | license = { text = "BSD 3-Clause License" } 13 | authors = [{ email = "talley.lambert@gmail.com", name = "Talley Lambert" }] 14 | classifiers = [ 15 | "Development Status :: 3 - Alpha", 16 | "License :: OSI Approved :: BSD License", 17 | "Natural Language :: English", 18 | "Programming Language :: Python :: 3", 19 | "Programming Language :: Python :: 3.7", 20 | "Programming Language :: Python :: 3.8", 21 | "Programming Language :: Python :: 3.9", 22 | "Programming Language :: Python :: 3.10", 23 | "Programming Language :: Python :: 3.11", 24 | "Programming Language :: Python :: 3.12", 25 | "Framework :: Pydantic", 26 | "Typing :: Typed", 27 | ] 28 | dynamic = ["version"] 29 | dependencies = ["pydantic", "importlib_metadata; python_version<'3.8'"] 30 | 31 | [tool.hatch.envs.default] 32 | dependencies = [ 33 | "pytest", 34 | "pytest-cov", 35 | "pdbpp", 36 | "rich", 37 | "importlib_metadata; python_version<'3.8'", 38 | ] 39 | 40 | [tool.hatch.envs.test] 41 | 42 | [tool.hatch.envs.test.scripts] 43 | test = "pytest -v" 44 | test-cov = "pytest -v --cov --cov-report=term-missing" 45 | test-cov-xml = "pytest -v --color=yes --cov --cov-report=xml --cov-append" 46 | 47 | [[tool.hatch.envs.test.matrix]] 48 | # python = ["3.8", "3.11"] # good for local, too verbose for CI 49 | pydantic = ["v1.8", "v1.9", "v1", "v2"] 50 | 51 | [tool.hatch.envs.test.overrides] 52 | matrix.pydantic.extra-dependencies = [ 53 | { value = "pydantic<1.9", if = ["v1.8"] }, 54 | { value = "pydantic<1.10", if = ["v1.9"] }, 55 | { value = "pydantic<2.0", if = ["v1"] }, 56 | { value = "pydantic>=2.0", if = ["v2"] }, 57 | ] 58 | 59 | 60 | 61 | # https://peps.python.org/pep-0621/#dependencies-optional-dependencies 62 | [project.optional-dependencies] 63 | test = ["pytest>=6.0", "pytest-cov"] 64 | dev = [ 65 | "black", 66 | "ipython", 67 | "mypy", 68 | "pdbpp", 69 | "pre-commit", 70 | "pytest-cov", 71 | "pytest", 72 | "rich", 73 | "ruff", 74 | ] 75 | 76 | [project.urls] 77 | homepage = "https://github.com/pyapp-kit/pydantic-compat" 78 | repository = "https://github.com/pyapp-kit/pydantic-compat" 79 | 80 | # https://hatch.pypa.io/latest/config/metadata/ 81 | [tool.hatch.version] 82 | source = "vcs" 83 | 84 | # https://hatch.pypa.io/latest/config/build/#file-selection 85 | [tool.hatch.build.targets.sdist] 86 | include = ["/src", "/tests"] 87 | 88 | [tool.hatch.build.targets.wheel] 89 | only-include = ["src"] 90 | sources = ["src"] 91 | 92 | # https://github.com/charliermarsh/ruff 93 | [tool.ruff] 94 | line-length = 88 95 | target-version = "py37" 96 | src = ["src"] 97 | # https://beta.ruff.rs/docs/rules/ 98 | select = [ 99 | "E", # style errors 100 | "W", # style warnings 101 | "F", # flakes 102 | "I", # isort 103 | "UP", # pyupgrade 104 | "C4", # flake8-comprehensions 105 | "B", # flake8-bugbear 106 | "A001", # flake8-builtins 107 | "RUF", # ruff-specific rules 108 | "TCH", 109 | "TID", 110 | ] 111 | 112 | # https://docs.pytest.org/en/6.2.x/customize.html 113 | [tool.pytest.ini_options] 114 | minversion = "6.0" 115 | testpaths = ["tests"] 116 | filterwarnings = ["error"] 117 | 118 | # https://mypy.readthedocs.io/en/stable/config_file.html 119 | [tool.mypy] 120 | files = "src/**/" 121 | strict = true 122 | disallow_any_generics = false 123 | disallow_subclassing_any = false 124 | show_error_codes = true 125 | pretty = true 126 | 127 | # https://coverage.readthedocs.io/en/6.4/config.html 128 | [tool.coverage.report] 129 | exclude_lines = [ 130 | "pragma: no cover", 131 | "if TYPE_CHECKING:", 132 | "@overload", 133 | "except ImportError", 134 | "\\.\\.\\.", 135 | "raise NotImplementedError()", 136 | ] 137 | [tool.coverage.run] 138 | source = ["pydantic_compat"] 139 | 140 | # https://github.com/mgedmin/check-manifest#configuration 141 | [tool.check-manifest] 142 | ignore = [ 143 | ".github_changelog_generator", 144 | ".pre-commit-config.yaml", 145 | ".ruff_cache/**/*", 146 | "tests/**/*", 147 | ] 148 | -------------------------------------------------------------------------------- /src/pydantic_compat/__init__.py: -------------------------------------------------------------------------------- 1 | """CompatibilityMixin for pydantic v1/1/v2.""" 2 | 3 | try: 4 | from importlib.metadata import PackageNotFoundError, version 5 | except ImportError: 6 | from importlib_metadata import PackageNotFoundError, version # type: ignore 7 | 8 | from typing import TYPE_CHECKING 9 | 10 | try: 11 | __version__ = version("pydantic-compat") 12 | except PackageNotFoundError: # pragma: no cover 13 | __version__ = "uninstalled" 14 | 15 | __author__ = "Talley Lambert" 16 | __email__ = "talley.lambert@gmail.com" 17 | __all__ = [ 18 | "PYDANTIC2", 19 | "BaseModel", 20 | "Field", 21 | "PydanticCompatMixin", 22 | "__version__", 23 | "field_validator", 24 | "model_validator", 25 | "root_validator", 26 | "validator", 27 | ] 28 | 29 | from ._shared import PYDANTIC2 30 | 31 | if TYPE_CHECKING: 32 | from pydantic import ( 33 | Field, 34 | field_validator, 35 | model_validator, 36 | root_validator, 37 | validator, 38 | ) 39 | 40 | # using this to avoid breaking pydantic mypy plugin 41 | # not that we could use a protocol. but it will be hard to provide proper names 42 | # AND proper signatures for both versions of pydantic without a ton of potentially 43 | # outdated signatures 44 | PydanticCompatMixin = type 45 | else: 46 | from ._shared import Field 47 | 48 | if PYDANTIC2: 49 | from pydantic import field_validator, model_validator 50 | 51 | from ._v2 import PydanticCompatMixin, root_validator, validator 52 | 53 | else: 54 | from pydantic import validator 55 | 56 | from ._v1 import ( 57 | PydanticCompatMixin, 58 | field_validator, 59 | model_validator, 60 | root_validator, 61 | ) 62 | 63 | 64 | import pydantic 65 | 66 | 67 | class BaseModel(PydanticCompatMixin, pydantic.BaseModel): 68 | """BaseModel with pydantic_compat mixins.""" 69 | 70 | 71 | del pydantic 72 | -------------------------------------------------------------------------------- /src/pydantic_compat/_shared.py: -------------------------------------------------------------------------------- 1 | import contextlib 2 | import warnings 3 | from inspect import signature 4 | from typing import Any 5 | 6 | import pydantic 7 | import pydantic.version 8 | 9 | PYDANTIC2 = pydantic.version.VERSION.startswith("2") 10 | FIELD_KWARGS = { 11 | p.name 12 | for p in signature(pydantic.Field).parameters.values() 13 | if p.kind != p.VAR_KEYWORD 14 | } 15 | 16 | V2_REMOVED_CONFIG_KEYS = { 17 | "allow_mutation", 18 | "error_msg_templates", 19 | "fields", 20 | "getter_dict", 21 | "smart_union", 22 | "underscore_attrs_are_private", 23 | "json_loads", 24 | "json_dumps", 25 | "copy_on_model_validation", 26 | "post_init_call", 27 | } 28 | V2_RENAMED_CONFIG_KEYS = { 29 | "allow_population_by_field_name": "populate_by_name", 30 | "anystr_lower": "str_to_lower", 31 | "anystr_strip_whitespace": "str_strip_whitespace", 32 | "anystr_upper": "str_to_upper", 33 | "keep_untouched": "ignored_types", 34 | "max_anystr_length": "str_max_length", 35 | "min_anystr_length": "str_min_length", 36 | "orm_mode": "from_attributes", 37 | "schema_extra": "json_schema_extra", 38 | "validate_all": "validate_default", 39 | } 40 | 41 | V1_FIELDS_TO_V2_FIELDS = { 42 | "min_items": "min_length", 43 | "max_items": "max_length", 44 | "regex": "pattern", 45 | "allow_mutation": "-frozen", 46 | } 47 | 48 | V2_FIELDS_TO_V1_FIELDS = {} 49 | for k, v in V1_FIELDS_TO_V2_FIELDS.items(): 50 | if v.startswith("-"): 51 | v = v[1:] 52 | k = f"-{k}" 53 | V2_FIELDS_TO_V1_FIELDS[v] = k 54 | 55 | FIELD_NAME_MAP = V1_FIELDS_TO_V2_FIELDS if PYDANTIC2 else V2_FIELDS_TO_V1_FIELDS 56 | 57 | 58 | def check_mixin_order(cls: type, mixin_class: type, base_model: type) -> None: 59 | """Warn if mixin_class appears after base_model in cls.__bases__.""" 60 | bases = cls.__bases__ 61 | with contextlib.suppress(ValueError): 62 | mixin_index = bases.index(mixin_class) 63 | base_model_index = bases.index(base_model) 64 | if mixin_index > base_model_index: 65 | warnings.warn( 66 | f"{mixin_class.__name__} should appear before pydantic.BaseModel", 67 | stacklevel=3, 68 | ) 69 | 70 | 71 | def move_field_kwargs(kwargs: dict) -> dict: 72 | """Move Field(...) kwargs from v1 to v2 and vice versa.""" 73 | for old_name, new_name in FIELD_NAME_MAP.items(): 74 | negate = False 75 | if new_name.startswith("-"): 76 | new_name = new_name[1:] 77 | negate = True 78 | if old_name in kwargs: 79 | if new_name in kwargs: 80 | raise ValueError(f"Cannot specify both {old_name} and {new_name}") 81 | val = not kwargs.pop(old_name) if negate else kwargs.pop(old_name) 82 | kwargs[new_name] = val 83 | return kwargs 84 | 85 | 86 | def clean_field_kwargs(kwargs: dict) -> dict: 87 | """Remove outdated Field(...) kwargs.""" 88 | const = kwargs.pop("const", None) 89 | if const is not None: 90 | raise TypeError( 91 | f"`const` is removed in v2, use `Literal[{const!r}]` instead, " 92 | "it works in v1 and v2." 93 | ) 94 | return kwargs 95 | 96 | 97 | if PYDANTIC2: 98 | 99 | def move_extras(kwargs: dict) -> dict: 100 | """Move extra field arguments to json_schema_extra.""" 101 | extras = {k: kwargs.pop(k) for k in list(kwargs) if k not in FIELD_KWARGS} 102 | kwargs.setdefault("json_schema_extra", {}).update(extras) 103 | return kwargs 104 | 105 | else: 106 | 107 | def move_extras(kwargs: dict) -> dict: 108 | """Move unknown json_schema_extra fields to extras.""" 109 | kwargs.update(kwargs.pop("json_schema_extra", {})) 110 | return kwargs 111 | 112 | 113 | def Field(*args: Any, **kwargs: Any) -> Any: 114 | """Create a field for objects that can be configured.""" 115 | kwargs = clean_field_kwargs(kwargs) # remove outdated kwargs 116 | kwargs = move_field_kwargs(kwargs) # move kwargs from v1 to v2 and vice versa 117 | kwargs = move_extras(kwargs) # move extras to/from json_schema_extra 118 | return pydantic.Field(*args, **kwargs) 119 | -------------------------------------------------------------------------------- /src/pydantic_compat/_v1/__init__.py: -------------------------------------------------------------------------------- 1 | import pydantic.version 2 | 3 | if not pydantic.version.VERSION.startswith("1"): # pragma: no cover 4 | raise ImportError("pydantic_compat._v1 only supports pydantic v1.x") 5 | 6 | 7 | from .decorators import field_validator as field_validator 8 | from .decorators import model_validator as model_validator 9 | from .decorators import root_validator as root_validator 10 | from .mixin import PydanticCompatMixin as PydanticCompatMixin 11 | -------------------------------------------------------------------------------- /src/pydantic_compat/_v1/decorators.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | from functools import wraps 4 | from typing import TYPE_CHECKING, Any, Callable 5 | 6 | import pydantic 7 | 8 | if TYPE_CHECKING: 9 | from typing import Literal 10 | 11 | 12 | # V2 signature 13 | def field_validator( 14 | _field: str, 15 | *fields: str, 16 | mode: Literal["before", "after", "wrap", "plain"] = "after", 17 | check_fields: bool | None = None, 18 | ) -> Callable: 19 | """Adaptor from v2.field_validator -> v1.validator.""" 20 | # V1 signature 21 | # def validator( 22 | # *fields: str, 23 | # pre: bool = False, 24 | # each_item: bool = False, 25 | # always: bool = False, 26 | # check_fields: bool = True, 27 | # whole: Optional[bool] = None, 28 | # allow_reuse: bool = False, 29 | # ) -> Callable[[AnyCallable], 'AnyClassMethod']: 30 | # ... 31 | return pydantic.validator( 32 | _field, 33 | *fields, 34 | pre=(mode in ("before")), 35 | always=True, # should it be? 36 | check_fields=bool(check_fields), 37 | allow_reuse=True, 38 | ) 39 | 40 | 41 | # V2 signature 42 | def model_validator(*, mode: Literal["wrap", "before", "after"]) -> Any: 43 | """Adaptor from v2.model_validator -> v1.root_validator.""" 44 | 45 | # V1 signature 46 | # def root_validator( 47 | # _func: Optional[AnyCallable] = None, 48 | # *, 49 | # pre: bool = False, 50 | # allow_reuse: bool = False, 51 | # skip_on_failure: bool = False, 52 | # ) -> Union["AnyClassMethod", Callable[[AnyCallable], "AnyClassMethod"]]: 53 | # ... 54 | return root_validator( 55 | pre=mode == "before", allow_reuse=True, construct_object=mode == "after" 56 | ) 57 | 58 | 59 | def root_validator( 60 | _func: Callable | None = None, 61 | *, 62 | pre: bool = False, 63 | allow_reuse: bool = False, 64 | skip_on_failure: bool = False, 65 | construct_object: bool = False, 66 | ) -> Any: 67 | def _inner(_func: Callable) -> Any: 68 | func = _func 69 | if construct_object and not pre: 70 | if isinstance(_func, classmethod): 71 | _func = _func.__func__ 72 | 73 | @wraps(_func) 74 | def func(cls: type[pydantic.BaseModel], *args: Any, **kwargs: Any) -> Any: 75 | arg0, *rest = args 76 | # cast dict to model to match the v2 model_validator signature 77 | # using construct because it should already be valid 78 | new_args = (cls.construct(**arg0), *rest) 79 | result: pydantic.BaseModel = _func(cls, *new_args, **kwargs) 80 | # cast back to dict of field -> value 81 | return {k: getattr(result, k) for k in result.__fields__} 82 | 83 | deco = pydantic.root_validator( # type: ignore [call-overload] 84 | pre=pre, allow_reuse=allow_reuse, skip_on_failure=skip_on_failure 85 | ) 86 | return deco(func) 87 | 88 | return _inner(_func) if _func else _inner 89 | -------------------------------------------------------------------------------- /src/pydantic_compat/_v1/mixin.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | import sys 4 | from typing import TYPE_CHECKING, Any, ClassVar, Iterator, Mapping 5 | 6 | from pydantic import main 7 | 8 | from pydantic_compat._shared import V2_RENAMED_CONFIG_KEYS, check_mixin_order 9 | 10 | if TYPE_CHECKING: 11 | from typing import Dict 12 | 13 | from pydantic.fields import ModelField # type: ignore 14 | from typing_extensions import Protocol 15 | 16 | # fmt:off 17 | class Model(Protocol): 18 | def dict(self, *args: Any, **kwargs: Any) -> Dict[str, Any]: ... # noqa: UP006 19 | def json(self, *args: Any, **kwargs: Any) -> str: ... 20 | def copy(self, *args: Any, **kwargs: Any) -> Model: ... 21 | @classmethod 22 | def schema(cls, *args: Any, **kwargs: Any) -> Dict[str, Any]: ... # noqa: UP006 23 | @classmethod 24 | def validate(cls, *args: Any, **kwargs: Any) -> Model: ... 25 | @classmethod 26 | def construct(cls, *args: Any, **kwargs: Any) -> Model: ... 27 | @classmethod 28 | def parse_raw(cls, *args: Any, **kwargs: Any) -> type[Model]: ... 29 | @classmethod 30 | def update_forward_refs(cls, *args: Any, **kwargs: Any) -> None: ... 31 | 32 | __fields__: ClassVar[Dict] # noqa: UP006 33 | __fields_set__: set[str] 34 | __config__: ClassVar[type] 35 | # fmt:on 36 | 37 | 38 | REVERSE_CONFIG_NAME_MAP = {v: k for k, v in V2_RENAMED_CONFIG_KEYS.items()} 39 | 40 | 41 | def _convert_config(config_dict: dict) -> type: 42 | deprecated_renamed_keys = REVERSE_CONFIG_NAME_MAP.keys() & config_dict.keys() 43 | for k in sorted(deprecated_renamed_keys): 44 | config_dict[REVERSE_CONFIG_NAME_MAP[k]] = config_dict.pop(k) 45 | 46 | return type("Config", (), config_dict) 47 | 48 | 49 | class _MixinMeta(main.ModelMetaclass): 50 | def __new__(cls, name, bases, namespace: dict, **kwargs): # type: ignore 51 | if "model_config" in namespace and isinstance(namespace["model_config"], dict): 52 | namespace["Config"] = _convert_config(namespace.pop("model_config")) 53 | 54 | return super().__new__(cls, name, bases, namespace, **kwargs) 55 | 56 | if sys.version_info < (3, 9): 57 | 58 | @property 59 | def model_fields(cls) -> dict[str, Any]: 60 | return FieldInfoMap(cls.__fields__) 61 | 62 | 63 | class PydanticCompatMixin(metaclass=_MixinMeta): 64 | @classmethod 65 | def __try_update_forward_refs__(cls, **localns: Any) -> None: 66 | sup = super() 67 | if hasattr(sup, "__try_update_forward_refs__"): 68 | sup.__try_update_forward_refs__(**localns) 69 | 70 | def __init_subclass__(cls, *args: Any, **kwargs: Any) -> None: 71 | check_mixin_order(cls, PydanticCompatMixin, main.BaseModel) 72 | 73 | def model_dump(self: Model, *args: Any, **kwargs: Any) -> Any: 74 | return self.dict(*args, **kwargs) 75 | 76 | def model_dump_json(self: Model, *args: Any, **kwargs: Any) -> Any: 77 | return self.json(*args, **kwargs) 78 | 79 | def model_copy(self: Model, *args: Any, **kwargs: Any) -> Any: 80 | return self.copy(*args, **kwargs) 81 | 82 | @classmethod 83 | def model_json_schema(cls: type[Model], *args: Any, **kwargs: Any) -> Any: 84 | return cls.schema(*args, **kwargs) 85 | 86 | @classmethod 87 | def model_validate(cls: type[Model], *args: Any, **kwargs: Any) -> Any: 88 | return cls.validate(*args, **kwargs) 89 | 90 | @classmethod 91 | def model_construct(cls: type[Model], *args: Any, **kwargs: Any) -> Any: 92 | return cls.construct(*args, **kwargs) 93 | 94 | @classmethod 95 | def model_validate_json(cls: type[Model], *args: Any, **kwargs: Any) -> Any: 96 | return cls.parse_raw(*args, **kwargs) 97 | 98 | @classmethod 99 | def model_rebuild(cls: type[Model], force: bool = True, **kwargs: Any) -> None: 100 | return cls.update_forward_refs(**kwargs) 101 | 102 | if sys.version_info < (3, 9): 103 | # differences in the behavior of patching class properties in python<3.9 104 | @property 105 | def model_fields(cls: type[Model]) -> Mapping[str, Any]: 106 | return FieldInfoMap(cls.__fields__) 107 | 108 | else: 109 | 110 | @classmethod # type: ignore [misc] 111 | @property 112 | def model_fields(cls: type[Model]) -> Mapping[str, Any]: 113 | return FieldInfoMap(cls.__fields__) 114 | 115 | @property 116 | def model_fields_set(self: Model) -> set[str]: 117 | return self.__fields_set__ 118 | 119 | @classmethod # type: ignore [misc] 120 | @property 121 | def model_config(cls: type[Model]) -> Mapping[str, Any]: 122 | return DictLike(cls.__config__) 123 | 124 | 125 | class FieldInfoLike: 126 | """Wrapper to convera pydantic v1 ModelField to v2 FieldInfo.""" 127 | 128 | def __init__(self, model_field: ModelField) -> None: 129 | self._model_field = model_field 130 | 131 | @property 132 | def annotation(self) -> Any: 133 | return self._model_field.outer_type_ 134 | 135 | @property 136 | def frozen(self) -> bool: 137 | return not self._model_field.field_info.allow_mutation 138 | 139 | @property 140 | def json_schema_extra(self) -> dict: 141 | return self._model_field.field_info.extra # type: ignore [no-any-return] 142 | 143 | def __getattr__(self, key: str) -> Any: 144 | return getattr(self._model_field, key) 145 | 146 | def __repr__(self) -> str: 147 | return repr(self._model_field) 148 | 149 | 150 | class FieldInfoMap(Mapping[str, FieldInfoLike]): 151 | """Adaptor between v1 __fields__ and v2 model_field.""" 152 | 153 | def __init__(self, fields: dict[str, ModelField]) -> None: 154 | self._fields = fields 155 | 156 | def get(self, key: str, default: Any = None) -> Any: 157 | return self[key] if key in self._fields else default 158 | 159 | def __getitem__(self, key: str) -> FieldInfoLike: 160 | return FieldInfoLike(self._fields[key]) 161 | 162 | def __setitem__(self, key: str, value: Any) -> None: 163 | self._fields[key] = value 164 | 165 | def __iter__(self) -> Iterator[str]: 166 | yield from self._fields 167 | 168 | def __len__(self) -> int: 169 | return len(self._fields) 170 | 171 | 172 | class DictLike(Mapping[str, Any]): 173 | """Provide dict-like interface to an object.""" 174 | 175 | def __init__(self, obj: Any) -> None: 176 | self._obj = obj 177 | 178 | def get(self, key: str, default: Any = None) -> Any: 179 | return getattr(self._obj, key, default) 180 | 181 | def __getitem__(self, key: str) -> Any: 182 | return getattr(self._obj, key) 183 | 184 | def __setitem__(self, key: str, value: Any) -> None: 185 | setattr(self._obj, key, value) 186 | 187 | def __iter__(self) -> Iterator[str]: 188 | yield from self._obj.__dict__ 189 | 190 | def __len__(self) -> int: 191 | return len(self._obj.__dict__) 192 | -------------------------------------------------------------------------------- /src/pydantic_compat/_v2/__init__.py: -------------------------------------------------------------------------------- 1 | import pydantic.version 2 | 3 | if int(pydantic.version.VERSION[0]) <= 1: # pragma: no cover 4 | raise ImportError("pydantic_compat._v2 only supports pydantic v2.x") 5 | 6 | from .decorators import root_validator as root_validator 7 | from .decorators import validator as validator 8 | from .mixin import PydanticCompatMixin as PydanticCompatMixin 9 | -------------------------------------------------------------------------------- /src/pydantic_compat/_v2/decorators.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | import warnings 4 | from typing import Any, Callable 5 | 6 | from pydantic.deprecated import class_validators 7 | 8 | 9 | # V1 signature 10 | # def validator( 11 | # *fields: str, 12 | # pre: bool = False, 13 | # each_item: bool = False, 14 | # always: bool = False, 15 | # check_fields: bool = True, 16 | # whole: Optional[bool] = None, 17 | # allow_reuse: bool = False, 18 | # ) -> Callable[[AnyCallable], 'AnyClassMethod']: 19 | # ... 20 | def validator( 21 | _field: str, *fields: str, **kwargs: Any 22 | ) -> Callable[[Callable], Callable]: 23 | """Adaptor from v1.validator -> v2.field_validator.""" 24 | with warnings.catch_warnings(): 25 | warnings.simplefilter("ignore", DeprecationWarning) 26 | return class_validators.validator(_field, *fields, **kwargs) 27 | 28 | 29 | # V1 signature 30 | # def root_validator( 31 | # _func: Optional[AnyCallable] = None, 32 | # *, 33 | # pre: bool = False, 34 | # allow_reuse: bool = False, 35 | # skip_on_failure: bool = False, 36 | # ) -> Union["AnyClassMethod", Callable[[AnyCallable], "AnyClassMethod"]]: 37 | # ... 38 | def root_validator( 39 | *_args: str, 40 | pre: bool = False, 41 | skip_on_failure: bool | None = None, 42 | allow_reuse: bool = False, 43 | construct_object: bool = False, # here to match our v1 patch behavior 44 | ) -> Any: 45 | """Adaptor from v1.root_validator -> v2.model_validator.""" 46 | # If you use `@root_validator` with pre=False (the default) 47 | # you MUST specify `skip_on_failure=True` 48 | # we let explicit `skip_on_failure=False` pass through to fail, 49 | # but we default to `skip_on_failure=True` to match v1 behavior 50 | if not pre and skip_on_failure is None: 51 | skip_on_failure = True 52 | 53 | if construct_object: 54 | raise ValueError( 55 | "construct_object=True is not supported by pydantic-compat when running on " 56 | "pydantic v2. Please use pydantic_compat.model_validator(mode='after') " 57 | "instead. (It works for both versions)." 58 | ) 59 | 60 | with warnings.catch_warnings(): 61 | warnings.simplefilter("ignore", DeprecationWarning) 62 | 63 | # def model_validator( *, mode: Literal['wrap', 'before', 'after']) -> Any: 64 | return class_validators.root_validator( # type: ignore [call-overload] 65 | *_args, 66 | pre=pre, 67 | skip_on_failure=bool(skip_on_failure), 68 | allow_reuse=allow_reuse, 69 | ) 70 | -------------------------------------------------------------------------------- /src/pydantic_compat/_v2/mixin.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | from typing import TYPE_CHECKING, Any, ClassVar, Dict, cast 4 | 5 | from pydantic import BaseModel 6 | from pydantic._internal import _model_construction 7 | 8 | from pydantic_compat._shared import V2_RENAMED_CONFIG_KEYS, check_mixin_order 9 | 10 | if TYPE_CHECKING: 11 | from pydantic import ConfigDict 12 | from typing_extensions import Protocol 13 | 14 | # fmt:off 15 | class Model(Protocol): 16 | def model_dump(self, *args: Any, **kwargs: Any) -> dict[str, Any]: ... 17 | def model_dump_json(self, *args: Any, **kwargs: Any) -> str: ... 18 | def model_copy(self, *args: Any, **kwargs: Any) -> Model: ... 19 | @classmethod 20 | def model_json_schema(cls, *args: Any, **kwargs: Any) -> dict[str, Any]: ... 21 | @classmethod 22 | def model_validate(cls, *args: Any, **kwargs: Any) -> Model: ... 23 | @classmethod 24 | def model_construct(cls, *args: Any, **kwargs: Any) -> Model: ... 25 | @classmethod 26 | def model_validate_json(cls, *args: Any, **kwargs: Any) -> type[Model]: ... 27 | @classmethod 28 | def model_rebuild(cls, *args: Any, **kwargs: Any) -> bool | None: ... 29 | 30 | model_fields: ClassVar[dict] 31 | model_fields_set: ClassVar[set[str]] 32 | model_config: ClassVar[ConfigDict] 33 | # fmt:on 34 | 35 | 36 | def _convert_config(config: type) -> ConfigDict: 37 | config_dict = {k: getattr(config, k) for k in dir(config) if not k.startswith("__")} 38 | 39 | deprecated_renamed_keys = V2_RENAMED_CONFIG_KEYS.keys() & config_dict.keys() 40 | for k in sorted(deprecated_renamed_keys): 41 | config_dict[V2_RENAMED_CONFIG_KEYS[k]] = config_dict.pop(k) 42 | 43 | # leave these here for now to warn about lost functionality 44 | # deprecated_removed_keys = V2_REMOVED_CONFIG_KEYS & config_dict.keys() 45 | # for k in sorted(deprecated_removed_keys): 46 | # config_dict.pop(k) 47 | 48 | return cast("ConfigDict", config_dict) 49 | 50 | 51 | class _MixinMeta(_model_construction.ModelMetaclass): 52 | def __new__(cls, name, bases, namespace, **kwargs): # type: ignore 53 | if "Config" in namespace and isinstance(namespace["Config"], type): 54 | namespace["model_config"] = _convert_config(namespace.pop("Config")) 55 | return super().__new__(cls, name, bases, namespace, **kwargs) 56 | 57 | 58 | class PydanticCompatMixin(metaclass=_MixinMeta): 59 | def __init_subclass__(cls, *args: Any, **kwargs: Any) -> None: 60 | check_mixin_order(cls, PydanticCompatMixin, BaseModel) 61 | # the deprecation warning is on the metaclass 62 | type(cls).__fields__ = property(lambda cls: cls.model_fields) # type: ignore 63 | 64 | def dict(self: Model, *args: Any, **kwargs: Any) -> Any: 65 | return self.model_dump(*args, **kwargs) 66 | 67 | def json(self: Model, *args: Any, **kwargs: Any) -> Any: 68 | return self.model_dump_json(*args, **kwargs) 69 | 70 | def copy(self: Model, *args: Any, **kwargs: Any) -> Any: 71 | return self.model_copy(*args, **kwargs) 72 | 73 | @classmethod 74 | def schema(cls: type[Model], *args: Any, **kwargs: Any) -> Any: 75 | return cls.model_json_schema(*args, **kwargs) 76 | 77 | @classmethod 78 | def validate(cls: type[Model], *args: Any, **kwargs: Any) -> Any: 79 | return cls.model_validate(*args, **kwargs) 80 | 81 | @classmethod 82 | def construct(cls: type[Model], *args: Any, **kwargs: Any) -> Any: 83 | return cls.model_construct(*args, **kwargs) 84 | 85 | @classmethod 86 | def parse_obj(cls: type[Model], *args: Any, **kwargs: Any) -> Any: 87 | return cls.model_validate(*args, **kwargs) 88 | 89 | @classmethod 90 | def parse_raw(cls: type[Model], *args: Any, **kwargs: Any) -> Any: 91 | return cls.model_validate_json(*args, **kwargs) 92 | 93 | # this is needed in addition to the metaclass patch in __init_subclass__ 94 | @property 95 | def __fields__(self: Model) -> Dict[str, Any]: # noqa: UP006 96 | return self.model_fields 97 | 98 | @property 99 | def __fields_set__(self: Model) -> set[str]: 100 | return self.model_fields_set 101 | 102 | @classmethod 103 | def update_forward_refs( 104 | cls: type[Model], 105 | force: bool = False, 106 | raise_errors: bool = True, 107 | **localns: Any, 108 | ) -> None: 109 | cls.model_rebuild( 110 | forc=force, raise_errors=raise_errors, _types_namespace=localns 111 | ) 112 | 113 | @classmethod 114 | def model_rebuild( 115 | cls: type[Model], force: bool = False, raise_errors: bool = True, **kwargs: Any 116 | ) -> bool | None: 117 | return super().model_rebuild( 118 | force=force, raise_errors=raise_errors, _types_namespace=kwargs 119 | ) 120 | -------------------------------------------------------------------------------- /src/pydantic_compat/py.typed: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/pyapp-kit/pydantic-compat/103f7576bd3c3c1d4d7365bd276d1e4424fb7027/src/pydantic_compat/py.typed -------------------------------------------------------------------------------- /tests/test_base_model.py: -------------------------------------------------------------------------------- 1 | from typing import ClassVar 2 | 3 | import pydantic 4 | import pytest 5 | 6 | from pydantic_compat import PYDANTIC2, PydanticCompatMixin 7 | 8 | 9 | class Model(PydanticCompatMixin, pydantic.BaseModel): 10 | x: int = 1 11 | 12 | 13 | def test_v1_api() -> None: 14 | m = Model() 15 | assert m.x == 1 16 | assert m.dict() == {"x": 1} 17 | assert m.json().replace(" ", "") == '{"x":1}' 18 | assert m.copy() == m 19 | 20 | assert Model.parse_raw('{"x": 2}') == Model(x=2) 21 | assert Model.parse_obj({"x": 2}) == Model(x=2) 22 | assert Model.construct(x=2) == Model(x=2) 23 | assert Model.validate({"x": 2}) == Model(x=2) 24 | assert Model.schema() == { 25 | "title": "Model", 26 | "type": "object", 27 | "properties": { 28 | "x": {"title": "X", "type": "integer", "default": 1}, 29 | }, 30 | } 31 | 32 | Model.update_forward_refs(name="name") 33 | 34 | 35 | def test_v2_api() -> None: 36 | m = Model() 37 | assert m.x == 1 38 | assert m.model_dump() == {"x": 1} 39 | assert m.model_dump_json().replace(" ", "") == '{"x":1}' 40 | assert m.model_copy() == m 41 | 42 | assert Model.model_validate_json('{"x": 2}') == Model(x=2) 43 | assert Model.model_validate({"x": 2}) == Model(x=2) 44 | assert Model.model_construct(x=2) == Model(x=2) 45 | assert Model.model_validate({"x": 2}) == Model(x=2) 46 | assert Model.model_json_schema() == { 47 | "title": "Model", 48 | "type": "object", 49 | "properties": { 50 | "x": {"title": "X", "type": "integer", "default": 1}, 51 | }, 52 | } 53 | Model.model_rebuild(force=True) 54 | 55 | 56 | def test_v1_attributes() -> None: 57 | m = Model() 58 | assert "x" in m.__fields__ 59 | assert "x" in Model.__fields__ 60 | assert "x" not in m.__fields_set__ 61 | m.x = 2 62 | assert "x" in m.__fields_set__ 63 | 64 | 65 | def test_v2_attributes() -> None: 66 | m = Model() 67 | assert "x" in m.model_fields 68 | assert "x" in Model.model_fields 69 | assert "x" not in m.model_fields_set 70 | m.x = 2 71 | assert "x" in m.model_fields_set 72 | 73 | if not PYDANTIC2: 74 | from pydantic_compat._v1.mixin import FieldInfoMap 75 | 76 | assert isinstance(Model.model_fields, FieldInfoMap) 77 | else: 78 | assert isinstance(Model.model_fields, dict) 79 | 80 | 81 | def test_mixin_order() -> None: 82 | with pytest.warns( 83 | match="PydanticCompatMixin should appear before pydantic.BaseModel" 84 | ): 85 | 86 | class Model1(pydantic.BaseModel, PydanticCompatMixin): 87 | x: int = 1 88 | 89 | class Model2(PydanticCompatMixin, pydantic.BaseModel): 90 | x: int = 1 91 | 92 | 93 | V2Config = {"populate_by_name": True, "extra": "forbid", "frozen": True} 94 | 95 | 96 | class V1Config: 97 | allow_population_by_field_name = True 98 | extra = "forbid" 99 | frozen = True 100 | json_encoders: ClassVar[dict] = {} 101 | 102 | 103 | @pytest.mark.parametrize("config", [V1Config, V2Config]) 104 | def test_config(config: object) -> None: 105 | class Model1(PydanticCompatMixin, pydantic.BaseModel): 106 | name: str = pydantic.Field(alias="full_name") 107 | 108 | # to make sure that populate_by_name is working 109 | with pytest.raises((ValueError, TypeError)): # (v1, v2) 110 | m = Model1(name="John") 111 | 112 | class Model(PydanticCompatMixin, pydantic.BaseModel): 113 | name: str = pydantic.Field(alias="full_name") 114 | if isinstance(config, dict): 115 | model_config = config 116 | else: 117 | Config = config 118 | 119 | m = Model(name="John") 120 | 121 | # test frozen 122 | with pytest.raises((ValueError, TypeError)): # (v1, v2) 123 | m.name = "Sue" 124 | 125 | # test extra 126 | with pytest.raises((ValueError, TypeError)): # (v1, v2) 127 | Model(extra=1) 128 | -------------------------------------------------------------------------------- /tests/test_decorators.py: -------------------------------------------------------------------------------- 1 | from unittest.mock import Mock 2 | 3 | import pydantic 4 | import pytest 5 | 6 | from pydantic_compat import ( 7 | PYDANTIC2, 8 | PydanticCompatMixin, 9 | field_validator, 10 | model_validator, 11 | root_validator, 12 | validator, 13 | ) 14 | 15 | 16 | def test_v1_validator(): 17 | mock_before = Mock() 18 | mock_after = Mock() 19 | 20 | class Model(PydanticCompatMixin, pydantic.BaseModel): 21 | x: int = 1 22 | 23 | @validator("x", pre=True) 24 | def _validate_x_before(cls, v): 25 | mock_before(v) 26 | return v 27 | 28 | @validator("x") 29 | def _validate_x_after(cls, v): 30 | mock_after(v) 31 | return v 32 | 33 | m = Model(x="2") 34 | mock_before.assert_called_once_with("2") 35 | mock_after.assert_called_once_with(2) 36 | assert m.x == 2 37 | 38 | 39 | def test_v2_field_validator(): 40 | mock_before = Mock() 41 | mock_after = Mock() 42 | 43 | class Model(PydanticCompatMixin, pydantic.BaseModel): 44 | x: int = 1 45 | 46 | @field_validator("x", mode="before") 47 | def _validate_x_before(cls, v): 48 | mock_before(v) 49 | return v 50 | 51 | @field_validator("x", mode="after") 52 | def _validate_x_after(cls, v): 53 | mock_after(v) 54 | return v 55 | 56 | m = Model(x="2") 57 | mock_before.assert_called_once_with("2") 58 | mock_after.assert_called_once_with(2) 59 | assert m.x == 2 60 | 61 | 62 | def test_v1_root_validator(): 63 | mock_before = Mock() 64 | mock_after = Mock() 65 | 66 | class Model(PydanticCompatMixin, pydantic.BaseModel): 67 | x: int = 1 68 | 69 | @root_validator(pre=True) 70 | def _validate_x_before(cls, v): 71 | mock_before(v) 72 | return v 73 | 74 | @root_validator(pre=False) 75 | def _validate_x_after(cls, v): 76 | mock_after(v) 77 | return v 78 | 79 | m = Model(x="2") 80 | mock_before.assert_called_once_with({"x": "2"}) 81 | mock_after.assert_called_once_with({"x": 2}) 82 | assert m.x == 2 83 | 84 | 85 | @pytest.mark.xfail(PYDANTIC2, reason="not supported in pydantic v2", strict=True) 86 | def test_v1_root_validator_with_construct(): 87 | """Test the construct_object parameter of root_validator. 88 | 89 | This converts the input dict to the model object before calling the validator. 90 | To match the v2 behavior. It's not supported when running on v2. For that, just 91 | use model_validator(mode='after'). 92 | """ 93 | mock_after2 = Mock() 94 | 95 | class Model(PydanticCompatMixin, pydantic.BaseModel): 96 | x: int = 1 97 | 98 | @root_validator(pre=False, construct_object=True) 99 | def _validate_x_after2(cls, values): 100 | assert isinstance(values, Model) 101 | mock_after2(values.x) 102 | return values 103 | 104 | m = Model(x="2") 105 | mock_after2.assert_called_once_with(2) 106 | assert m.x == 2 107 | 108 | 109 | def test_v2_model_validator(): 110 | mock_before = Mock() 111 | mock_after = Mock() 112 | mock_after_cm = Mock() 113 | 114 | class Model(PydanticCompatMixin, pydantic.BaseModel): 115 | x: int = 1 116 | 117 | @model_validator(mode="before") 118 | def _validate_x_before(cls, v): 119 | mock_before(v) 120 | return v 121 | 122 | @model_validator(mode="after") 123 | def _validate_x_after(cls, v): 124 | mock_after(v) 125 | return v 126 | 127 | # this also needs to work 128 | @model_validator(mode="after") 129 | @classmethod 130 | def _validate_x_after_cm(cls, v): 131 | mock_after_cm(v) 132 | return v 133 | 134 | m = Model(x="2") 135 | mock_before.assert_called_once_with({"x": "2"}) 136 | mock_after.assert_called_once_with(m) 137 | mock_after_cm.assert_called_once_with(m) 138 | assert m.x == 2 139 | -------------------------------------------------------------------------------- /tests/test_fields.py: -------------------------------------------------------------------------------- 1 | from typing import ClassVar, List, Tuple 2 | 3 | import pytest 4 | from typing_extensions import Literal 5 | 6 | from pydantic_compat import BaseModel, Field 7 | 8 | 9 | def test_field_const() -> None: 10 | with pytest.raises(TypeError, match="use `Literal\\['bar'\\]` instead"): 11 | Field(..., const="bar") # type: ignore 12 | 13 | class Foo(BaseModel): 14 | bar: Literal["bar"] = "bar" 15 | 16 | with pytest.raises(ValueError, match="validation error"): 17 | Foo(bar="baz") # type: ignore 18 | 19 | 20 | @pytest.mark.parametrize("post", ["items", "length"]) 21 | @pytest.mark.parametrize("pre", ["min", "max"]) 22 | def test_field_min_max_items(pre: str, post: str) -> None: 23 | class Foo(BaseModel): 24 | bar: List[int] = Field(..., **{f"{pre}_{post}": 2}) # type: ignore 25 | 26 | bad_val = [1, 2, 3] if pre == "max" else [1] 27 | with pytest.raises((TypeError, ValueError)): # (v1, v2) 28 | Foo(bar=bad_val) 29 | 30 | 31 | def test_field_allow_mutation() -> None: 32 | # used in v1 33 | class Foo(BaseModel): 34 | bar: int = Field(default=1, allow_mutation=False) 35 | 36 | class Config: 37 | validate_assignment = True 38 | 39 | foo = Foo() 40 | with pytest.raises((TypeError, ValueError)): # (v1, v2) 41 | foo.bar = 2 42 | 43 | 44 | def test_field_frozen() -> None: 45 | # used in v2 46 | class Foo(BaseModel): 47 | bar: int = Field(default=1, frozen=True) 48 | model_config: ClassVar[dict] = {"validate_assignment": True} # type: ignore 49 | 50 | foo = Foo() 51 | with pytest.raises((TypeError, ValueError)): # (v1, v2) 52 | foo.bar = 2 53 | 54 | 55 | @pytest.mark.parametrize("key", ["regex", "pattern"]) 56 | def test_regex_pattern(key: str) -> None: 57 | class Foo(BaseModel): 58 | bar: str = Field(..., **{key: "^bar$"}) # type: ignore 59 | 60 | Foo(bar="bar") 61 | with pytest.raises(ValueError): 62 | Foo(bar="baz") 63 | 64 | 65 | @pytest.mark.parametrize( 66 | "keys", 67 | [ 68 | ("min_items", "min_length"), 69 | ("max_items", "max_length"), 70 | ("allow_mutation", "frozen"), 71 | ("regex", "pattern"), 72 | ], 73 | ) 74 | def test_double_usage_raises(keys: Tuple[str, str]) -> None: 75 | with pytest.raises(ValueError, match="Cannot specify both"): 76 | Field(..., **dict.fromkeys(keys)) # type: ignore 77 | 78 | 79 | # not attempting unique_items yet... 80 | # see https://github.com/pydantic/pydantic-core/issues/296 81 | # @pytest.mark.skipif( 82 | # pydantic.version.VERSION.startswith("1.8"), 83 | # reason="pydantic 1.8 does not support unique_items", 84 | # ) 85 | # def test_unique_items() -> None: 86 | # class Foo(BaseModel): 87 | # bar: List[int] = Field(..., unique_items=True) 88 | # with pytest.raises(ValueError): 89 | # Foo(bar=[1, 2, 3, 1]) 90 | 91 | 92 | def test_field_extras() -> None: 93 | class Foo(BaseModel): 94 | bar: int = Field(..., metadata={"foo": "bar"}) # type: ignore 95 | 96 | assert Foo.model_fields["bar"].json_schema_extra["metadata"] == {"foo": "bar"} 97 | --------------------------------------------------------------------------------