Skip to content

Commit

Permalink
update
Browse files Browse the repository at this point in the history
  • Loading branch information
jhassine committed Oct 27, 2024
1 parent 6cce2a0 commit bcf0074
Show file tree
Hide file tree
Showing 35 changed files with 1,821 additions and 690 deletions.
8 changes: 8 additions & 0 deletions .pyre_configuration
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
{
"site_package_search_strategy": "all",
"source_directories": [
"."
],
"strict": true,
"python_version": "3.12"
}
5 changes: 4 additions & 1 deletion .ruff.toml
Original file line number Diff line number Diff line change
Expand Up @@ -2,4 +2,7 @@ target-version = "py312"

[lint]
select = ["ALL"]
external = ["WPS", "C", "W"]
external = ["WPS", "C", "W"]

[lint.per-file-ignores]
"tests/test_*.py" = ["S101"]
1 change: 1 addition & 0 deletions .watchmanconfig
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
{}
13 changes: 13 additions & 0 deletions Dockerfile
Original file line number Diff line number Diff line change
Expand Up @@ -4,11 +4,24 @@ ENV PYTHONDONTWRITEBYTECODE=1
ENV PYTHONUNBUFFERED=1
ENV POETRY_VIRTUALENVS_CREATE=0

RUN apt-get update && apt-get install -y --no-install-recommends \
watchman \
&& rm -rf /var/lib/apt/lists/*

RUN pip install --no-cache-dir -U pip setuptools wheel
RUN pip install --no-cache-dir poetry

# Install pyenv for testing with different python versions
ENV PYENV_ROOT="$HOME/.pyenv"
ENV PATH="$PYENV_ROOT/bin:$PATH"
RUN curl https://pyenv.run | bash
RUN eval "$(pyenv init -)"
RUN pyenv install 3.13
RUN pyenv local 3.13

WORKDIR /app

RUN --mount=type=bind,source=./pyproject.toml,target=/app/pyproject.toml \
--mount=type=bind,source=./poetry.lock,target=/app/poetry.lock \
poetry install --no-root

6 changes: 6 additions & 0 deletions examples.py
Original file line number Diff line number Diff line change
Expand Up @@ -74,6 +74,11 @@ class Meta:
app_label = "tests"
default_related_name = "books"

@property
def author_names(self) -> str:
"""Return a comma separated list of author names."""
return ", ".join([author.name for author in self.authors.all()])


class Library(models.Model):
name = models.CharField(max_length=100)
Expand Down Expand Up @@ -137,6 +142,7 @@ class Meta(SuperSchema.Meta):
"authors": {"name": Infer},
"publisher": {"name": Infer},
"book_copies": {"library": Infer}, # note: here we use a reverse relation
"author_names": Infer,
}


Expand Down
4 changes: 2 additions & 2 deletions noxfile.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,10 +6,10 @@
import nox


@nox.session
@nox.session(python=["3.12", "3.13"], reuse_venv=True)
def tests(session: nox.Session) -> None:
"""Run the test suite."""
session.install("pytest")
session.run("poetry", "install", "--only=test", external=True)
session.run("pytest")


Expand Down
574 changes: 323 additions & 251 deletions poetry.lock

Large diffs are not rendered by default.

16 changes: 14 additions & 2 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,7 @@ classifiers = [
python = "^3.12"
django = "^5.1.1"
pydantic = "^2.9.2"
email-validator = "^2.2.0"

[tool.poetry.group.dev.dependencies]
pyright = "^1.1.383"
Expand All @@ -52,10 +53,10 @@ mypy = "^1.11.2"
wemake-python-styleguide = "^0.19.2"
pytype = "^2024.9.13"
isort = "^5.13.2"
django-stubs = {extras = ["compatible-mypy"], version = "^5.1.0"}
django-stubs = {git = "https://github.com/typeddjango/django-stubs.git", rev = "master", extras = ["compatible-mypy"]}
pytest = "^8.3.3"
pytest-cov = "^5.0.0"
ruff = "^0.6.9"
ruff = "^0.7.0"
nox = "^2024.4.15"
pytest-django = "^4.9.0"
hypothesis = "^6.112.4"
Expand All @@ -68,6 +69,17 @@ import-linter = "^2.1"
debugpy = "^1.8.6"
ipykernel = "^6.29.5"


[tool.poetry.group.test.dependencies]
pytest = "^8.3.3"
pytest-django = "^4.9.0"
hypothesis = "^6.115.5"
beartype = "^0.19.0"
typeguard = "^4.3.0"
pydantic = "^2.9.2"
email-validator = "^2.2.0"
django = "^5.1.2"

[build-system]
requires = ["poetry-core"]
build-backend = "poetry.core.masonry.api"
35 changes: 35 additions & 0 deletions pytest-with-beartype.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
#!/usr/bin/env python
"""Pytest wrapper which instruments it with Beartype's type annotation checks.
Why is this needed?
------------------
Because if you run pytest with the pytest-beartype plugin with command:
`pytest --beartype-packages='src,tests,automation,config'`
it will emit the following type warning:
```
BeartypePytestWarning: Previously imported packages "..." not checkable by beartype.
```
This is because the Beartype plugin is not able to instrument the packages
that are already imported somehow by pytest.
Refs:
- https://github.com/beartype/beartype/issues/322
- https://github.com/beartype/pytest-beartype/issues/3
So this wrapper script provides the workaround for this issue.
"""

import pytest
from beartype import BeartypeConf
from beartype.claw import beartype_package

type_check_instrumented_packages: list[str] = ["superschema", "tests"]

for package in type_check_instrumented_packages:
beartype_package(package_name=package, conf=BeartypeConf())


# Run all tests in the current directory
pytest.main()
30 changes: 30 additions & 0 deletions pytest-with-typeguard.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
#!/usr/bin/env python
"""Pytest wrapper which instruments it with Typeguard's type annotation checks.
Why is this needed?
------------------
Because if you run pytest with the pytest-typeguard plugin with command:
`pytest typeguard-packages=src,tests,automation,config`
it will emit the following type warning:
```
InstrumentationWarning:
typeguard cannot check these packages because they are already imported: config, ...
````
This is because the Typeguard plugin is not able to instrument the packages
that are already imported somehow by pytest.
So this wrapper script provides the workaround for this issue.
"""

import pytest
from typeguard import install_import_hook

type_check_instrumented_packages: list[str] = ["superschema", "tests"]

install_import_hook(packages=type_check_instrumented_packages)

# Run all tests in the current directory
pytest.main()
3 changes: 2 additions & 1 deletion superschema/__init__.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
"""Super schema packages."""

from superschema.schema import SuperSchema
from superschema.types import Infer, InferExcept, MetaFields, ModelFields

__all__ = ["Infer", "InferExcept", "ModelFields", "MetaFields"]
__all__ = ["Infer", "InferExcept", "ModelFields", "MetaFields", "SuperSchema"]
71 changes: 46 additions & 25 deletions superschema/base.py
Original file line number Diff line number Diff line change
@@ -1,14 +1,15 @@
"""Tooling to convert Django models and fields to Pydantic native models."""

from collections.abc import Callable
from enum import Enum
from enum import Enum, IntEnum
from types import UnionType
from typing import Any, Optional, TypeVar, override
from uuid import UUID

from django.contrib.contenttypes.fields import GenericForeignKey
from django.core.exceptions import FieldDoesNotExist
from django.db import models
from pydantic import BaseModel, create_model
from pydantic import BaseModel, ConfigDict, create_model
from pydantic._internal._model_construction import ModelMetaclass
from pydantic.fields import FieldInfo
from pydantic_core import PydanticUndefined
Expand Down Expand Up @@ -40,10 +41,19 @@
]


def has_property(cls: type, property_name: str) -> bool:
return hasattr(cls, property_name) and isinstance(
getattr(cls, property_name),
property,
)


def create_pydantic_model(
django_model: type[models.Model],
field_type_registry: FieldTypeRegistry,
included_fields: ModelFields,
model_name: str | None = None,
config: ConfigDict | None = None,
) -> type[BaseModel]:
"""Create a Pydantic model from a Django model.
Expand All @@ -67,22 +77,29 @@ def create_pydantic_model(
"""
pydantic_fields: dict[
str,
tuple[type[BaseModel | list[BaseModel]] | type | Enum, FieldInfo],
tuple[
type[BaseModel | list[BaseModel]] | type | Enum | IntEnum | UnionType,
FieldInfo,
],
] = {}

errors: list[str] = []

for field_name, field_def in included_fields.items():
try:
django_field = django_model._meta.get_field( # noqa: SLF001, WPS437
field_name=field_name,
)
except FieldDoesNotExist:
errors.append(
f"The fields definition includes field '{field_name}' "
f"which is not found in the Django model '{django_model.__name__}'.",
)
continue
# Check if the field is a property function:
if has_property(django_model, field_name):
django_field = getattr(django_model, field_name)
else:
try:
django_field = django_model._meta.get_field( # noqa: SLF001, WPS437
field_name=field_name,
)
except FieldDoesNotExist:
errors.append(
f"The fields definition includes field '{field_name}' "
f"which is not found in the Django model '{django_model.__name__}'.",
)
continue

if field_def is Infer:
type_handler = field_type_registry.get_handler(django_field)
Expand Down Expand Up @@ -127,7 +144,6 @@ def create_pydantic_model(
),
)
elif isinstance(field_def, dict):
print("field_def", field_def)
related_django_model_name = field_name
related_model_fields = field_def

Expand All @@ -148,6 +164,7 @@ def create_pydantic_model(
related_django_model,
field_type_registry,
related_model_fields,
model_name=f"{model_name}_{related_django_model_name}",
)

default = PydanticUndefined
Expand Down Expand Up @@ -192,15 +209,6 @@ def create_pydantic_model(
)
continue

print(
"field_name",
field_name,
"field_type",
field_type,
"django_field",
django_field,
)

pydantic_fields[related_django_model_name] = (
field_type,
FieldInfo(
Expand All @@ -218,11 +226,21 @@ def create_pydantic_model(
)

if errors:
raise AttributeError("Error: ".join(errors))
msg = (
f"Error creating Pydantic model from '{django_model.__name__}' Django model:"
"\n\t❌ "
"\n\t❌ ".join(errors)
)
raise AttributeError(msg)

# Finally, create the Pydantic model:
# https://docs.pydantic.dev/2.9/concepts/models/#dynamic-model-creation
return create_model(f"{django_model.__name__}Schema", **pydantic_fields)
model_name = model_name or f"{django_model.__name__}Schema"
return create_model(
model_name,
__config__={"from_attributes": True},
**pydantic_fields,
)


Bases = tuple[type[BaseModel]]
Expand Down Expand Up @@ -263,8 +281,11 @@ def __new__( # pylint: disable=W0222,C0204
msg = f"model field is required in Meta class for {name}"
raise ValueError(msg)

model_name = getattr(namespace["Meta"], "name", None)
return create_pydantic_model(
model_class,
field_type_registry,
included_fields=namespace["Meta"].fields,
model_name=model_name,
config=namespace.get("model_config", None),
)
4 changes: 4 additions & 0 deletions superschema/defaults.py
Original file line number Diff line number Diff line change
Expand Up @@ -32,3 +32,7 @@
field_type_registry.register(handlers.BigIntegerFieldHandler)
field_type_registry.register(handlers.SlugFieldHandler)
field_type_registry.register(handlers.AutoFieldHandler)
field_type_registry.register(handlers.BigAutoFieldHandler)
field_type_registry.register(handlers.PropertyHandler)
field_type_registry.register(handlers.OneToOneFieldHandler)
field_type_registry.register(handlers.ManyToManyFieldHandler)
8 changes: 7 additions & 1 deletion superschema/handlers/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,11 @@
SmallIntegerFieldHandler,
)
from superschema.handlers.property import PropertyHandler
from superschema.handlers.relational import ForeignKeyHandler
from superschema.handlers.relational import (
ForeignKeyHandler,
ManyToManyFieldHandler,
OneToOneFieldHandler,
)
from superschema.handlers.text import (
CharFieldHandler,
EmailFieldHandler,
Expand Down Expand Up @@ -67,6 +71,8 @@
"AutoFieldHandler",
"BigAutoFieldHandler",
"ForeignKeyHandler",
"OneToOneFieldHandler",
"ManyToManyFieldHandler",
"PropertyHandler",
"JSONFieldHandler",
"BinaryFieldHandler",
Expand Down
10 changes: 10 additions & 0 deletions superschema/handlers/auto.py
Original file line number Diff line number Diff line change
Expand Up @@ -71,6 +71,16 @@ def ge(self) -> int | None:
def le(self) -> int | None:
return 9223372036854775807

@property
@override
def examples(self) -> list[int]:
if self.ge is not None and self.le is not None:
return [self.ge, self.le]
if self.ge is not None:
return [self.ge]
if self.le is not None:
return [self.le]

@override
def get_pydantic_type_raw(self) -> type[int]:
return int
Loading

0 comments on commit bcf0074

Please sign in to comment.