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__ для различения доступа через класс и экземпляр — тогда проверяющий стабильно примет присваивания в конструкторе и чтения из экземпляра.