2026, Jan 04 00:01

Сужение Iterable[str] в строгом режиме Pyright: рабочий type guard

Почему в строгом режиме Pyright all(isinstance) не сужает Iterable[str], и как исправить: TypeIs и type guard для корректной реализации __contains__ в коллекциях

Работа с Pyright в строгом режиме часто выявляет разрыв между проверками во время выполнения и тем, что статический анализатор способен доказать. Типичный случай: вы хотите убедиться, что значение с аннотацией object на деле является Iterable[str], и рассчитываете, что проверяющий типы это поймёт после защитной проверки isinstance. Этого не происходит. Ниже — почему так и как аккуратно это исправить.

Проблема

Вам нужно реализовать __contains__ в подклассе MutableSequence, где сигнатура метода жёстко задаётся как value: object. Во время выполнения вы хотите принимать любой Iterable[str], нормализовать его и сравнить со своими внутренними данными. Прямолинейный вариант выглядит так:

from typing import Iterable
class Holder:
    def __init__(self, payload: Iterable[str]) -> None:
        self.payload = payload
    def __contains__(self, probe: object) -> bool:
        if isinstance(probe, Iterable) and all(isinstance(tok, str) for tok in probe):
            merged = "".join(probe).upper()
            return merged in "".join(self.payload)
        return False

Во время выполнения это работает. Но в строгом режиме Pyright переменная внутри генератора, tok, остаётся типа Unknown, и проверяющий не сужает probe до Iterable[str] внутри блока if. Ручной обход, for tok in probe, тоже не помогает: тип элемента по-прежнему неизвестен.

Почему проверка isinstance + all(...) не сужает тип

Pyright не рассматривает all(isinstance(...)) как защиту типов (type guard). В отличие от некоторых встроенных функций вроде filter, для которых можно описать поведение через заглушки, all потребовала бы особой логики в проверяющем и предположений о семантике итерируемого объекта. Мейнтейнеры резюмируют это так:

Проверяющему пришлось бы не только «зашить» знание о функции all, но и делать предположения о семантике итерируемого выражения, с которым она работает.

Поддержку пришлось бы добавлять специально для выражений вида all(isinstance(a, b) for a in [x, y]). Такая форма используется редко, поэтому добавлять для неё отдельную логику нецелесообразно.

Короче говоря, all(...) не сузит probe до Iterable[str] в Pyright, а переменная элемента tok в строгом режиме останется Unknown.

Решение: отдельная защита типов

Рекомендуемый подход — добавить небольшой, переиспользуемый type guard, который явно говорит: «это Iterable[T] с элементами указанного типа». Затем защищаем им тело __contains__. Так сигнатура с value: object остаётся неизменной, а Pyright получает понятный ему сигнал.

from typing import Iterable, TypeIs
# Переиспользуемый гард: проверяет итерируемость и тип элементов
def ensure_iterable_of[T](thing, kind: type[T] = object) -> TypeIs[Iterable[T]]:
    return isinstance(thing, Iterable) and all(isinstance(elem, kind) for elem in thing)
class Holder:
    def __init__(self, payload: Iterable[str]) -> None:
        self.payload = payload
    def __contains__(self, probe: object) -> bool:
        if ensure_iterable_of(probe, str):
            normalized = "".join(probe).upper()
            return normalized in "".join(self.payload)
        return False

Этот приём сужает probe до Iterable[str] внутри блока if. Если не хочется добавлять помощник, можно сделать cast после проверки. Если строгий режим всё ещё помечает переменную генератора как неизвестную в вызове all(...), можно либо точечно подавить предупреждение reportUnknownArgumentType, либо разбить гард на два шага так, чтобы сама итерация шла по Iterable[Any]; это убирает жалобу на неизвестный аргумент.

from typing import Any, Iterable, TypeIs
# Шаг 1: распознать «итерируемое из любых элементов»
def seems_iterable(source: object) -> TypeIs[Iterable[Any]]:
    return isinstance(source, Iterable)
# Шаг 2: уточнить до конкретного типа элементов, опираясь на первый гард
def ensure_iterable_of_type[T](source: object, kind: type[T] = object) -> TypeIs[Iterable[T]]:
    return seems_iterable(source) and all(isinstance(unit, kind) for unit in source)

Замечание о поведении во время выполнения

Помимо типизации, помните, что некоторые итерируемые объекты — генераторы или файловые дескрипторы — одноразовые. Проверка all(isinstance(...)) их «съедает», поэтому последующий join увидит пустой поток. Это оговорка уровня исполнения, не связанная с типами, но важная, если вы проверяете и затем повторно итерируете один и тот же объект.

Зачем это важно

Сужение типов в Python держится на шаблонах, которые проверяющий умеет распознавать. Простые защиты вроде isinstance(var, SomeType) понятны; генераторные выражения внутри all(...) — нет. Ожидание, что all(...) уточнит типы, приводит к хрупким предположениям и утечке Unknown в код. Небольшой явный гард на базе TypeIs чётко выражает намерение, точно сужает там, где нужно, и делает строгий режим полезным, а не шумным.

Итоги

Не рассчитывайте на all(isinstance(...)) для уточнения типов элементов Iterable в строгом режиме Pyright. Для сужения это не поддерживается, а «ручная» итерация ничего не меняет. Добавьте простой гард, утверждающий «итерируемое из T», или сделайте приведение типов после ваших рантайм‑проверок — если так удобнее. Следите за одноразовыми итерируемыми: если вы и проверяете, и потребляете их, это важно. С этими поправками вы сохраните требуемый интерфейс __contains__ с параметром типа object и при этом получите чистые, точные типы внутри метода.