Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
31 commits
Select commit Hold shift + click to select a range
737ed6f
Auto-register assert_type Django apps in test settings
UnknownPlatypus May 1, 2026
94699ba
Add PEP 696 default TypeVars and _NT null flag to Field stubs
UnknownPlatypus May 1, 2026
1dfb4d1
Update mypy plugin to resolve Field types via descriptor overloads
UnknownPlatypus May 1, 2026
e0fbc14
Update yml typecheck tests for new Field type signatures
UnknownPlatypus May 1, 2026
bd59897
Migrate field yml typecheck tests to assert_type-based tests
UnknownPlatypus May 1, 2026
abf1fe8
Tighten pyright/pyrefly suppressions in assert_type tests
UnknownPlatypus May 1, 2026
3ffb7c3
Document PEP 696 + _NT generics for custom Field subclasses
UnknownPlatypus May 1, 2026
b151be2
Sync with master
UnknownPlatypus May 1, 2026
9d7fc0a
Fix missing typevar
UnknownPlatypus May 2, 2026
7e0bcb5
Bare `Field` in annotation is inferred as `Field[Any, Any, Literal[Fa…
UnknownPlatypus May 2, 2026
3044ef0
Add regression test with a migration
UnknownPlatypus May 2, 2026
20aecc7
WIP need fixing
UnknownPlatypus May 3, 2026
b51f05d
merge changes on `reparametrize_generic_class` with master
UnknownPlatypus May 3, 2026
a5757b3
Add `@type_check_only` to typing descriptor
UnknownPlatypus May 3, 2026
b498992
Move `test_related` to a proper model file
UnknownPlatypus May 3, 2026
16658ad
Define _ST and _GT locally
UnknownPlatypus May 4, 2026
b0a7891
Loosen jsonfield `db_default` to avoid false positive with empty cont…
UnknownPlatypus May 4, 2026
7718e1f
Add test
UnknownPlatypus May 4, 2026
e6c4c5d
Add variance tests
UnknownPlatypus May 4, 2026
7269241
FIXME
UnknownPlatypus May 4, 2026
80dcc05
More smooth handling of old school annotations in mypy
UnknownPlatypus May 8, 2026
4a5cea9
Move custom field test and add test with 2 base class
UnknownPlatypus May 8, 2026
9bc1ccf
Add test for custom field overriding init
UnknownPlatypus May 8, 2026
1075df8
Fix FileField type always return non null
UnknownPlatypus May 8, 2026
0082516
Merge branch 'master' into pep696-field-typing-skip-null
UnknownPlatypus May 14, 2026
36b2622
Merge remote-tracking branch 'typeddjango/master' into pep696-field-t…
UnknownPlatypus May 15, 2026
6f91128
Merge remote-tracking branch 'typeddjango/master' into pep696-field-t…
UnknownPlatypus May 15, 2026
ac2f8fb
Some pyrefly false positives are gone
UnknownPlatypus May 15, 2026
beef751
Merge branch 'master' into pep696-field-typing-skip-null
ngnpope Jun 5, 2026
2c2db84
Remove unused ignore for pyright
ngnpope Jun 5, 2026
cc7d2e8
Add new ignores for ty
ngnpope Jun 5, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
55 changes: 48 additions & 7 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -428,23 +428,34 @@ Use this setting on your own risk, because it can hide valid errors.
> This require type generic support, see <a href="#i-cannot-use-queryset-or-manager-with-type-annotations">this section</a> to enable it.


Django `models.Field` (and subclasses) are generic types with two parameters:
Django `models.Field` (and subclasses) are generic types with three parameters:
- `_ST`: type that can be used when setting a value
- `_GT`: type that will be returned when getting a value
- `_NT`: `Literal[True]` or `Literal[False]`, tracking the field's `null=...` flag so
`None` can be added to `_GT`/ `_ST` automatically when the field is nullable

When you create a subclass, you have two options depending on how strict you want
the type to be for consumers of your custom field.

> [!IMPORTANT]
> Each `TypeVar` you forward to `models.Field` (or one of its subclasses) **must**
> declare a `default=` value (PEP 696). Without a default, mypy will not be able
> to instantiate your field without explicit type arguments and the plugin will
> not be able to infer the right types for your model attributes.

1. Generic subclass:

```python
from typing import TypeVar, reveal_type
from typing import Literal, reveal_type
from typing_extensions import TypeVar # for `default=` (PEP 696)
from django.db import models
from django.db.models.expressions import Combinable

_ST = TypeVar("_ST", contravariant=True)
_GT = TypeVar("_GT", covariant=True)
_ST = TypeVar("_ST", contravariant=True, default=float | int | str | Combinable)
_GT = TypeVar("_GT", covariant=True, default=int)
_NT = TypeVar("_NT", Literal[True], Literal[False], default=Literal[False])

class MyIntegerField(models.IntegerField[_ST, _GT]):
class MyIntegerField(models.IntegerField[_ST, _GT, _NT]):
...

class User(models.Model):
Expand All @@ -458,12 +469,14 @@ User().my_field = "12" # OK (because Django IntegerField allows str and will tr
2. Non-generic subclass (more strict):

```python
from typing import reveal_type
from typing import Literal, reveal_type, TypeVar
from django.db import models

_NT = TypeVar("_NT", Literal[True], Literal[False], default=Literal[False])

# This is a non-generic subclass being very explicit
# that it expects only int when setting values.
class MyStrictIntegerField(models.IntegerField[int, int]):
class MyStrictIntegerField(models.IntegerField[int, int, _NT]):
...

class User(models.Model):
Expand All @@ -476,6 +489,34 @@ User().my_field = "12" # E: Incompatible types in assignment (expression has typ

See mypy section on [generic classes subclasses](https://mypy.readthedocs.io/en/stable/generics.html#defining-subclasses-of-generic-classes).

#### Overriding `__init__`

If you override `__init__`, expose `null: _NT` in the signature so type
checkers can track the `null=` flag β€” a plain `*args, **kwargs` passthrough
loses it and `_NT` falls back to `Literal[False]`:

```python
from typing import Any, Literal
from typing_extensions import TypeVar, assert_type
from django.db import models

_ST = TypeVar("_ST", contravariant=True, default=float | int | str)
_GT = TypeVar("_GT", covariant=True, default=int)
_NT = TypeVar("_NT", Literal[True], Literal[False], default=Literal[False])

class MyIntegerField(models.IntegerField[_ST, _GT, _NT]):
def __init__(self, *args: Any, null: _NT = False, **kwargs: Any) -> None: # type: ignore[assignment]
kwargs["null"] = null
super().__init__(*args, **kwargs)

class User(models.Model):
custom_int = MyIntegerField(null=False)
custom_int_nullable = MyIntegerField(null=True)

assert_type(User().custom_int, int)
assert_type(User().custom_int_nullable, int | None)
```

## Related projects

- [`awesome-python-typing`](https://github.com/typeddjango/awesome-python-typing) - Awesome list of all typing-related things in Python.
Expand Down
11 changes: 7 additions & 4 deletions django-stubs/contrib/admin/filters.pyi
Original file line number Diff line number Diff line change
Expand Up @@ -51,11 +51,11 @@ class SimpleListFilter(FacetsMixin, ListFilter):

class FieldListFilter(FacetsMixin, ListFilter):
list_separator: ClassVar[str]
field: Field
field: Field[Any, Any, Any]
field_path: str
def __init__(
self,
field: Field,
field: Field[Any, Any, Any],
request: HttpRequest,
params: dict[str, list[str]],
model: type[Model],
Expand All @@ -64,12 +64,15 @@ class FieldListFilter(FacetsMixin, ListFilter):
) -> None: ...
@classmethod
def register(
cls, test: Callable[[Field], Any], list_filter_class: type[FieldListFilter], take_priority: bool = ...
cls,
test: Callable[[Field[Any, Any, Any]], Any],
list_filter_class: type[FieldListFilter],
take_priority: bool = ...,
) -> None: ...
@classmethod
def create(
cls,
field: Field,
field: Field[Any, Any, Any],
request: HttpRequest,
params: dict[str, list[str]],
model: type[Model],
Expand Down
8 changes: 5 additions & 3 deletions django-stubs/contrib/admin/options.pyi
Original file line number Diff line number Diff line change
Expand Up @@ -95,7 +95,7 @@ class BaseModelAdmin(Generic[_ModelT], metaclass=MediaDefiningClass):
filter_horizontal: ClassVar[_ListOrTuple[str]]
radio_fields: ClassVar[Mapping[str, _Direction]]
prepopulated_fields: ClassVar[dict[str, Sequence[str]]]
formfield_overrides: ClassVar[Mapping[type[Field], Mapping[str, Any]]]
formfield_overrides: ClassVar[Mapping[type[Field[Any, Any, Any]], Mapping[str, Any]]]
readonly_fields: ClassVar[_ListOrTuple[str]]
ordering: ClassVar[_ListOrTuple[_OrderByFieldName] | None]
sortable_by: ClassVar[_ListOrTuple[str] | None]
Expand All @@ -106,9 +106,11 @@ class BaseModelAdmin(Generic[_ModelT], metaclass=MediaDefiningClass):
admin_site: AdminSite
def __init__(self) -> None: ...
def check(self, **kwargs: Any) -> list[CheckMessage]: ...
def formfield_for_dbfield(self, db_field: Field, request: HttpRequest, **kwargs: Any) -> FormField | None: ...
def formfield_for_dbfield(
self, db_field: Field[Any, Any, Any], request: HttpRequest, **kwargs: Any
) -> FormField | None: ...
def formfield_for_choice_field(
self, db_field: Field, request: HttpRequest, **kwargs: Any
self, db_field: Field[Any, Any, Any], request: HttpRequest, **kwargs: Any
) -> TypedChoiceField | None: ...
def get_field_queryset(self, db: str | None, db_field: RelatedField, request: HttpRequest) -> QuerySet | None: ...
def formfield_for_foreignkey(
Expand Down
15 changes: 10 additions & 5 deletions django-stubs/contrib/admin/utils.pyi
Original file line number Diff line number Diff line change
Expand Up @@ -65,7 +65,10 @@ class NestedObjects(Collector):
) -> None: ...
@override
def related_objects(
self, related_model: type[Model], related_fields: Iterable[Field], objs: _IndexableCollection[Model]
self,
related_model: type[Model],
related_fields: Iterable[Field[Any, Any, Any]],
objs: _IndexableCollection[Model],
) -> QuerySet[Model]: ...
@overload
def nested(self, format_callback: None = None) -> list[Any]: ...
Expand All @@ -83,7 +86,7 @@ def model_format_dict(obj: Model | type[Model] | QuerySet | Options[Model]) -> _
def model_ngettext(obj: Options | QuerySet, n: int | None = ...) -> str: ...
def lookup_field(
name: Callable | str, obj: Model, model_admin: BaseModelAdmin | None = ...
) -> tuple[Field | None, str | None, Any]: ...
) -> tuple[Field[Any, Any, Any] | None, str | None, Any]: ...
@overload
def label_for_field(
name: Callable | str,
Expand All @@ -101,14 +104,16 @@ def label_for_field(
form: BaseForm | None = ...,
) -> str: ...
def help_text_for_field(name: str, model: type[Model]) -> str: ...
def display_for_field(value: Any, field: Field, empty_value_display: str, avoid_link: bool = False) -> str: ...
def display_for_field(
value: Any, field: Field[Any, Any, Any], empty_value_display: str, avoid_link: bool = False
) -> str: ...
def display_for_value(value: Any, empty_value_display: str, boolean: bool = ...) -> str: ...

class NotRelationField(Exception): ...

def get_model_from_relation(field: Field | reverse_related.ForeignObjectRel) -> type[Model]: ...
def get_model_from_relation(field: Field[Any, Any, Any] | reverse_related.ForeignObjectRel) -> type[Model]: ...
def reverse_field_path(model: type[Model], path: str) -> tuple[type[Model], str]: ...
def get_fields_from_path(model: type[Model], path: str) -> list[Field]: ...
def get_fields_from_path(model: type[Model], path: str) -> list[Field[Any, Any, Any]]: ...
def construct_change_message(
form: Form, formsets: Iterable[BaseFormSet], add: bool
) -> list[dict[str, dict[str, list[str]]]]: ...
2 changes: 1 addition & 1 deletion django-stubs/contrib/admindocs/views.pyi
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,7 @@ class ModelDetailView(BaseAdminDocsView): ...
class TemplateDetailView(BaseAdminDocsView): ...

def get_return_data_type(func_name: Any) -> str: ...
def get_readable_field_data_type(field: Field | str) -> str: ...
def get_readable_field_data_type(field: Field[Any, Any, Any] | str) -> str: ...
def extract_views_from_urlpatterns(
urlpatterns: Iterable[_AnyURL], base: str = ..., namespace: str | None = ...
) -> list[tuple[Callable, Pattern[str], str | None, str | None]]: ...
Expand Down
3 changes: 1 addition & 2 deletions django-stubs/contrib/auth/base_user.pyi
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,6 @@ from typing import Any, ClassVar, Literal, overload

from django.db import models
from django.db.models.base import Model
from django.db.models.expressions import Combinable
from django.db.models.fields import BooleanField
from typing_extensions import TypeVar

Expand All @@ -23,7 +22,7 @@ class AbstractBaseUser(models.Model):

password = models.CharField(max_length=128)
last_login = models.DateTimeField(blank=True, null=True)
is_active: bool | BooleanField[bool | Combinable, bool]
is_active: bool | BooleanField[bool, bool]
backend: str # Set dynamically by authenticate(), used by login()

def get_username(self) -> str: ...
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -12,8 +12,8 @@ PASSWORD_FIELD: str

class Command(BaseCommand):
UserModel: type[AbstractBaseUser]
username_field: Field
username_field: Field[Any, Any, Any]
stdin: Any
def get_input_data(self, field: Field, message: str, default: str | None = ...) -> str | None: ...
def get_input_data(self, field: Field[Any, Any, Any], message: str, default: str | None = ...) -> str | None: ...
@cached_property
def username_is_unique(self) -> bool: ...
13 changes: 4 additions & 9 deletions django-stubs/contrib/contenttypes/fields.pyi
Original file line number Diff line number Diff line change
Expand Up @@ -4,8 +4,7 @@ from typing import Any
from django.contrib.contenttypes.models import ContentType
from django.core.checks.messages import CheckMessage
from django.db.models.base import Model
from django.db.models.expressions import Combinable
from django.db.models.fields import Field, _AllLimitChoicesTo
from django.db.models.fields import _GT, _NT, _ST, Field, _AllLimitChoicesTo
from django.db.models.fields.mixins import FieldCacheMixin
from django.db.models.fields.related import ForeignObject
from django.db.models.fields.related_descriptors import ReverseManyToOneDescriptor
Expand All @@ -16,11 +15,7 @@ from django.db.models.sql.where import WhereNode
from django.utils.functional import cached_property
from typing_extensions import override

class GenericForeignKey(FieldCacheMixin, Field):
# django-stubs implementation only fields
_pyi_private_set_type: Any | Combinable
_pyi_private_get_type: Any
# attributes
class GenericForeignKey(FieldCacheMixin, Field[_ST, _GT, _NT]):
hidden: bool
is_relation: bool
many_to_many: bool
Expand Down Expand Up @@ -74,7 +69,7 @@ class GenericRel(ForeignObjectRel):
limit_choices_to: _AllLimitChoicesTo | None = None,
) -> None: ...

class GenericRelation(ForeignObject[Any, Any]):
class GenericRelation(ForeignObject[_ST, _GT, _NT]):
rel_class: type[GenericRel]
mti_inherited: bool
object_id_field_name: str
Expand All @@ -91,7 +86,7 @@ class GenericRelation(ForeignObject[Any, Any]):
**kwargs: Any,
) -> None: ...
@override
def resolve_related_fields(self) -> list[tuple[Field, Field]]: ...
def resolve_related_fields(self) -> list[tuple[Field[Any, Any, Any], Field[Any, Any, Any]]]: ...
@override
def get_local_related_value(self, instance: Model) -> tuple[Any, ...]: ...
@override
Expand Down
4 changes: 3 additions & 1 deletion django-stubs/contrib/gis/admin/options.pyi
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,8 @@ _ModelT = TypeVar("_ModelT", bound=Model)
class GeoModelAdminMixin:
gis_widget: type[OSMWidget]
gis_widget_kwargs: dict[str, Any]
def formfield_for_dbfield(self, db_field: Field, request: HttpRequest, **kwargs: Any) -> FormField | None: ...
def formfield_for_dbfield(
self, db_field: Field[Any, Any, Any], request: HttpRequest, **kwargs: Any
) -> FormField | None: ...

class GISModelAdmin(GeoModelAdminMixin, ModelAdmin[_ModelT]): ...
8 changes: 4 additions & 4 deletions django-stubs/contrib/gis/db/backends/mysql/schema.pyi
Original file line number Diff line number Diff line change
Expand Up @@ -11,14 +11,14 @@ logger: Logger
class MySQLGISSchemaEditor(DatabaseSchemaEditor):
sql_add_spatial_index: str
@override
def skip_default(self, field: Field) -> bool: ...
def skip_default(self, field: Field[Any, Any, Any]) -> bool: ...
@override
def column_sql(
self, model: type[Model], field: Field, include_default: bool = ...
self, model: type[Model], field: Field[Any, Any, Any], include_default: bool = ...
) -> tuple[None, None] | tuple[str, list[Any]]: ...
@override
def create_model(self, model: type[Model]) -> None: ...
@override
def add_field(self, model: type[Model], field: Field) -> None: ...
def add_field(self, model: type[Model], field: Field[Any, Any, Any]) -> None: ...
@override
def remove_field(self, model: type[Model], field: Field) -> None: ...
def remove_field(self, model: type[Model], field: Field[Any, Any, Any]) -> None: ...
6 changes: 3 additions & 3 deletions django-stubs/contrib/gis/db/backends/oracle/schema.pyi
Original file line number Diff line number Diff line change
Expand Up @@ -15,14 +15,14 @@ class OracleGISSchemaEditor(DatabaseSchemaEditor):
def geo_quote_name(self, name: Any) -> Any: ...
@override
def column_sql(
self, model: type[Model], field: Field, include_default: bool = ...
self, model: type[Model], field: Field[Any, Any, Any], include_default: bool = ...
) -> tuple[None, None] | tuple[str, list[Any]]: ...
@override
def create_model(self, model: type[Model]) -> None: ...
@override
def delete_model(self, model: type[Model]) -> None: ...
@override
def add_field(self, model: type[Model], field: Field) -> None: ...
def add_field(self, model: type[Model], field: Field[Any, Any, Any]) -> None: ...
@override
def remove_field(self, model: type[Model], field: Field) -> None: ...
def remove_field(self, model: type[Model], field: Field[Any, Any, Any]) -> None: ...
def run_geometry_sql(self) -> None: ...
8 changes: 4 additions & 4 deletions django-stubs/contrib/gis/db/backends/spatialite/schema.pyi
Original file line number Diff line number Diff line change
Expand Up @@ -19,17 +19,17 @@ class SpatialiteSchemaEditor(DatabaseSchemaEditor):
def geo_quote_name(self, name: Any) -> Any: ...
@override
def column_sql(
self, model: type[Model], field: Field, include_default: bool = False
self, model: type[Model], field: Field[Any, Any, Any], include_default: bool = False
) -> tuple[None, None] | tuple[str, list[Any]]: ...
def remove_geometry_metadata(self, model: type[Model], field: Field) -> None: ...
def remove_geometry_metadata(self, model: type[Model], field: Field[Any, Any, Any]) -> None: ...
@override
def create_model(self, model: type[Model]) -> None: ...
@override
def delete_model(self, model: type[Model], **kwargs: Any) -> None: ... # type: ignore[override]
@override
def add_field(self, model: type[Model], field: Field) -> None: ...
def add_field(self, model: type[Model], field: Field[Any, Any, Any]) -> None: ...
@override
def remove_field(self, model: type[Model], field: Field) -> None: ...
def remove_field(self, model: type[Model], field: Field[Any, Any, Any]) -> None: ...
@override
def alter_db_table(
self,
Expand Down
Loading
Loading