Общие подсказки типов для кваргов

Я пытаюсь обернуть класс сигнала блинкера классом, который обеспечивает ввод текста, чтобы аргументы send и connect проверялись по типам для каждого конкретного сигнала.

например, если у меня есть сигнал user_update, который ожидает, что sender будет экземпляром User и будет иметь ровно два kwargs: time: int, audit: str, я могу подкласс Signal, чтобы обеспечить это следующим образом:

class UserUpdateSignal(Signal):
  class Receiver(Protocol):
    def __call__(sender: User, /, time: int, audit: str):
      ...

  def send(sender: User, /, time: int, audit: str):
    # super call

  def connect(receiver: Receiver):
    # super call

что приводит к желаемому поведению при проверке типов:

user_update.send(user, time=34, audit = "user_initiated") # OK

@user_update.connect # OK
def receiver(sender: User, /, time: int, audit: str):
    ...

user_update.send("sender") # typing error - signature mismatch

@user_update.connect # typing error - signature mismatch
def receiver(sender: str):
    ...

Проблемы с этим подходом заключаются в следующем:

  • это очень многословно, для нескольких десятков сигналов у меня были бы сотни строк кода
  • на самом деле он не связывает тип подписи send с типом подписи connect - их можно обновлять независимо, проверка типов пройдет, но код будет аварийно завершать работу при запуске

Идеальный подход заключался бы в применении подписи, определенной один раз, как к send, так и к connect — возможно, с помощью дженериков. На данный момент я попробовал несколько подходов:

Позиционные аргументы только с ParamSpec

Я могу добиться желаемого поведения, используя только

class TypedSignal(Generic[P], Signal):
    def send(self, *args: P.args, **kwargs: P.kwargs):
        super().send(*args, **kwargs)

    def connect(self, receiver: Callable[P, None]):
        return super().connect(receiver=receiver)


user_update = TypedSignal[[User, str]]()

Этот тип правильно проверяет позиционные аргументы, но не поддерживает kwargs из-за ограничений Callable. Мне нужна kwargs поддержка, поскольку blinker использует kwargs для каждого прошлого аргумента sender.

Другие попытки

Использование TypeVar и TypeVarTuple

Я могу добиться подсказки типа для sender arg довольно просто, используя дженерики:

T = TypeVar("T")

class TypedSignal(Generic[T], Signal):
    def send(self, sender: Type[T], **kwargs):
        super(TypedSignal, self).send(sender)

    def connect(self, receiver: Callable[[Type[T], ...], None]) -> Callable:
        return super(TypedSignal, self).connect(receiver)

# used as
my_signal = TypedSignal[MyClass]()

сложнее всего, когда я хочу добавить проверку типов для kwargs. Подход, который я пытаюсь реализовать, заключается в использовании вариативного обобщения и Unpack вот так:

T = TypeVar("T")
KW = TypeVarTuple("KW")

class TypedSignal(Generic[T, Unpack[KW]], Signal):
    def send(self, sender: Type[T], **kwargs: Unpack[Type[KW]]):
        super(TypedSignal, self).send(sender)

    def connect(self, receiver: Callable[[Type[T], Unpack[Type[KW]]], None]) -> Callable:
        return super(TypedSignal, self).connect(receiver)

но mypy жалуется: error: Unpack item in ** argument must be a TypedDict, что кажется странным, потому что эта ошибка выдается даже без использования обобщенного кода, не говоря уже о том, когда передается TypedDict.

Использование ParamSpec и протокола

P = ParamSpec("P")

class TypedSignal(Generic[P], Signal):
    def send(self, *args: P.args, **kwargs: P.kwargs) -> None:
        super().send(*args, **kwargs)

    def connect(self, receiver: Callable[P, None]):
        return super().connect(receiver=receiver)


class Receiver(Protocol):
    def __call__(self, sender: MyClass) -> None:
        pass

update = TypedSignal[Receiver]()


@update.connect
def my_func(sender: MyClass) -> None:
    pass

update.send(MyClass())

но mypy, похоже, оборачивает протокол, поэтому ожидает функцию, которая принимает протокол, и выдает следующие ошибки:

 error: Argument 1 to "connect" of "TypedSignal" has incompatible type "Callable[[MyClass], None]"; expected "Callable[[Receiver], None]"  [arg-type]
 error: Argument 1 to "send" of "TypedSignal" has incompatible type "MyClass"; expected "Receiver"  [arg-type]

Краткое содержание

Есть ли более простой способ сделать это? Возможно ли это при текущей типизации Python?

версия mypy — 1.9.0 — пробовал с более ранними версиями, и она полностью разбилась.

Непонятно, что вы пытаетесь набрать статически. Я бы посоветовал вам переписать вопрос с точки зрения ожидаемых ошибок, которые mypy должен выдать, если вы ответите неправильно **kwargs. Основываясь на исходном коде блинкера, я вижу две возможные вещи, которые вы можете захотеть ввести check при передаче **kwargs: (1) соответствие подписи blinker.base.Signal.send и .connect или (2) соответствие вызываемым объектам в .receivers: dict[IdentityType, t.Callable | annotatable_weakref], которые получат **kwargs перешел в send.

dROOOze 14.04.2024 05:01

Ваши примеры, кажется, намекают на то, что вы хотите (1), но ваш TypedSignal.send, похоже, не передает **kwargs в super(), поэтому - что касается вашей текущей реализации - вообще нет смысла проверять тип **kwargs - вы можете также используйте typing.Any или _typeshed.Unused в качестве аннотации.

dROOOze 14.04.2024 05:03

Я хочу создать подписи для каждого набираемого сигнала. например, у меня был бы сигнал user_update, где отправителем всегда должен быть экземпляр User, и у него должен быть определенный набор kwargs, которые проверяются по типу - я добавлю подробности к вопросу

Michoel 15.04.2024 22:20

В вашем обновленном примере вы разрешаете time=34, audit = "user_initiated" в качестве аргумента ключевого слова для send. Как вы выбрали эти два ключевых аргумента, соответствующие send? Например, где определена исходная подпись, чтобы send совместимо с этой исходной подписью? Или вы ищете API (что-то вроде TypedSignal[Receiver, <KWArgs>]), который позволит вам генерировать подтип Signal, который автоматически проверяет, соответствует ли send<KWArgs>?

dROOOze 16.04.2024 06:54

Да, как и второй! Я обновлю вопрос более описательным ответом

Michoel 18.04.2024 18:47
Почему в Python есть оператор "pass"?
Почему в Python есть оператор "pass"?
Оператор pass в Python - это простая концепция, которую могут быстро освоить даже новички без опыта программирования.
Некоторые методы, о которых вы не знали, что они существуют в Python
Некоторые методы, о которых вы не знали, что они существуют в Python
Python - самый известный и самый простой в изучении язык в наши дни. Имея широкий спектр применения в области машинного обучения, Data Science,...
Основы Python Часть I
Основы Python Часть I
Вы когда-нибудь задумывались, почему в программах на Python вы видите приведенный ниже код?
LeetCode - 1579. Удаление максимального числа ребер для сохранения полной проходимости графа
LeetCode - 1579. Удаление максимального числа ребер для сохранения полной проходимости графа
Алиса и Боб имеют неориентированный граф из n узлов и трех типов ребер:
Оптимизация кода с помощью тернарного оператора Python
Оптимизация кода с помощью тернарного оператора Python
И последнее, что мы хотели бы показать вам, прежде чем двигаться дальше, это
Советы по эффективной веб-разработке с помощью Python
Советы по эффективной веб-разработке с помощью Python
Как веб-разработчик, Python может стать мощным инструментом для создания эффективных и масштабируемых веб-приложений.
2
5
411
2
Перейти к ответу Данный вопрос помечен как решенный

Ответы 2

Я не думаю, что это возможно.

Вы можете комментировать **kwargs тремя способами:

  1. **kwargs: Foo -> kwargs имеет тип dict[str, Foo]
  2. **kwargs: Unpack[TD] где TDTypedDict -> kwargs имеет тип TD
  3. *args: P.args, **kwargs: P.kwargs где P — это ParamSpec -> kwargs является общим для P

Таким образом, единственный способ использовать общий kwargs — это использовать 1. с обычным TypeVar, что означает, что все аргументы ключевых слов должны иметь совместимые типы, или использовать 3., но в настоящее время , который требует, чтобы у вас также были *args , что вы не имею.

Спасибо! Я также пробовал использовать ParamSpec, преобразуя первый аргумент в *args, но это не работает, потому что я не могу найти способ определить с его помощью kwargs (аналогично этой проблеме: github.com/python/typing/issues/ 1405)

Michoel 12.04.2024 15:25

Добавлен еще один подход с использованием ParamSpec и Protocol — любопытно, стоит ли что-нибудь попробовать в этом направлении. Есть ли более простой способ использовать ParamSpec здесь?

Michoel 12.04.2024 19:54

Я не уверен. Частично сложность заключается в том, что mypy имеет тенденцию отставать от новых функций набора текста, что иногда означает, что трудно отличить «использование неправильного набора текста», «это невозможно ввести в этой версии Python» и «это невозможно ввести в этой версии mypy». .

Jasmijn 15.04.2024 14:27
Ответ принят как подходящий

После долгих попыток найти ошибку я нашел относительно простое решение, хотя оно зависит от mypy_extensions, которое устарело, поэтому оно может быть не совсем перспективным, хотя оно все еще работает в последней mypy версии.

По сути, использование NamedArg mypy позволяет определить kwargs в Callable, что позволяет нам просто использовать ParamSpec для решения этой проблемы:

class TypedSignal(Generic[P], Signal):
    def send(self, *args: P.args, **kwargs: P.kwargs):
        super().send(*args, **kwargs)

    def connect(self, receiver: Callable[P, None]):
        return super().connect(receiver=receiver)


user_update = TypedSignal[[User, NamedArg(str, "metadata")]]()

Это правильно проверяет типы вызовов, так что все, кроме приведенного ниже:

@user_update.connect
def my_func(sender: User, metadata: str) -> None:
    pass

user_update.send(User(), metadata = "metadata")

выдаст ошибку.

Другие вопросы по теме