2025, Nov 21 18:03

Опциональный keyword-only параметр в Python: рабочий подход с overload и Protocol

Как в Python и Pylance сделать keyword-only аргумент кажущимся опциональным: декоратор для автогенерации, ограничения ParamSpec, overload и Protocol — подход.

Сделать обязательный параметр только по ключу (keyword‑only) «кажущимся» необязательным во время вызова — частая просьба про удобство: хочется, чтобы выполнение подставляло его при отсутствии, а IDE показывала его как опциональный. Сложность в том, чтобы согласовать это со статической типизацией и отображением сигнатур в инструментах вроде Pylance.

Постановка задачи

Предположим, несколько функций принимают именованный аргумент kw определённого типа T. Во время выполнения, если kw опущен, вы хотите, чтобы декоратор сгенерировал его. При этом всплывающая подсказка редактора должна показывать kw как необязательный со значением по умолчанию None, даже если реальная сигнатура требует его.

Изначально шаблон выглядит как функция вида:

def worker(*args, kw: T, **kwargs): ...

Но хотелось бы, чтобы IDE показывала так:

def worker(*args, kw: T | None = None, **kwargs): ...

Наивная попытка

Один из способов попробовать — подправить интроспектируемую сигнатуру и обернуть вызов, синтезируя kw при отсутствии. Концептуально:

import inspect
from typing import Optional, get_type_hints
from functools import wraps
def make_kw_opt(fn):
    siginfo = inspect.signature(fn)
    ann = get_type_hints(fn)
    kw_hint = ann.get("kw")
    maybe_kw_hint = Optional[kw_hint]
    patched_params = []
    for p in siginfo.parameters.values():
        if p.name == "kw":
            patched_params.append(
                p.replace(
                    annotation=maybe_kw_hint,
                    default=None,
                )
            )
        else:
            patched_params.append(p)
    siginfo2 = siginfo.replace(parameters=patched_params)
    @wraps(fn)
    def callable_proxy(*a, **k):
        if k.get("kw") is None:
            k["kw"] = build_kw()
        return fn(*a, **k)
    # здесь нет поддерживаемого способа заставить Pylance показать изменённую подсказку
    return callable_proxy

Такой подход даёт желаемое поведение во время выполнения, но не заставляет Pylance показывать изменённую сигнатуру. Иными словами, «пропатчить» отображаемую IDE сигнатуру простым редактированием рантайм‑сигнатуры не получится.

Почему переписать сигнатуру, как задумано, не выходит

В Pylance изменить отображаемую подсказку на произвольно переписанную сигнатуру в таком сценарии не поддерживается. Чтобы корректно прокидывать неизвестную сигнатуру, нужен ParamSpec, позволяющий захватить и передать позиционные и именованные параметры. Однако вы не можете вставить новый параметр между varargs и kwargs ParamSpec. То есть нельзя определить что-то вроде *args: P.args, kw: T, **kwargs: P.kwargs и рассчитывать, что инструменты типизации примут это как допустимую трансформацию.

Иногда форму именованных аргументов можно описать через TypedDict в kwargs, но для этой задачи это менее гибко, чем ParamSpec.

Ближайший практичный подход: overload + Protocol

Самый реалистичный способ повлиять на то, что показывает Pylance, — объявить две перегрузки: одна зеркалит исходный обязательный kw, вторая показывает kw как опциональный со значением по умолчанию None. Protocol используется, чтобы описать форму входного и выходного вызываемого объекта для декоратора. Сам декоратор может решать рантайм‑задачи и возвращать исходную функцию, а статическая типизация будет видеть функцию с двумя перегрузками.

from typing import overload, Protocol, cast
class NeedsKw[U, V, **Q](Protocol):
    @overload
    @staticmethod
    def __call__(*args, kw: U, **kwargs) -> V: ...
    @overload
    @staticmethod
    def __call__(*args: Q.args, **kwargs: Q.kwargs) -> V: ...
    # Нельзя: вставлять что‑то между Q.args и Q.kwargs
    # def __call__(*args: Q.args, kw: U, **kwargs: Q.kwargs) -> V: ...
class DefaultedKw[U, V, **Q](Protocol):
    @overload
    @staticmethod
    def __call__(*args: Q.args, **kwargs: Q.kwargs) -> V: ...
    @overload
    @staticmethod
    def __call__(*args, kw: U | None = None, **kwargs) -> V:
        """kw is optional and None by default"""
    # Тоже нельзя: вставлять параметр между Q.args и Q.kwargs при использовании ParamSpec
    # def __call__(*args: Q.args, kw: U | None = None, **kwargs: Q.kwargs) -> V: ...
def make_kw_flexible[U, V, **Q](fn: NeedsKw[U, V, Q]) -> DefaultedKw[U, V, Q]:
    # Perform any runtime wrapping if needed, then return a view typed as DefaultedKw
    return cast(DefaultedKw[U, V, Q], fn)
@make_kw_flexible
def demo(x: int, *, kw: str, extra: int) -> str:
    ...

Этот паттерн заставляет IDE показать две сигнатуры для декорированной функции. Одна отражает исходное требование kw с его конкретным типом. Другая показывает kw как опциональный со значением None по умолчанию, что и требуется в подсказке. Прокидывание вызова использует ParamSpec ради общности, не пытаясь втиснуть параметры между varargs и kwargs.

Частные случаи и примечания

Есть узкий случай, когда одиночная перегрузка непосредственно над функцией может «обмануть» отображение и показать kw как опциональный, но это обычно вызывает предупреждения, потому что ожидается как минимум две перегрузки. Если kw — первый позиционный параметр, такой обход иногда возможен. Но когда kw — ключевой‑только, ограничение остаётся: трансформировать ParamSpec так, чтобы добавить параметры между *args и **kwargs, нельзя.

Есть инструменты, способные менять типовую модель с учётом декоратора на этапе анализа. Например, плагин mypy может реализовать такую трансформацию; однако наличие соответствующих интеграций в IDE здесь не установлено. Стандартных средств типизации для произвольных трансформаций ParamSpec нет.

Почему это важно

Точность сигнатуры — это больше, чем эстетика. Команды полагаются на подсказки IDE и типизаторы, чтобы доносить контракт. Если в подсказке написано «kw опционален», а типовая система не может формально выразить эту трансформацию, вы рискуете запутать читателей и статические инструменты. Оставаясь в границах того, что могут выразить Pylance и типовая система — перегрузки и Protocol, — вы сохраняете предсказуемость процесса разработки.

Практическое резюме

Если нужно генерировать kw при его отсутствии, делайте это в рантайме через декоратор. Чтобы улучшить опыт в Pylance, выразите опциональность через перегрузки в Protocol и пусть декоратор возвращает вызываемый объект, типизированный как «defaulted» Protocol. Не пытайтесь вставлять параметры внутрь «бутерброда» ParamSpec — такая форма не поддерживается. Если kw может быть позиционным, манёвров больше, но для ключевых‑только параметров ограничения сохраняются. В сомнительных случаях опирайтесь на перегрузки, чтобы показать несколько форм вызова, а рантайм‑обёртку держите простой.