From f1001898695a21d76fab29c348b2ae55391299aa Mon Sep 17 00:00:00 2001 From: Thibaut Decombe Date: Sat, 28 Mar 2026 20:53:39 +0100 Subject: [PATCH 1/3] look good --- django-stubs/contrib/gis/db/models/fields.pyi | 4 ++-- django-stubs/contrib/postgres/fields/array.pyi | 4 ++-- django-stubs/core/validators.pyi | 6 ++++-- django-stubs/db/models/fields/__init__.pyi | 6 +++--- django-stubs/db/models/fields/generated.pyi | 2 +- django-stubs/db/models/fields/related.pyi | 8 ++++---- 6 files changed, 16 insertions(+), 14 deletions(-) diff --git a/django-stubs/contrib/gis/db/models/fields.pyi b/django-stubs/contrib/gis/db/models/fields.pyi index aee6110a4..7dcd40d50 100644 --- a/django-stubs/contrib/gis/db/models/fields.pyi +++ b/django-stubs/contrib/gis/db/models/fields.pyi @@ -66,7 +66,7 @@ class BaseSpatialField(Field[_ST, _GT]): db_column: str | None = ..., db_comment: str | None = ..., db_tablespace: str | None = ..., - validators: Iterable[_ValidatorCallable] = ..., + validators: Iterable[_ValidatorCallable[_GT]] = ..., error_messages: _ErrorMessagesMapping | None = ..., ) -> None: ... @override @@ -115,7 +115,7 @@ class GeometryField(BaseSpatialField[_ST, _GT]): db_column: str | None = ..., db_comment: str | None = ..., db_tablespace: str | None = ..., - validators: Iterable[_ValidatorCallable] = ..., + validators: Iterable[_ValidatorCallable[_GT]] = ..., error_messages: _ErrorMessagesMapping | None = ..., ) -> None: ... @override diff --git a/django-stubs/contrib/postgres/fields/array.pyi b/django-stubs/contrib/postgres/fields/array.pyi index 940731d95..be0144f60 100644 --- a/django-stubs/contrib/postgres/fields/array.pyi +++ b/django-stubs/contrib/postgres/fields/array.pyi @@ -28,7 +28,7 @@ class ArrayField(CheckPostgresInstalledMixin, CheckFieldDefaultMixin, Field[_ST, default_error_messages: ClassVar[_ErrorMessagesDict] base_field: Field size: int | None - default_validators: Sequence[_ValidatorCallable] + default_validators: Sequence[_ValidatorCallable[_GT]] from_db_value: Any def __init__( self, @@ -56,7 +56,7 @@ class ArrayField(CheckPostgresInstalledMixin, CheckFieldDefaultMixin, Field[_ST, db_column: str | None = ..., db_comment: str | None = ..., db_tablespace: str | None = ..., - validators: Iterable[_ValidatorCallable] = ..., + validators: Iterable[_ValidatorCallable[_GT]] = ..., error_messages: _ErrorMessagesMapping | None = ..., ) -> None: ... @override diff --git a/django-stubs/core/validators.pyi b/django-stubs/core/validators.pyi index b44d74978..80195c468 100644 --- a/django-stubs/core/validators.pyi +++ b/django-stubs/core/validators.pyi @@ -1,7 +1,7 @@ from collections.abc import Callable, Collection, Sequence, Sized from decimal import Decimal from re import Pattern, RegexFlag -from typing import Any, TypeAlias +from typing import Any, TypeAlias, TypeVar from django.core.files.base import File from django.utils.deconstruct import _Deconstructible @@ -12,7 +12,9 @@ EMPTY_VALUES: Any _Regex: TypeAlias = str | Pattern[str] -_ValidatorCallable: TypeAlias = Callable[[Any], None] # noqa: PYI047 +_VT = TypeVar("_VT", contravariant=True) + +_ValidatorCallable: TypeAlias = Callable[[_VT], None] # noqa: PYI047 class RegexValidator(_Deconstructible): regex: _Regex # Pattern[str] on instance, but may be str on class definition diff --git a/django-stubs/db/models/fields/__init__.pyi b/django-stubs/db/models/fields/__init__.pyi index 0d4c54edb..26fa1954d 100644 --- a/django-stubs/db/models/fields/__init__.pyi +++ b/django-stubs/db/models/fields/__init__.pyi @@ -144,7 +144,7 @@ class Field(RegisterLookupMixin, Generic[_ST, _GT]): empty_values: Sequence[Any] creation_counter: int auto_creation_counter: int - default_validators: Sequence[validators._ValidatorCallable] + default_validators: Sequence[validators._ValidatorCallable[_GT]] default_error_messages: ClassVar[_ErrorMessagesDict] hidden: bool system_check_removed_details: Any | None @@ -172,7 +172,7 @@ class Field(RegisterLookupMixin, Generic[_ST, _GT]): db_column: str | None = None, db_tablespace: str | None = None, auto_created: bool = False, - validators: Iterable[validators._ValidatorCallable] = (), + validators: Iterable[validators._ValidatorCallable[_GT]] = (), error_messages: _ErrorMessagesMapping | None = None, db_comment: str | None = None, db_default: type[NOT_PROVIDED] | Expression | _ST = ..., @@ -205,7 +205,7 @@ class Field(RegisterLookupMixin, Generic[_ST, _GT]): @cached_property def error_messages(self) -> _ErrorMessagesDict: ... @cached_property - def validators(self) -> list[validators._ValidatorCallable]: ... + def validators(self) -> list[validators._ValidatorCallable[_GT]]: ... def run_validators(self, value: Any) -> None: ... def validate(self, value: Any, model_instance: Model | None) -> None: ... def clean(self, value: Any, model_instance: Model | None) -> Any: ... diff --git a/django-stubs/db/models/fields/generated.pyi b/django-stubs/db/models/fields/generated.pyi index 0bf3eae73..e3dc59b19 100644 --- a/django-stubs/db/models/fields/generated.pyi +++ b/django-stubs/db/models/fields/generated.pyi @@ -42,7 +42,7 @@ class GeneratedField(models.Field[Any, Any]): db_column: str | None = ..., db_comment: str | None = ..., db_tablespace: str | None = ..., - validators: Iterable[_ValidatorCallable] = ..., + validators: Iterable[_ValidatorCallable[Any]] = ..., error_messages: _ErrorMessagesMapping | None = ..., **kwargs: Any, ) -> None: ... diff --git a/django-stubs/db/models/fields/related.pyi b/django-stubs/db/models/fields/related.pyi index b69f5c8ea..5f70dff06 100644 --- a/django-stubs/db/models/fields/related.pyi +++ b/django-stubs/db/models/fields/related.pyi @@ -73,7 +73,7 @@ class RelatedField(FieldCacheMixin, Field[_ST, _GT]): db_column: str | None = ..., db_tablespace: str | None = ..., auto_created: bool = ..., - validators: Iterable[validators._ValidatorCallable] = ..., + validators: Iterable[validators._ValidatorCallable[_GT]] = ..., error_messages: _ErrorMessagesMapping | None = ..., db_comment: str | None = ..., ) -> None: ... @@ -129,7 +129,7 @@ class ForeignObject(RelatedField[_ST, _GT]): help_text: _StrOrPromise = ..., db_column: str | None = ..., db_tablespace: str | None = ..., - validators: Iterable[validators._ValidatorCallable] = ..., + validators: Iterable[validators._ValidatorCallable[_GT]] = ..., error_messages: _ErrorMessagesMapping | None = ..., db_comment: str | None = ..., ) -> None: ... @@ -212,7 +212,7 @@ class ForeignKey(ForeignObject[_ST, _GT]): help_text: _StrOrPromise = ..., db_column: str | None = ..., db_tablespace: str | None = ..., - validators: Iterable[validators._ValidatorCallable] = ..., + validators: Iterable[validators._ValidatorCallable[_GT]] = ..., error_messages: _ErrorMessagesMapping | None = ..., db_comment: str | None = ..., ) -> None: ... @@ -264,7 +264,7 @@ class OneToOneField(ForeignKey[_ST, _GT]): help_text: _StrOrPromise = ..., db_column: str | None = ..., db_tablespace: str | None = ..., - validators: Iterable[validators._ValidatorCallable] = ..., + validators: Iterable[validators._ValidatorCallable[_GT]] = ..., error_messages: _ErrorMessagesMapping | None = ..., db_comment: str | None = ..., ) -> None: ... From 0c2494362894c0cf6e859acec9589a9b1c21d9ad Mon Sep 17 00:00:00 2001 From: Thibaut Decombe Date: Sat, 28 Mar 2026 20:57:30 +0100 Subject: [PATCH 2/3] Good --- .../db/models/fields/test_validators.py | 35 +++++++++++++++++++ 1 file changed, 35 insertions(+) create mode 100644 tests/assert_type/db/models/fields/test_validators.py diff --git a/tests/assert_type/db/models/fields/test_validators.py b/tests/assert_type/db/models/fields/test_validators.py new file mode 100644 index 000000000..1b88914d1 --- /dev/null +++ b/tests/assert_type/db/models/fields/test_validators.py @@ -0,0 +1,35 @@ +from collections.abc import Callable +from datetime import date +from decimal import Decimal + +from django.db import models +from django.db.models.expressions import Combinable +from typing_extensions import assert_type + + +def validate_title(value: str) -> None: ... + + +def validate_price(value: Decimal) -> None: ... + + +def validate_published_on(value: date) -> None: ... + + +title_field: models.CharField[str | int | Combinable, str] = models.CharField( + max_length=200, + validators=[validate_title], +) +price_field: models.DecimalField[str | float | Decimal | Combinable, Decimal] = models.DecimalField( + max_digits=8, + decimal_places=2, + validators=[validate_price], +) +published_on_field: models.DateField[str | date | Combinable, date] = models.DateField( + validators=[validate_published_on], +) + + +assert_type(title_field.validators, list[Callable[[str], None]]) # ty: ignore[type-assertion-failure] +assert_type(price_field.validators, list[Callable[[Decimal], None]]) # ty: ignore[type-assertion-failure] +assert_type(published_on_field.validators, list[Callable[[date], None]]) # ty: ignore[type-assertion-failure] From d1cac1a0eb7d40cfc7aa616fb62b7868747e4b5c Mon Sep 17 00:00:00 2001 From: Thibaut Decombe Date: Sat, 28 Mar 2026 21:02:40 +0100 Subject: [PATCH 3/3] Add missing _GT bindings --- django-stubs/db/models/fields/__init__.pyi | 20 ++++----- django-stubs/db/models/fields/composite.pyi | 2 +- django-stubs/db/models/fields/files.pyi | 2 +- tests/typecheck/fields/test_validators.yml | 46 +++++++++++++++++++++ 4 files changed, 58 insertions(+), 12 deletions(-) create mode 100644 tests/typecheck/fields/test_validators.yml diff --git a/django-stubs/db/models/fields/__init__.pyi b/django-stubs/db/models/fields/__init__.pyi index 26fa1954d..b82c86780 100644 --- a/django-stubs/db/models/fields/__init__.pyi +++ b/django-stubs/db/models/fields/__init__.pyi @@ -325,7 +325,7 @@ class DecimalField(Field[_ST, _GT]): db_column: str | None = ..., db_comment: str | None = ..., db_tablespace: str | None = ..., - validators: Iterable[validators._ValidatorCallable] = ..., + validators: Iterable[validators._ValidatorCallable[_GT]] = ..., error_messages: _ErrorMessagesMapping | None = ..., ) -> None: ... @cached_property @@ -361,7 +361,7 @@ class CharField(Field[_ST, _GT]): db_column: str | None = ..., db_comment: str | None = ..., db_tablespace: str | None = ..., - validators: Iterable[validators._ValidatorCallable] = ..., + validators: Iterable[validators._ValidatorCallable[_GT]] = ..., error_messages: _ErrorMessagesMapping | None = ..., *, db_collation: str | None = None, @@ -393,7 +393,7 @@ class SlugField(CharField[_ST, _GT]): db_column: str | None = ..., db_comment: str | None = ..., db_tablespace: str | None = ..., - validators: Iterable[validators._ValidatorCallable] = ..., + validators: Iterable[validators._ValidatorCallable[_GT]] = ..., error_messages: _ErrorMessagesMapping | None = ..., *, max_length: int | None = 50, @@ -434,7 +434,7 @@ class URLField(CharField[_ST, _GT]): db_comment: str | None = ..., db_tablespace: str | None = ..., auto_created: bool = ..., - validators: Iterable[validators._ValidatorCallable] = ..., + validators: Iterable[validators._ValidatorCallable[_GT]] = ..., error_messages: _ErrorMessagesMapping | None = ..., ) -> None: ... @override @@ -468,7 +468,7 @@ class TextField(Field[_ST, _GT]): db_column: str | None = ..., db_comment: str | None = ..., db_tablespace: str | None = ..., - validators: Iterable[validators._ValidatorCallable] = ..., + validators: Iterable[validators._ValidatorCallable[_GT]] = ..., error_messages: _ErrorMessagesMapping | None = ..., *, db_collation: str | None = None, @@ -520,7 +520,7 @@ class GenericIPAddressField(Field[_ST, _GT]): db_column: str | None = ..., db_comment: str | None = ..., db_tablespace: str | None = ..., - validators: Iterable[validators._ValidatorCallable] = ..., + validators: Iterable[validators._ValidatorCallable[_GT]] = ..., error_messages: _ErrorMessagesMapping | None = ..., ) -> None: ... @override @@ -558,7 +558,7 @@ class DateField(DateTimeCheckMixin, Field[_ST, _GT]): db_column: str | None = ..., db_comment: str | None = ..., db_tablespace: str | None = ..., - validators: Iterable[validators._ValidatorCallable] = ..., + validators: Iterable[validators._ValidatorCallable[_GT]] = ..., error_messages: _ErrorMessagesMapping | None = ..., ) -> None: ... @override @@ -593,7 +593,7 @@ class TimeField(DateTimeCheckMixin, Field[_ST, _GT]): db_column: str | None = ..., db_comment: str | None = ..., db_tablespace: str | None = ..., - validators: Iterable[validators._ValidatorCallable] = ..., + validators: Iterable[validators._ValidatorCallable[_GT]] = ..., error_messages: _ErrorMessagesMapping | None = ..., ) -> None: ... @override @@ -635,7 +635,7 @@ class UUIDField(Field[_ST, _GT]): db_comment: str | None = ..., db_tablespace: str | None = ..., auto_created: bool = ..., - validators: Iterable[validators._ValidatorCallable] = ..., + validators: Iterable[validators._ValidatorCallable[_GT]] = ..., error_messages: _ErrorMessagesMapping | None = ..., ) -> None: ... @override @@ -673,7 +673,7 @@ class FilePathField(Field[_ST, _GT]): db_column: str | None = ..., db_comment: str | None = ..., db_tablespace: str | None = ..., - validators: Iterable[validators._ValidatorCallable] = ..., + validators: Iterable[validators._ValidatorCallable[_GT]] = ..., error_messages: _ErrorMessagesMapping | None = ..., ) -> None: ... @override diff --git a/django-stubs/db/models/fields/composite.pyi b/django-stubs/db/models/fields/composite.pyi index f7cb3528a..0e998e183 100644 --- a/django-stubs/db/models/fields/composite.pyi +++ b/django-stubs/db/models/fields/composite.pyi @@ -47,7 +47,7 @@ class CompositePrimaryKey(Field[Any, Any]): db_column: None = None, db_tablespace: str | None = None, auto_created: bool = False, - validators: Iterable[validators._ValidatorCallable] = (), + validators: Iterable[validators._ValidatorCallable[Any]] = (), error_messages: Mapping[str, _StrOrPromise] | None = None, db_comment: str | None = None, db_default: type[NOT_PROVIDED] = ..., diff --git a/django-stubs/db/models/fields/files.pyi b/django-stubs/db/models/fields/files.pyi index b696784b9..82cd2135c 100644 --- a/django-stubs/db/models/fields/files.pyi +++ b/django-stubs/db/models/fields/files.pyi @@ -90,7 +90,7 @@ class FileField(Field[Any, Any]): db_column: str | None = ..., db_comment: str | None = ..., db_tablespace: str | None = ..., - validators: Iterable[validators._ValidatorCallable] = ..., + validators: Iterable[validators._ValidatorCallable[Any]] = ..., error_messages: _ErrorMessagesMapping | None = ..., ) -> None: ... # class access diff --git a/tests/typecheck/fields/test_validators.yml b/tests/typecheck/fields/test_validators.yml new file mode 100644 index 000000000..0098f8c57 --- /dev/null +++ b/tests/typecheck/fields/test_validators.yml @@ -0,0 +1,46 @@ +- case: model_field_validators_follow_get_type_and_support_subclassing + main: | + from datetime import date + from decimal import Decimal + from typing import NewType + from uuid import UUID + + from django.db import models + from django.db.models.expressions import Combinable + from typing_extensions import reveal_type + + SlugValue = NewType("SlugValue", str) + + class CustomSlugField(models.SlugField[SlugValue | str | int | Combinable, SlugValue]): + ... + + def validate_slug(value: str) -> None: ... + def validate_price(value: Decimal) -> None: ... + def validate_published_on(value: date) -> None: ... + def validate_identifier(value: UUID) -> None: ... + def validate_ip(value: str) -> None: ... + def validate_custom_slug(value: SlugValue) -> None: ... + def validate_int(value: int) -> None: ... + def validate_date(value: date) -> None: ... + def validate_uuid(value: UUID) -> None: ... + + class Article(models.Model): + slug = models.SlugField(validators=[validate_slug]) + price = models.DecimalField(max_digits=8, decimal_places=2, validators=[validate_price]) + published_on = models.DateField(validators=[validate_published_on]) + identifier = models.UUIDField(validators=[validate_identifier]) + ip_address = models.GenericIPAddressField(validators=[validate_ip]) + custom_slug = CustomSlugField(validators=[validate_custom_slug]) + + bad_slug = models.SlugField(validators=[validate_int]) # E: List item 0 has incompatible type "Callable[[int], None]"; expected "Callable[[str], None]" [list-item] + bad_published_on = models.DateField(validators=[validate_int]) # E: List item 0 has incompatible type "Callable[[int], None]"; expected "Callable[[date], None]" [list-item] + bad_identifier = models.UUIDField(validators=[validate_date]) # E: List item 0 has incompatible type "Callable[[date], None]"; expected "Callable[[UUID], None]" [list-item] + bad_ip_address = models.GenericIPAddressField(validators=[validate_uuid]) # E: List item 0 has incompatible type "Callable[[UUID], None]"; expected "Callable[[str], None]" [list-item] + bad_price = models.DecimalField(max_digits=8, decimal_places=2, validators=[validate_uuid]) # E: List item 0 has incompatible type "Callable[[UUID], None]"; expected "Callable[[Decimal], None]" [list-item] + + reveal_type(Article().slug) # N: Revealed type is "builtins.str" + reveal_type(Article().price) # N: Revealed type is "decimal.Decimal" + reveal_type(Article().published_on) # N: Revealed type is "datetime.date" + reveal_type(Article().identifier) # N: Revealed type is "uuid.UUID" + reveal_type(Article().ip_address) # N: Revealed type is "builtins.str" + reveal_type(Article().custom_slug) # N: Revealed type is "main.SlugValue"