2025, Dec 04 21:02
Data-descriptor в Python для dataclass: корректная типизация в Pyright
Как сделать Python-дескриптор data-descriptor, чтобы Pyright правильно типизировал dataclass: перегрузка __get__, добавляем __set__ и __set_name__, пример кода
Сделать так, чтобы дескриптор Python воспринимался как один тип на уровне класса и как другой — на уровне экземпляра, часто требуется, когда хочется аккуратного интерфейса при статической проверке типов. Перегрузка __get__ обычно почти решает задачу, но Pyright всё равно будет ругаться при инициализации dataclass, если дескриптор не является полноценным data-descriptor. В результате при передаче значения экземпляра в конструктор вы увидите сообщение о несоответствии типов.
Проблема
Нужно, чтобы обращение к атрибуту на уровне класса возвращало сам объект дескриптора, а на уровне экземпляра — конкретное значение. Перегрузка __get__ даёт типизатору эту подсказку, однако Pyright всё равно помечает инициализатор dataclass.
Literal[1] is not assignable to Field[int]
Воспроизводимый пример:
import typing as tp
from dataclasses import dataclass
class Cell[ValT]:
def __init__(self, seed: ValT):
self.seed = seed
self.prime = True
def __get__(self, obj, owner):
if self.prime:
self.prime = False
return self.seed
return self
if tp.TYPE_CHECKING:
@tp.overload
def __get__(self, obj: None, owner: type) -> tp.Self: ...
@tp.overload
def __get__(self, obj: object, owner: type) -> ValT: ...
@dataclass
class Record:
slot: Cell[int] = Cell(0)
if __name__ == "__main__":
cls_slot: Cell = Record.slot
inst_slot: int = Record().slot
assert isinstance(cls_slot, Cell)
assert isinstance(inst_slot, int)
item = Record(slot=1)
Что происходит
Перегруженный __get__ описывает разные возвращаемые типы для доступа через класс и экземпляр, но сам атрибут при этом не является data-descriptor. Чтобы им быть, нужен метод __set__. Без него Pyright не воспринимает присваивания через конструктор dataclass как запись значения в экземпляр и продолжает видеть атрибут как тип дескриптора, что и приводит к указанному конфликту. Принимаемый тип значения определяется сигнатурой __set__, которая должна принимать обобщённый тип значения.
Решение и рабочая версия
Сделайте дескриптор полноценным data-descriptor: добавьте __set__ и используйте __set_name__ для сохранения значения экземпляра в приватном атрибуте. Тип значения экземпляра затем контролируется через обобщённый параметр в __set__. При этом текущее поведение при первом чтении сохраняется.
import typing as tp
from dataclasses import dataclass
class Cell[ValT]:
def __init__(self, seed: ValT):
self.seed = seed
self.prime = True
@tp.overload
def __get__(self, obj: None, owner: type) -> tp.Self: ...
@tp.overload
def __get__(self, obj: object, owner: type) -> ValT: ...
def __get__(self, obj, owner):
if self.prime:
self.prime = False
return self.seed
if obj is None:
return self
return getattr(obj, self._alias, self.seed)
def __set_name__(self, owner, name):
self._alias = "_" + name
def __set__(self, obj, value: ValT):
setattr(obj, self._alias, value)
@dataclass
class Record:
slot: Cell[int] = Cell(0)
if __name__ == "__main__":
cls_slot = Record.slot
reveal_type(cls_slot) # Cell[int]
rec = Record()
inst_slot: int = rec.slot
reveal_type(inst_slot) # int
assert isinstance(cls_slot, Cell)
assert isinstance(inst_slot, int)
valid = Record(slot=1) # ОК
invalid = Record(slot="1") # Ошибка
Почему это важно
Дескрипторы, которые выглядят как разные типы на уровне класса и экземпляра, — мощный инструмент, но статическим анализаторам нужны чёткие сигналы. Если дескриптор — это data-descriptor с __set__, принимающим обобщённый тип значения, поведение времени выполнения согласуется со статическими ожиданиями, и аргументы конструктора с присваиваниями атрибутам проходят проверку типов как задумано.
Выводы
Если Pyright во время инициализации воспринимает поле dataclass с дескриптором как сам тип дескриптора, сделайте его data-descriptor. Реализуйте __set__ с параметром value, соответствующим обобщённому типу значения экземпляра, и при необходимости используйте __set_name__ для организации поместного хранилища значений. Сохраните перегрузки __get__ для различения доступа через класс и экземпляр — тогда проверяющий стабильно примет присваивания в конструкторе и чтения из экземпляра.