Skip to content

Commit

Permalink
feat: support Field() (#2)
Browse files Browse the repository at this point in the history
* adding fields

* more fields

* more fields

* use typing extensions

* fix typing

* add note on unique items

* typing

* more typing

* add pattern

* tests

* move field def

* docstring

* move import

* read

* fix typing

* newline
  • Loading branch information
tlambert03 authored Aug 2, 2023
1 parent f4b79ac commit e3579f9
Show file tree
Hide file tree
Showing 12 changed files with 324 additions and 106 deletions.
4 changes: 3 additions & 1 deletion .pre-commit-config.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -24,4 +24,6 @@ repos:
rev: v1.0.1
hooks:
- id: mypy
files: "^src/"
files: "^src/"
additional_dependencies:
- pydantic
26 changes: 20 additions & 6 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,9 @@ environment, pinning to a specific version of pydantic is not always an option

This package provides (unofficial) compatibility mixins and function adaptors for pydantic
v1-v2 cross compatibility. It allows you to use either v1 or v2 API names,
regardless of the pydantic version installed. (Prefer using v2 names when possible)
regardless of the pydantic version installed. (Prefer using v2 names when possible).

Tests are run on Pydantic v1.8 and up

The API conversion is not exhaustive, but suffices for many of the use cases
I have come across. I will be using it in:
Expand All @@ -35,11 +37,15 @@ you need.

Not much! :joy:

Mostly serves to translate names from one API to another. While
pydantic2 does offer deprecated access to the v1 API, if you explicitly
wish to support pydantic1 without your users seeing deprecation warnings,
then you need to do a lot of name adaptation depending on the runtime
pydantic version. This package does that for you.
Mostly it serves to translate names from one API to another. It backports
the v2 API to v1 (so you can v2 names in a pydantic1 runtime),
and forwards the v1 API to v2 (so you can use v1 names in a v2 runtime
without deprecation warnings).

> While pydantic2 does offer deprecated access to the v1 API, if you explicitly
> wish to support pydantic1 without your users seeing deprecation warnings,
> then you need to do a lot of name adaptation depending on the runtime
> pydantic version. This package does that for you.
It does _not_ do any significantly complex translation of API logic.
For custom types, you will still likely need to add class methods to
Expand Down Expand Up @@ -95,6 +101,14 @@ pydantic version installed (without deprecation warnings):
| `Model.__fields__` | `Model.model_fields` |
| `Model.__fields_set__` | `Model.model_fields_set` |

## Field notes

- Don't use `var = Field(..., const='val')`, use `var: Literal['val'] = 'val'`
it works in both v1 and v2
- No attempt is made to convert between v1's `unique_items` and v2's `Set[]`
semantics. See <https://github.com/pydantic/pydantic-core/issues/296> for
discussion.

## API rules

- both V1 and V2 names may be used (regardless of pydantic version), but
Expand Down
7 changes: 4 additions & 3 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -43,12 +43,13 @@ test-cov = "pytest -v --cov --cov-report=term-missing"
test-cov-xml = "pytest -v --color=yes --cov --cov-report=xml --cov-append"

[[tool.hatch.envs.test.matrix]]
# python = ["3.7", "3.11"] # good for local, too verbose for CI
pydantic = ["v1.8", "v1", "v2"]
# python = ["3.8", "3.11"] # good for local, too verbose for CI
pydantic = ["v1.8", "v1.9", "v1", "v2"]

[tool.hatch.envs.test.overrides]
matrix.pydantic.extra-dependencies = [
{ value = "pydantic==1.8.0", if = ["v1.8"] },
{ value = "pydantic<1.9", if = ["v1.8"] },
{ value = "pydantic<1.10", if = ["v1.9"] },
{ value = "pydantic<2.0", if = ["v1"] },
{ value = "pydantic>=2.0", if = ["v2"] },
]
Expand Down
52 changes: 36 additions & 16 deletions src/pydantic_compat/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@
try:
from importlib.metadata import PackageNotFoundError, version
except ImportError:
from importlib_metadata import PackageNotFoundError, version
from importlib_metadata import PackageNotFoundError, version # type: ignore

from typing import TYPE_CHECKING

Expand All @@ -22,30 +22,50 @@
"PydanticCompatMixin",
"root_validator",
"validator",
"Field",
"BaseModel",
]

from ._shared import PYDANTIC2

if TYPE_CHECKING:
from pydantic import field_validator, model_validator, root_validator, validator
from pydantic import (
Field,
field_validator,
model_validator,
root_validator,
validator,
)

# using this to avoid breaking pydantic mypy plugin
# not that it will be hard to provide proper names AND proper signatures for
# both versions of pydantic without a ton of potentially outdated signatures
# not that we could use a protocol. but it will be hard to provide proper names
# AND proper signatures for both versions of pydantic without a ton of potentially
# outdated signatures
PydanticCompatMixin = type
else:
from ._shared import Field

elif PYDANTIC2:
from pydantic import field_validator, model_validator
if PYDANTIC2:
from pydantic import field_validator, model_validator

from ._v2_decorators import root_validator, validator
from ._v2_mixin import PydanticCompatMixin
from ._v2 import PydanticCompatMixin, root_validator, validator

else:
from pydantic import validator # type: ignore
else:
from pydantic import validator

from ._v1_decorators import ( # type: ignore
field_validator,
model_validator,
root_validator,
)
from ._v1_mixin import PydanticCompatMixin # type: ignore
from ._v1 import (
PydanticCompatMixin,
field_validator,
model_validator,
root_validator,
)


import pydantic


class BaseModel(PydanticCompatMixin, pydantic.BaseModel):
"""BaseModel with pydantic_compat mixins."""


del pydantic
57 changes: 54 additions & 3 deletions src/pydantic_compat/_shared.py
Original file line number Diff line number Diff line change
@@ -1,9 +1,11 @@
import contextlib
import warnings
from typing import Any

from pydantic import version
import pydantic
import pydantic.version

PYDANTIC2 = version.VERSION.startswith("2")
PYDANTIC2 = pydantic.version.VERSION.startswith("2")

V2_REMOVED_CONFIG_KEYS = {
"allow_mutation",
Expand All @@ -30,8 +32,24 @@
"validate_all": "validate_default",
}

V1_FIELDS_TO_V2_FIELDS = {
"min_items": "min_length",
"max_items": "max_length",
"regex": "pattern",
"allow_mutation": "-frozen",
}

V2_FIELDS_TO_V1_FIELDS = {}
for k, v in V1_FIELDS_TO_V2_FIELDS.items():
if v.startswith("-"):
v = v[1:]
k = f"-{k}"
V2_FIELDS_TO_V1_FIELDS[v] = k

FIELD_NAME_MAP = V1_FIELDS_TO_V2_FIELDS if PYDANTIC2 else V2_FIELDS_TO_V1_FIELDS

def _check_mixin_order(cls: type, mixin_class: type, base_model: type) -> None:

def check_mixin_order(cls: type, mixin_class: type, base_model: type) -> None:
"""Warn if mixin_class appears after base_model in cls.__bases__."""
bases = cls.__bases__
with contextlib.suppress(ValueError):
Expand All @@ -42,3 +60,36 @@ def _check_mixin_order(cls: type, mixin_class: type, base_model: type) -> None:
f"{mixin_class.__name__} should appear before pydantic.BaseModel",
stacklevel=3,
)


def move_field_kwargs(kwargs: dict) -> dict:
"""Move Field(...) kwargs from v1 to v2 and vice versa."""
for old_name, new_name in FIELD_NAME_MAP.items():
negate = False
if new_name.startswith("-"):
new_name = new_name[1:]
negate = True
if old_name in kwargs:
if new_name in kwargs:
raise ValueError(f"Cannot specify both {old_name} and {new_name}")
val = not kwargs.pop(old_name) if negate else kwargs.pop(old_name)
kwargs[new_name] = val
return kwargs


def clean_field_kwargs(kwargs: dict) -> dict:
"""Remove outdated Field(...) kwargs."""
const = kwargs.pop("const", None)
if const is not None:
raise TypeError(
f"`const` is removed in v2, use `Literal[{const!r}]` instead, "
"it works in v1 and v2."
)
return kwargs


def Field(*args: Any, **kwargs: Any) -> Any:
"""Create a field for objects that can be configured."""
kwargs = clean_field_kwargs(kwargs)
kwargs = move_field_kwargs(kwargs)
return pydantic.Field(*args, **kwargs)
10 changes: 10 additions & 0 deletions src/pydantic_compat/_v1/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
import pydantic.version

if not pydantic.version.VERSION.startswith("1"): # pragma: no cover
raise ImportError("pydantic_compat._v1 only supports pydantic v1.x")


from .decorators import field_validator as field_validator
from .decorators import model_validator as model_validator
from .decorators import root_validator as root_validator
from .mixin import PydanticCompatMixin as PydanticCompatMixin
Original file line number Diff line number Diff line change
@@ -1,12 +1,7 @@
from __future__ import annotations

from functools import wraps
from typing import TYPE_CHECKING, Callable

import pydantic.version

if not pydantic.version.VERSION.startswith("1"): # pragma: no cover
raise ImportError("pydantic_compat._v1 only supports pydantic v1.x")
from typing import TYPE_CHECKING, Any, Callable

import pydantic

Expand Down Expand Up @@ -44,7 +39,7 @@ def field_validator(


# V2 signature
def model_validator(*, mode: Literal["wrap", "before", "after"]) -> Callable:
def model_validator(*, mode: Literal["wrap", "before", "after"]) -> Any:
"""Adaptor from v2.model_validator -> v1.root_validator."""

# V1 signature
Expand All @@ -68,25 +63,26 @@ def root_validator(
allow_reuse: bool = False,
skip_on_failure: bool = False,
construct_object: bool = False,
) -> Callable:
def _inner(_func: Callable):
) -> Any:
def _inner(_func: Callable) -> Any:
func = _func
if construct_object and not pre:
if isinstance(_func, classmethod):
_func = _func.__func__

@wraps(_func)
def func(cls: type[pydantic.BaseModel], *args, **kwargs):
def func(cls: type[pydantic.BaseModel], *args: Any, **kwargs: Any) -> Any:
arg0, *rest = args
# cast dict to model to match the v2 model_validator signature
# using construct because it should already be valid
new_args = (cls.construct(**arg0), *rest)
result: pydantic.BaseModel= _func(cls, *new_args, **kwargs)
result: pydantic.BaseModel = _func(cls, *new_args, **kwargs)
# cast back to dict of field -> value
return {k: getattr(result, k) for k in result.__fields__}

return pydantic.root_validator(
func, pre=pre, allow_reuse=allow_reuse, skip_on_failure=skip_on_failure
deco = pydantic.root_validator( # type: ignore [call-overload]
pre=pre, allow_reuse=allow_reuse, skip_on_failure=skip_on_failure
)
return deco(func)

return _inner(_func) if _func else _inner
Loading

0 comments on commit e3579f9

Please sign in to comment.