Skip to content

Commit

Permalink
Bypass field validation on model instantiation if it does not contain…
Browse files Browse the repository at this point in the history
… a default= argument
  • Loading branch information
surenkov committed May 5, 2024
1 parent b6bb473 commit bbe60d8
Show file tree
Hide file tree
Showing 4 changed files with 55 additions and 11 deletions.
17 changes: 14 additions & 3 deletions Makefile
Original file line number Diff line number Diff line change
@@ -1,34 +1,45 @@
DJANGO_SETTINGS_MODULE ?= "tests.settings.django_test_settings"

.PHONY: install build test lint upload upload-test clean
export DJANGO_SETTINGS_MODULE=tests.settings.django_test_settings

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

.PHONY: build
build:
python3 -m build

.PHONY: migrations
migrations:
python3 -m django makemigrations --noinput

.PHONY: runserver
runserver:
python3 -m django migrate && \
python3 -m django runserver

.PHONY: check
check:
python3 -m django check

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

.PHONY: lint
lint: A=.
lint:
python3 -m 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/*
25 changes: 19 additions & 6 deletions django_pydantic_field/v1/fields.py
Original file line number Diff line number Diff line change
Expand Up @@ -37,8 +37,14 @@ def __set__(self, obj, value):
obj.__dict__[self.field.attname] = self.field.to_python(value)


class UninitializedSchemaAttribute(SchemaAttribute):
def __set__(self, obj, value):
if value is not None:
value = self.field.to_python(value)
obj.__dict__[self.field.attname] = value


class PydanticSchemaField(JSONField, t.Generic[base.ST]):
descriptor_class = SchemaAttribute
_is_prepared_schema: bool = False

def __init__(
Expand All @@ -61,8 +67,10 @@ def __copy__(self):
return copied

def get_default(self):
value = super().get_default()
return self.to_python(value)
default_value = super().get_default()
if self.has_default():
return self.to_python(default_value)
return default_value

def to_python(self, value) -> "base.SchemaT":
# Attempt to resolve forward referencing schema if it was not succesful
Expand Down Expand Up @@ -104,6 +112,12 @@ def deconstruct(self):

return name, path, args, kwargs

@staticmethod
def descriptor_class(field: "PydanticSchemaField") -> DeferredAttribute:
if field.has_default():
return SchemaAttribute(field)
return UninitializedSchemaAttribute(field)

def contribute_to_class(self, cls, name, private_only=False):
if self.schema is None:
self._resolve_schema_from_type_hints(cls, name)
Expand Down Expand Up @@ -162,8 +176,7 @@ def _prepare_model_schema(self, cls=None):

def _deconstruct_default(self, kwargs):
default = kwargs.get("default", NOT_PROVIDED)

if not (default is NOT_PROVIDED or callable(default)):
if default is not NOT_PROVIDED and not callable(default):
if self._is_prepared_schema:
default = self.get_prep_value(default)
kwargs.update(default=default)
Expand Down Expand Up @@ -202,6 +215,6 @@ def SchemaField(
) -> "base.ST": ...


def SchemaField(schema=None, config=None, default=None, *args, **kwargs) -> t.Any:
def SchemaField(schema=None, config=None, default=NOT_PROVIDED, *args, **kwargs) -> t.Any: # type: ignore
kwargs.update(schema=schema, config=config, default=default)
return PydanticSchemaField(*args, **kwargs)
17 changes: 15 additions & 2 deletions django_pydantic_field/v2/fields.py
Original file line number Diff line number Diff line change
Expand Up @@ -63,8 +63,14 @@ def __set__(self, obj, value):
obj.__dict__[self.field.attname] = self.field.to_python(value)


class UninitializedSchemaAttribute(SchemaAttribute):
def __set__(self, obj, value):
if value is not None:
value = self.field.to_python(value)
obj.__dict__[self.field.attname] = value


class PydanticSchemaField(JSONField, ty.Generic[types.ST]):
descriptor_class: type[DeferredAttribute] = SchemaAttribute
adapter: types.SchemaAdapter

def __init__(
Expand Down Expand Up @@ -102,6 +108,12 @@ def deconstruct(self) -> ty.Any:

return field_name, import_path, args, kwargs

@staticmethod
def descriptor_class(field: PydanticSchemaField) -> DeferredAttribute:
if field.has_default():
return SchemaAttribute(field)
return UninitializedSchemaAttribute(field)

def contribute_to_class(self, cls: types.DjangoModelType, name: str, private_only: bool = False) -> None:
self.adapter.bind(cls, name)
super().contribute_to_class(cls, name, private_only)
Expand All @@ -118,7 +130,8 @@ def check(self, **kwargs: ty.Any) -> list[checks.CheckMessage]:

try:
# Test that the default value conforms to the schema.
self.get_prep_value(self.get_default())
if self.has_default():
self.get_prep_value(self.get_default())
except pydantic.ValidationError as exc:
message = f"Default value cannot be adapted to the schema. Pydantic error: \n{str(exc)}"
performed_checks.append(checks.Error(message, obj=self, id="pydantic.E002"))
Expand Down
7 changes: 7 additions & 0 deletions tests/test_fields.py
Original file line number Diff line number Diff line change
Expand Up @@ -314,3 +314,10 @@ def test_copy_field():
assert copied.name == Building.meta.field.name
assert copied.attname == Building.meta.field.attname
assert copied.concrete == Building.meta.field.concrete


def test_model_init_no_default():
try:
SampleModel()
except Exception:
pytest.fail("Model with schema field without a default value should be able to initialize")

0 comments on commit bbe60d8

Please sign in to comment.