Skip to content

Commit

Permalink
Merge pull request #34 from surenkov/feature/pydantic-v2
Browse files Browse the repository at this point in the history
Pydantic 2 support
  • Loading branch information
surenkov authored Jan 6, 2024
2 parents afe249d + d76d23c commit f467138
Show file tree
Hide file tree
Showing 68 changed files with 4,018 additions and 914 deletions.
21 changes: 21 additions & 0 deletions .editorconfig
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
root = true

[*]
charset = utf-8
end_of_line = lf
insert_final_newline = true
trim_trailing_whitespace = true
indent_style = space
indent_size = 4

[*.py]
max_line_length = 120

[Makefile]
indent_style = tab
indent_size = 4


[{*.json,*.yml,*.yaml}]
indent_size = 2
insert_final_newline = false
2 changes: 1 addition & 1 deletion .github/workflows/python-publish.yml
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,7 @@ jobs:
- name: Build package
run: python -m build
- name: Publish package
uses: pypa/gh-action-pypi-publish@27b31702a0e7fc50959f5ad993c78deac1bdfc29
uses: pypa/gh-action-pypi-publish@v1.8.10
with:
user: __token__
password: ${{ secrets.PYPI_API_TOKEN }}
3 changes: 3 additions & 0 deletions .github/workflows/python-test.yml
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,7 @@ jobs:
strategy:
matrix:
python-version: ["3.7", "3.8", "3.9", "3.10", "3.11", "3.12"]
pydantic-version: ["1.10.13", "2.4.2"]

services:
postgres:
Expand Down Expand Up @@ -75,5 +76,7 @@ jobs:
sudo apt update && sudo apt install -qy python3-dev default-libmysqlclient-dev build-essential
python -m pip install --upgrade pip
python -m pip install -e .[dev,test,ci]
- name: Install Pydantic ${{ matrix.pydantic-version }}
run: python -m pip install "pydantic==${{ matrix.pydantic-version }}"
- name: Test package
run: pytest
2 changes: 2 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -12,3 +12,5 @@ dist/
*.egg-info/
build
htmlcov

.python-version
14 changes: 3 additions & 11 deletions Makefile
Original file line number Diff line number Diff line change
@@ -1,36 +1,28 @@
.PHONY: install build test lint upload upload-test clean

.PHONY: install
install:
python3 -m pip install build twine
python3 -m pip install -e .[dev,test]


.PHONY: build
build:
python3 -m build

migrations:
DJANGO_SETTINGS_MODULE="tests.settings.django_test_settings" python3 -m django makemigrations --noinput

.PHONY: test
test: A=
test:
pytest $(A)

.PHONY: lint
lint: A=.
lint:
mypy $(A)


.PHONY: upload
upload:
python3 -m twine upload dist/*


.PHONY: upload-test
upload-test:
python3 -m twine upload --repository testpypi dist/*


.PHONY: clean
clean:
rm -rf dist/*
3 changes: 1 addition & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,8 +8,7 @@

Django JSONField with Pydantic models as a Schema

**[Pydantic 2 support](https://github.com/surenkov/django-pydantic-field/discussions/36) is in progress,
you can track the status [in this PR](https://github.com/surenkov/django-pydantic-field/pull/34)**
**[Pydantic 2 support](https://github.com/surenkov/django-pydantic-field/discussions/36) is in progress, you can track the status [in this PR](https://github.com/surenkov/django-pydantic-field/pull/34)**

## Usage

Expand Down
9 changes: 8 additions & 1 deletion django_pydantic_field/__init__.py
Original file line number Diff line number Diff line change
@@ -1 +1,8 @@
from .fields import *
from .fields import SchemaField as SchemaField

def __getattr__(name):
if name == "_migration_serializers":
module = __import__("django_pydantic_field._migration_serializers", fromlist=["*"])
return module

raise AttributeError(f"Module {__name__!r} has no attribute {name!r}")
153 changes: 9 additions & 144 deletions django_pydantic_field/_migration_serializers.py
Original file line number Diff line number Diff line change
@@ -1,144 +1,9 @@
"""
Django Migration serializer helpers
[Built-in generic annotations](https://peps.python.org/pep-0585/)
introduced in Python 3.9 are having a different semantics from `typing` collections.
Due to how Django treats field serialization/reconstruction while writing migrations,
it is not possible to distnguish between `types.GenericAlias` and any other regular types,
thus annotations are being erased by `MigrationWriter` serializers.
To mitigate this, I had to introduce custom container for schema deconstruction.
[Union types syntax](https://peps.python.org/pep-0604/)
`typing.Union` and its special forms, like `typing.Optional`, have its own inheritance chain.
Moreover, `types.UnionType`, introduced in 3.10, do not allow explicit type construction,
only with `X | Y` syntax. Both cases require a dedicated serializer for migration writes.
"""
import sys
import types
import typing as t

try:
from typing import get_args, get_origin
except ImportError:
from typing_extensions import get_args, get_origin

from django.db.migrations.serializer import BaseSerializer, serializer_factory
from django.db.migrations.writer import MigrationWriter


class GenericContainer:
__slots__ = "origin", "args"

def __init__(self, origin, args: tuple = ()):
self.origin = origin
self.args = args

@classmethod
def wrap(cls, typ_):
if isinstance(typ_, GenericTypes):
wrapped_args = tuple(map(cls.wrap, get_args(typ_)))
return cls(get_origin(typ_), wrapped_args)
return typ_

@classmethod
def unwrap(cls, type_):
if not isinstance(type_, GenericContainer):
return type_

if not type_.args:
return type_.origin

unwrapped_args = tuple(map(cls.unwrap, type_.args))
try:
# This is a fallback for Python < 3.8, please be careful with that
return type_.origin[unwrapped_args]
except TypeError:
return GenericAlias(type_.origin, unwrapped_args)

def __repr__(self):
return repr(self.unwrap(self))

__str__ = __repr__

def __eq__(self, other):
if isinstance(other, self.__class__):
return self.origin == other.origin and self.args == other.args
if isinstance(other, GenericTypes):
return self == self.wrap(other)
return NotImplemented


class GenericSerializer(BaseSerializer):
value: GenericContainer

def serialize(self):
value = self.value

tp_repr, imports = serializer_factory(type(value)).serialize()
orig_repr, orig_imports = serializer_factory(value.origin).serialize()
imports.update(orig_imports)

args = []
for arg in value.args:
arg_repr, arg_imports = serializer_factory(arg).serialize()
args.append(arg_repr)
imports.update(arg_imports)

if args:
args_repr = ", ".join(args)
generic_repr = "%s(%s, (%s,))" % (tp_repr, orig_repr, args_repr)
else:
generic_repr = "%s(%s)" % (tp_repr, orig_repr)

return generic_repr, imports


class TypingSerializer(BaseSerializer):
def serialize(self):
orig_module = self.value.__module__
orig_repr = repr(self.value)

if not orig_repr.startswith(orig_module):
orig_repr = f"{orig_module}.{orig_repr}"

return orig_repr, {f"import {orig_module}"}


if sys.version_info >= (3, 9):
GenericAlias = types.GenericAlias
GenericTypes: t.Tuple[t.Any, ...] = (
GenericAlias,
type(t.List[int]),
type(t.List),
)
else:
# types.GenericAlias is missing, meaning python version < 3.9,
# which has a different inheritance models for typed generics
GenericAlias = type(t.List[int])
GenericTypes = GenericAlias, type(t.List)


MigrationWriter.register_serializer(GenericContainer, GenericSerializer)
MigrationWriter.register_serializer(t.ForwardRef, TypingSerializer)
MigrationWriter.register_serializer(type(t.Union), TypingSerializer) # type: ignore


if sys.version_info >= (3, 10):
UnionType = types.UnionType

class UnionTypeSerializer(BaseSerializer):
value: UnionType

def serialize(self):
imports = set()
if isinstance(self.value, type(t.Union)): # type: ignore
imports.add("import typing")

for arg in get_args(self.value):
_, arg_imports = serializer_factory(arg).serialize()
imports.update(arg_imports)

return repr(self.value), imports

MigrationWriter.register_serializer(UnionType, UnionTypeSerializer)
import warnings
from .compat.django import *

DEPRECATION_MSG = (
"Module 'django_pydantic_field._migration_serializers' is deprecated "
"and will be removed in version 1.0.0. "
"Please replace it with 'django_pydantic_field.compat.django' in migrations."
)
warnings.warn(DEPRECATION_MSG, category=DeprecationWarning)
5 changes: 5 additions & 0 deletions django_pydantic_field/compat/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
from .pydantic import PYDANTIC_V1 as PYDANTIC_V1
from .pydantic import PYDANTIC_V2 as PYDANTIC_V2
from .pydantic import PYDANTIC_VERSION as PYDANTIC_VERSION
from .django import GenericContainer as GenericContainer
from .django import MigrationWriter as MigrationWriter
23 changes: 23 additions & 0 deletions django_pydantic_field/compat/deprecation.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
from __future__ import annotations

import typing as ty
import warnings

_MISSING = object()
_DEPRECATED_KWARGS = (
"allow_nan",
"indent",
"separators",
"skipkeys",
"sort_keys",
)
_DEPRECATED_KWARGS_MESSAGE = (
"The `%s=` argument is not supported by Pydantic v2 and will be removed in the future versions."
)


def truncate_deprecated_v1_export_kwargs(kwargs: dict[str, ty.Any]) -> None:
for kwarg in _DEPRECATED_KWARGS:
maybe_present_kwarg = kwargs.pop(kwarg, _MISSING)
if maybe_present_kwarg is not _MISSING:
warnings.warn(_DEPRECATED_KWARGS_MESSAGE % (kwarg,), DeprecationWarning, stacklevel=2)
Loading

0 comments on commit f467138

Please sign in to comment.