Микшер для создания типизированных полей сторонней модели Django
Учитывая проблему, заключающуюся в том, что не все библиотеки добавляют аннотации к типу, я пытаюсь создать комбинацию, которая позволила бы мне легко добавлять подсказки к типу. Без этого средство проверки типов определяет тип поля экземпляра модели как тип значения базового встроенного поля (например, str, int, дата и т.д.), на основе которого построено пользовательское поле (или Unknown
).
Вот пример:
from django.db import models
# ========== defined in a third-party library ==========
class StructuredCountry:
iso_code: str
...
class CountryField(models.CharField):
# Not shown: the textual value from the database
# is converted to a StructuredCountry object.
pass
# ========== defined in my own code ====================
class MyModel(models.Model):
country = CountryField()
reveal_type(MyModel().country) # ⇒ str
MyModel().country.iso_code # E: Attribute "iso_code" is unknown
Для приведенного выше примера я хотел бы иметь возможность:
class TypedCountryField(FieldAnnotationMixin, CountryField):
pass
class MyModel(models.Model):
country = TypedCountryField()
reveal_type(MyModel().country) # ⇒ StructuredCountry
MyModel().country.iso_code # ✔
<время работы/>
Пока что я придумал следующее (вдохновленный заглушками VSCode), которое действительно работает.
if TYPE_CHECKING:
class FieldAnnotationMixin[FieldClassType: type[models.Field], ValueType]:
def __new__(cls, *args, **kwargs) -> FieldClassType: ...
# Class access
@overload
def __get__(self, instance: None, owner: Any) -> Self: ... # type: ignore[overload]
# Model instance access
@overload
def __get__(self, instance: models.Model, owner: Any) -> ValueType: ...
# Non-Model instance access
@overload
def __get__(self, instance: Any, owner: Any) -> Self: ...
else:
class FieldAnnotationMixin[IgnoredT1, IgnoredT2]:
pass
Однако я застрял на поле с возможностью обнуления. Для поля, определенного в модели с помощью null=True
, значением экземпляра модели должно быть ValueType | None
(или Optional[ValueType]
), и средство проверки типов выдаст жалобу, если это значение будет использовано до проверки того, что оно не равно null. Еще раз взглянув на заглушки VSCode, я знаю, что могу перегрузить метод __new__
:
@overload
def __new__(cls, *args, null: Literal[False] = False, **kwargs) -> FieldClassType[ValueType]: ...
@overload
def __new__(cls, *args, null: Literal[True] = True, **kwargs) -> FieldClassType[ValueType | None]: ...
Здесь я сталкиваюсь с двумя проблемами:
- Средство проверки типов (правильно) жалуется, что тип “TypeVar "[FieldClassType@FieldAnnotationMixin]"не может быть подписан”. И я заранее не знаю, является ли стороннее поле общим или нет.
- Я понятия не имею, как передать информацию о параметре
null
в перегрузку__get__
, чтобы она возвращалаOptional[ValueType]
.
Вы на правильном пути с вашим подходом, особенно с использованием TYPE_CHECKING
для ввода информации о типе, которая не влияет на поведение во время выполнения. Однако динамическая настройка возвращаемого типа __get__
на основе null=True
проблематична, поскольку система типов Python не допускает условных типов, основанных на аргументах конструктора (например, null=True
) — средство проверки типов должно знать это статически.
Что вы можете сделать вместо этого, так это разделить задачу: создать две отдельные типизированные версии пользовательского поля — одну для поля, не имеющего значения null, и одну для поля, имеющего значение null. Вот упрощенная версия, которая хорошо работает с такими средствами проверки типов, как MyPy или Pyright:
from typing import TYPE_CHECKING, Any, Optional, overload, TypeVar, Generic
from django.db import models
# Simulating a third-party type
class StructuredCountry:
iso_code: str
class CountryField(models.CharField):
...
T = TypeVar("T")
if TYPE_CHECKING:
class FieldAnnotationMixin(Generic[T]):
@overload
def __get__(self, instance: None, owner: Any) -> "FieldAnnotationMixin[T]": ...
@overload
def __get__(self, instance: models.Model, owner: Any) -> T: ...
@overload
def __get__(self, instance: Any, owner: Any) -> "FieldAnnotationMixin[T]": ...
def __get__(self, instance, owner):
...
else:
class FieldAnnotationMixin(Generic[T]):
pass
Теперь просто создайте подкласс вашего стороннего поля с правильным вводом:
class TypedCountryField(FieldAnnotationMixin[StructuredCountry], CountryField):
pass
class NullableTypedCountryField(FieldAnnotationMixin[Optional[StructuredCountry]], CountryField):
pass
Затем в вашей модели:
class MyModel(models.Model):
country = TypedCountryField()
nullable_country = NullableTypedCountryField(null=True)
reveal_type(MyModel().country) # StructuredCountry
reveal_type(MyModel().nullable_country) # Optional[StructuredCountry]
Это позволяет избежать попыток вывода типов из аргументов конструктора, таких как null=True
, и сохраняет все явным и типобезопасным. Если бы вы действительно хотели разобраться с этим, вы могли бы использовать фабрику полей для автоматической генерации нужного типа, но разделять их таким образом намного проще и удобнее для проверки типов.