From 190975420840845bfbe54bef9e128c8abe8b6762 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andrea=20Magist=C3=A0?= Date: Mon, 15 Jun 2026 18:35:39 +0200 Subject: [PATCH 1/6] refactor: enhance type hinting for field constructors --- tortoise/fields/base.py | 72 ++++++++++++++++++++++++++++- tortoise/fields/data.py | 85 ++++++++++++++++++++++++----------- tortoise/fields/relational.py | 26 ++++++++--- 3 files changed, 151 insertions(+), 32 deletions(-) diff --git a/tortoise/fields/base.py b/tortoise/fields/base.py index 81db786c0..aa82dd9ba 100644 --- a/tortoise/fields/base.py +++ b/tortoise/fields/base.py @@ -7,7 +7,7 @@ from collections.abc import Callable from enum import Enum from functools import reduce -from typing import TYPE_CHECKING, Any, Generic, TypeVar, overload +from typing import TYPE_CHECKING, Any, Generic, TypedDict, TypeVar, overload from pypika_tortoise.terms import Term @@ -90,6 +90,76 @@ class OnDelete(StrEnum): NO_ACTION = OnDelete.NO_ACTION +class _FieldKwargsCommon(TypedDict, total=False): + """:class:`Field` constructor arguments that are never declared as explicit parameters. + + Used with :data:`typing.Unpack` to give ``**kwargs`` explicit type hints. This is the + smallest set; fields that declare ``unique``/``db_index``/``primary_key`` explicitly + (e.g. ``TextField``) unpack this directly to avoid PEP 692 parameter-name collisions. + """ + + source_field: str | None + generated: bool + default: Any + db_default: Any + description: str | None + model: Model | None + validators: list[Validator | Callable] + pk: bool # deprecated alias for primary_key + index: bool # deprecated alias for db_index + + +class _FieldKwargsNoPk(_FieldKwargsCommon, total=False): + """Common arguments excluding ``primary_key`` and ``null``. + + For constructors that declare ``primary_key`` and ``null`` as explicit parameters + (e.g. ``IntField``). + """ + + unique: bool + db_index: bool | None + + +class FieldKwargs(_FieldKwargsNoPk, total=False): + """Common arguments excluding ``null``. + + For constructors that declare only ``null`` as an explicit parameter (the majority). + """ + + primary_key: bool | None + + +class JSONFieldKwargs(FieldKwargs, total=False): + """Constructor arguments for :class:`JSONField`. + + ``JSONField`` declares neither ``null`` nor ``primary_key`` explicitly, and also accepts + a custom ``field_type`` (e.g. a Pydantic model class). + """ + + null: bool + field_type: Any + +class RelationalFieldKwargs(FieldKwargs, total=False): + """Constructor arguments for :func:`ForeignKeyField` and :func:`OneToOneField`. + + Extends the common :class:`~tortoise.fields.base.FieldKwargs` with ``to_field``. + ``null`` is declared as an explicit parameter on those constructors, so it is omitted. + """ + + to_field: str | None + + +class ManyToManyFieldKwargs(_FieldKwargsCommon, total=False): + """Constructor arguments for :func:`ManyToManyField`. + + ``unique`` is declared as an explicit parameter, so it is omitted here; the deprecated + ``create_unique_index`` alias is still accepted. + """ + + create_unique_index: bool # deprecated alias for unique + + + class _FieldMeta(type): # TODO: Require functions to return field instances instead of this hack def __new__(mcs, name: str, bases: tuple[type, ...], attrs: dict) -> type: diff --git a/tortoise/fields/data.py b/tortoise/fields/data.py index 6109d0022..0ba5fa94d 100644 --- a/tortoise/fields/data.py +++ b/tortoise/fields/data.py @@ -4,6 +4,7 @@ import datetime import functools import json +import sys import warnings from collections.abc import Callable from decimal import Decimal @@ -17,7 +18,18 @@ from tortoise import timezone from tortoise.exceptions import ConfigurationError, FieldError -from tortoise.fields.base import Field +from tortoise.fields.base import ( + Field, + FieldKwargs, + JSONFieldKwargs, + _FieldKwargsCommon, + _FieldKwargsNoPk, +) + +if sys.version_info >= (3, 11): + from typing import Unpack +else: # pragma: no cover + from typing_extensions import Unpack from tortoise.timezone import get_default_timezone, get_timezone, get_use_tz, localtime from tortoise.validators import MaxLengthValidator @@ -107,7 +119,7 @@ def __init__( primary_key: bool | None = None, *, null: Literal[False] = False, - **kwargs: Any, + **kwargs: Unpack[_FieldKwargsNoPk], ) -> None: ... @overload @@ -116,7 +128,7 @@ def __init__( primary_key: bool | None = None, *, null: Literal[True], - **kwargs: Any, + **kwargs: Unpack[_FieldKwargsNoPk], ) -> None: ... def __init__(self, primary_key: bool | None = None, **kwargs: Any) -> None: @@ -222,12 +234,20 @@ class CharField(Field[T_STR]): @overload def __init__( - self: CharField[str], max_length: int, *, null: Literal[False] = False, **kwargs: Any + self: CharField[str], + max_length: int, + *, + null: Literal[False] = False, + **kwargs: Unpack[FieldKwargs], ) -> None: ... @overload def __init__( - self: CharField[str | None], max_length: int, *, null: Literal[True], **kwargs: Any + self: CharField[str | None], + max_length: int, + *, + null: Literal[True], + **kwargs: Unpack[FieldKwargs], ) -> None: ... def __init__(self, max_length: int, **kwargs: Any) -> None: @@ -269,7 +289,7 @@ def __init__( primary_key: bool | None = None, unique: bool = False, db_index: bool = False, - **kwargs: Any, + **kwargs: Unpack[_FieldKwargsCommon], ) -> None: if primary_key or kwargs.get("pk"): warnings.warn( @@ -315,12 +335,12 @@ class BooleanField(Field[T_BOOL]): @overload def __init__( - self: BooleanField[bool], *, null: Literal[False] = False, **kwargs: Any + self: BooleanField[bool], *, null: Literal[False] = False, **kwargs: Unpack[FieldKwargs] ) -> None: ... @overload def __init__( - self: BooleanField[bool | None], *, null: Literal[True], **kwargs: Any + self: BooleanField[bool | None], *, null: Literal[True], **kwargs: Unpack[FieldKwargs] ) -> None: ... def __init__(self, **kwargs: Any) -> None: @@ -357,7 +377,7 @@ def __init__( decimal_places: int, *, null: Literal[False] = False, - **kwargs: Any, + **kwargs: Unpack[FieldKwargs], ) -> None: ... @overload @@ -367,7 +387,7 @@ def __init__( decimal_places: int, *, null: Literal[True], - **kwargs: Any, + **kwargs: Unpack[FieldKwargs], ) -> None: ... def __init__(self, max_digits: int, decimal_places: int, **kwargs: Any) -> None: @@ -440,7 +460,7 @@ def __init__( auto_now_add: bool = False, *, null: Literal[False] = False, - **kwargs: Any, + **kwargs: Unpack[FieldKwargs], ) -> None: ... @overload @@ -450,7 +470,7 @@ def __init__( auto_now_add: bool = False, *, null: Literal[True], - **kwargs: Any, + **kwargs: Unpack[FieldKwargs], ) -> None: ... def __init__(self, auto_now: bool = False, auto_now_add: bool = False, **kwargs: Any) -> None: @@ -530,12 +550,15 @@ class DateField(Field[T_DATE], datetime.date): @overload def __init__( - self: DateField[datetime.date], *, null: Literal[False] = False, **kwargs: Any + self: DateField[datetime.date], + *, + null: Literal[False] = False, + **kwargs: Unpack[FieldKwargs], ) -> None: ... @overload def __init__( - self: DateField[datetime.date | None], *, null: Literal[True], **kwargs: Any + self: DateField[datetime.date | None], *, null: Literal[True], **kwargs: Unpack[FieldKwargs] ) -> None: ... def __init__(self, **kwargs: Any) -> None: @@ -574,7 +597,7 @@ def __init__( auto_now_add: bool = False, *, null: Literal[False] = False, - **kwargs: Any, + **kwargs: Unpack[FieldKwargs], ) -> None: ... @overload @@ -584,7 +607,7 @@ def __init__( auto_now_add: bool = False, *, null: Literal[True], - **kwargs: Any, + **kwargs: Unpack[FieldKwargs], ) -> None: ... def __init__(self, auto_now: bool = False, auto_now_add: bool = False, **kwargs: Any) -> None: @@ -654,12 +677,18 @@ class TimeDeltaField(Field[T_TIMEDELTA]): @overload def __init__( - self: TimeDeltaField[datetime.timedelta], *, null: Literal[False] = False, **kwargs: Any + self: TimeDeltaField[datetime.timedelta], + *, + null: Literal[False] = False, + **kwargs: Unpack[FieldKwargs], ) -> None: ... @overload def __init__( - self: TimeDeltaField[datetime.timedelta | None], *, null: Literal[True], **kwargs: Any + self: TimeDeltaField[datetime.timedelta | None], + *, + null: Literal[True], + **kwargs: Unpack[FieldKwargs], ) -> None: ... def __init__(self, **kwargs: Any) -> None: @@ -692,11 +721,13 @@ class FloatField(Field[T_FLOAT], float): @overload def __init__( - self: FloatField[float], *, null: Literal[False] = False, **kwargs: Any + self: FloatField[float], *, null: Literal[False] = False, **kwargs: Unpack[FieldKwargs] ) -> None: ... @overload - def __init__(self: FloatField[float | None], *, null: Literal[True], **kwargs: Any) -> None: ... + def __init__( + self: FloatField[float | None], *, null: Literal[True], **kwargs: Unpack[FieldKwargs] + ) -> None: ... def __init__(self, **kwargs: Any) -> None: super().__init__(**kwargs) @@ -748,7 +779,7 @@ def __init__( self, encoder: JsonDumpsFunc = JSON_DUMPS, decoder: JsonLoadsFunc = JSON_LOADS, - **kwargs: Any, + **kwargs: Unpack[JSONFieldKwargs], ) -> None: super().__init__(**kwargs) self.encoder = encoder @@ -820,10 +851,14 @@ class _db_postgres: SQL_TYPE = "UUID" @overload - def __init__(self: UUIDField[UUID], *, null: Literal[False] = False, **kwargs: Any) -> None: ... + def __init__( + self: UUIDField[UUID], *, null: Literal[False] = False, **kwargs: Unpack[FieldKwargs] + ) -> None: ... @overload - def __init__(self: UUIDField[UUID | None], *, null: Literal[True], **kwargs: Any) -> None: ... + def __init__( + self: UUIDField[UUID | None], *, null: Literal[True], **kwargs: Unpack[FieldKwargs] + ) -> None: ... def __init__(self, **kwargs: Any) -> None: if (kwargs.get("primary_key") or kwargs.get("pk", False)) and "default" not in kwargs: @@ -852,12 +887,12 @@ class BinaryField(Field[T_BINARY], bytes): # type: ignore @overload def __init__( - self: BinaryField[bytes], *, null: Literal[False] = False, **kwargs: Any + self: BinaryField[bytes], *, null: Literal[False] = False, **kwargs: Unpack[FieldKwargs] ) -> None: ... @overload def __init__( - self: BinaryField[bytes | None], *, null: Literal[True], **kwargs: Any + self: BinaryField[bytes | None], *, null: Literal[True], **kwargs: Unpack[FieldKwargs] ) -> None: ... def __init__(self, **kwargs: Any) -> None: diff --git a/tortoise/fields/relational.py b/tortoise/fields/relational.py index 442707848..d69e6c24d 100644 --- a/tortoise/fields/relational.py +++ b/tortoise/fields/relational.py @@ -1,5 +1,6 @@ from __future__ import annotations +import sys import warnings from collections.abc import AsyncGenerator, Generator, Iterator from typing import TYPE_CHECKING, Any, Generic, Literal, TypeVar, overload @@ -7,7 +8,19 @@ from pypika_tortoise.queries import Table from tortoise.exceptions import ConfigurationError, NoValuesFetched, OperationalError -from tortoise.fields.base import CASCADE, SET_NULL, Field, OnDelete +from tortoise.fields.base import ( + CASCADE, + SET_NULL, + Field, + ManyToManyFieldKwargs, + OnDelete, + RelationalFieldKwargs, +) + +if sys.version_info >= (3, 11): + from typing import Unpack +else: # pragma: no cover + from typing_extensions import Unpack if TYPE_CHECKING: # pragma: nocoverage from tortoise.backends.base.client import BaseDBAsyncClient @@ -17,6 +30,7 @@ MODEL = TypeVar("MODEL", bound="Model") + class _NoneAwaitable: __slots__ = () @@ -441,7 +455,7 @@ def OneToOneField( db_constraint: bool = True, *, null: Literal[True], - **kwargs: Any, + **kwargs: Unpack[RelationalFieldKwargs], ) -> OneToOneNullableRelation[MODEL]: ... @@ -452,7 +466,7 @@ def OneToOneField( on_delete: OnDelete = CASCADE, db_constraint: bool = True, null: Literal[False] = False, - **kwargs: Any, + **kwargs: Unpack[RelationalFieldKwargs], ) -> OneToOneRelation[MODEL]: ... @@ -516,7 +530,7 @@ def ForeignKeyField( db_constraint: bool = True, *, null: Literal[True], - **kwargs: Any, + **kwargs: Unpack[RelationalFieldKwargs], ) -> ForeignKeyNullableRelation[MODEL]: ... @@ -527,7 +541,7 @@ def ForeignKeyField( on_delete: OnDelete = CASCADE, db_constraint: bool = True, null: Literal[False] = False, - **kwargs: Any, + **kwargs: Unpack[RelationalFieldKwargs], ) -> ForeignKeyRelation[MODEL]: ... @@ -592,7 +606,7 @@ def ManyToManyField( on_delete: OnDelete = CASCADE, db_constraint: bool = True, unique: bool = True, - **kwargs: Any, + **kwargs: Unpack[ManyToManyFieldKwargs], ) -> ManyToManyRelation[MODEL]: """ ManyToMany relation field. From 390b8df4e2918a89dc6cc6300f046aa3830952ea Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andrea=20Magist=C3=A0?= Date: Mon, 15 Jun 2026 18:36:05 +0200 Subject: [PATCH 2/6] updated changelog --- CHANGELOG.rst | 1 + 1 file changed, 1 insertion(+) diff --git a/CHANGELOG.rst b/CHANGELOG.rst index 78e832ee1..1a4a22ab2 100644 --- a/CHANGELOG.rst +++ b/CHANGELOG.rst @@ -17,6 +17,7 @@ Added - ``QuerySet.contains()`` method to check if an object exists in a queryset. - Added comprehensive EXPLAIN support for MySQL and PostgreSQL. - Built-in ``DomainNameValidator``, ``URLValidator``, and ``EmailValidator`` classes for common validation patterns. (#2162) +- Typed ``**kwargs`` on field constructors via PEP 692 (``Unpack[TypedDict]``), so IDEs and type checkers can autocomplete and validate common field arguments (``default``, ``null``, ``unique``, ``db_index``, ``description``, etc.). (#2168) Fixed ^^^^^ From dbd4819c9726a49f8822954e01a41919b460718a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andrea=20Magist=C3=A0?= Date: Tue, 16 Jun 2026 07:58:31 +0200 Subject: [PATCH 3/6] run `make style` --- tortoise/fields/base.py | 2 +- tortoise/fields/relational.py | 1 - 2 files changed, 1 insertion(+), 2 deletions(-) diff --git a/tortoise/fields/base.py b/tortoise/fields/base.py index aa82dd9ba..bd6562c0f 100644 --- a/tortoise/fields/base.py +++ b/tortoise/fields/base.py @@ -139,6 +139,7 @@ class JSONFieldKwargs(FieldKwargs, total=False): null: bool field_type: Any + class RelationalFieldKwargs(FieldKwargs, total=False): """Constructor arguments for :func:`ForeignKeyField` and :func:`OneToOneField`. @@ -159,7 +160,6 @@ class ManyToManyFieldKwargs(_FieldKwargsCommon, total=False): create_unique_index: bool # deprecated alias for unique - class _FieldMeta(type): # TODO: Require functions to return field instances instead of this hack def __new__(mcs, name: str, bases: tuple[type, ...], attrs: dict) -> type: diff --git a/tortoise/fields/relational.py b/tortoise/fields/relational.py index d69e6c24d..b5e7ab568 100644 --- a/tortoise/fields/relational.py +++ b/tortoise/fields/relational.py @@ -30,7 +30,6 @@ MODEL = TypeVar("MODEL", bound="Model") - class _NoneAwaitable: __slots__ = () From fab2ca66a88aea011db2752c75016803285a00cb Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andrea=20Magist=C3=A0?= Date: Wed, 17 Jun 2026 08:47:53 +0200 Subject: [PATCH 4/6] Added two @overload signatures with explicit null: Literal[False] / null: Literal[True] --- tortoise/fields/data.py | 22 ++++++++++++++++++++++ 1 file changed, 22 insertions(+) diff --git a/tortoise/fields/data.py b/tortoise/fields/data.py index 0ba5fa94d..4e3453429 100644 --- a/tortoise/fields/data.py +++ b/tortoise/fields/data.py @@ -284,12 +284,34 @@ class TextField(Field[str], str): # type: ignore indexable = False SQL_TYPE = "TEXT" + @overload + def __init__( + self, + *, + primary_key: bool | None = None, + unique: bool = False, + db_index: bool = False, + null: Literal[False] = False, + **kwargs: Unpack[_FieldKwargsCommon], + ) -> None: ... + + @overload def __init__( self, + *, primary_key: bool | None = None, unique: bool = False, db_index: bool = False, + null: Literal[True], **kwargs: Unpack[_FieldKwargsCommon], + ) -> None: ... + + def __init__( + self, + primary_key: bool | None = None, + unique: bool = False, + db_index: bool = False, + **kwargs: Any, ) -> None: if primary_key or kwargs.get("pk"): warnings.warn( From ae4953c608c71203646817165f5d3625d1c040fc Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andrea=20Magist=C3=A0?= Date: Wed, 17 Jun 2026 08:57:58 +0200 Subject: [PATCH 5/6] fix: changed Textfield type to Field[T_STR] instead of Field[str] --- tortoise/fields/data.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/tortoise/fields/data.py b/tortoise/fields/data.py index 4e3453429..ac70562e4 100644 --- a/tortoise/fields/data.py +++ b/tortoise/fields/data.py @@ -276,7 +276,7 @@ def SQL_TYPE(self) -> str: return f"NVARCHAR2({self.field.max_length})" -class TextField(Field[str], str): # type: ignore +class TextField(Field[T_STR], str): # type: ignore """ Large Text field. """ @@ -286,7 +286,7 @@ class TextField(Field[str], str): # type: ignore @overload def __init__( - self, + self: TextField[str], *, primary_key: bool | None = None, unique: bool = False, @@ -297,7 +297,7 @@ def __init__( @overload def __init__( - self, + self: TextField[str | None], *, primary_key: bool | None = None, unique: bool = False, From 538d1f9c41c83ef9a59c98ee948c4a0c5be84958 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andrea=20Magist=C3=A0?= Date: Thu, 18 Jun 2026 16:44:54 +0200 Subject: [PATCH 6/6] refactor: changed import order for consistency --- tortoise/fields/data.py | 60 ++++++++++++++++++++++++++++++----------- 1 file changed, 45 insertions(+), 15 deletions(-) diff --git a/tortoise/fields/data.py b/tortoise/fields/data.py index ac70562e4..f36332432 100644 --- a/tortoise/fields/data.py +++ b/tortoise/fields/data.py @@ -25,11 +25,6 @@ _FieldKwargsCommon, _FieldKwargsNoPk, ) - -if sys.version_info >= (3, 11): - from typing import Unpack -else: # pragma: no cover - from typing_extensions import Unpack from tortoise.timezone import get_default_timezone, get_timezone, get_use_tz, localtime from tortoise.validators import MaxLengthValidator @@ -42,7 +37,9 @@ try: from pydantic import BaseModel as _PydanticBaseModel - from pydantic._internal._model_construction import ModelMetaclass as _PydanticModelMetaclass + from pydantic._internal._model_construction import ( + ModelMetaclass as _PydanticModelMetaclass, + ) except ImportError: _PydanticBaseModel = None # type: ignore[assignment,misc] _PydanticModelMetaclass = None # type: ignore[assignment,misc] @@ -50,6 +47,12 @@ if TYPE_CHECKING: # pragma: nocoverage from tortoise.models import Model + +if sys.version_info >= (3, 11): + from typing import Unpack +else: # pragma: no cover + from typing_extensions import Unpack + __all__ = ( "BigIntField", "BinaryField", @@ -357,12 +360,18 @@ class BooleanField(Field[T_BOOL]): @overload def __init__( - self: BooleanField[bool], *, null: Literal[False] = False, **kwargs: Unpack[FieldKwargs] + self: BooleanField[bool], + *, + null: Literal[False] = False, + **kwargs: Unpack[FieldKwargs], ) -> None: ... @overload def __init__( - self: BooleanField[bool | None], *, null: Literal[True], **kwargs: Unpack[FieldKwargs] + self: BooleanField[bool | None], + *, + null: Literal[True], + **kwargs: Unpack[FieldKwargs], ) -> None: ... def __init__(self, **kwargs: Any) -> None: @@ -580,7 +589,10 @@ def __init__( @overload def __init__( - self: DateField[datetime.date | None], *, null: Literal[True], **kwargs: Unpack[FieldKwargs] + self: DateField[datetime.date | None], + *, + null: Literal[True], + **kwargs: Unpack[FieldKwargs], ) -> None: ... def __init__(self, **kwargs: Any) -> None: @@ -743,12 +755,18 @@ class FloatField(Field[T_FLOAT], float): @overload def __init__( - self: FloatField[float], *, null: Literal[False] = False, **kwargs: Unpack[FieldKwargs] + self: FloatField[float], + *, + null: Literal[False] = False, + **kwargs: Unpack[FieldKwargs], ) -> None: ... @overload def __init__( - self: FloatField[float | None], *, null: Literal[True], **kwargs: Unpack[FieldKwargs] + self: FloatField[float | None], + *, + null: Literal[True], + **kwargs: Unpack[FieldKwargs], ) -> None: ... def __init__(self, **kwargs: Any) -> None: @@ -874,12 +892,18 @@ class _db_postgres: @overload def __init__( - self: UUIDField[UUID], *, null: Literal[False] = False, **kwargs: Unpack[FieldKwargs] + self: UUIDField[UUID], + *, + null: Literal[False] = False, + **kwargs: Unpack[FieldKwargs], ) -> None: ... @overload def __init__( - self: UUIDField[UUID | None], *, null: Literal[True], **kwargs: Unpack[FieldKwargs] + self: UUIDField[UUID | None], + *, + null: Literal[True], + **kwargs: Unpack[FieldKwargs], ) -> None: ... def __init__(self, **kwargs: Any) -> None: @@ -909,12 +933,18 @@ class BinaryField(Field[T_BINARY], bytes): # type: ignore @overload def __init__( - self: BinaryField[bytes], *, null: Literal[False] = False, **kwargs: Unpack[FieldKwargs] + self: BinaryField[bytes], + *, + null: Literal[False] = False, + **kwargs: Unpack[FieldKwargs], ) -> None: ... @overload def __init__( - self: BinaryField[bytes | None], *, null: Literal[True], **kwargs: Unpack[FieldKwargs] + self: BinaryField[bytes | None], + *, + null: Literal[True], + **kwargs: Unpack[FieldKwargs], ) -> None: ... def __init__(self, **kwargs: Any) -> None: