2025, Oct 17 01:16
assert_never и Enum в mypy: баг сравнения через экземпляр
Разбираем баг mypy: assert_never считается достижимым при сравнении Enum через экземпляр. Даем воспроизведение, рабочие обходы и статус фикса в 1.18.
Полное разбор веток для значений Python Enum — распространённый способ держать бизнес‑логику чёткой и безопасной. Если проверяющий типов понимает, что учтены все варианты, финальная ветка с assert_never должна быть недостижима. Но иногда статический анализ спотыкается в неочевидных местах — особенно там, где доступ к членам Enum идёт через экземпляр, а не через сам класс.
Как воспроизвести проблему
Ниже — минимальный пример. Логика сравнивает экземпляр Enum с его же членами, полученными через этот экземпляр. В финальном else используется assert_never для проверки полноты, но MyPy считает его достижимым.
from enum import Enum
from typing import assert_never
class WeekNum(Enum):
    MONDAY = 1
    TUESDAY = 2
    WEDNESDAY = 3
chosen = WeekNum(1)  # упрощённая версия более крупного кода
if chosen == chosen.MONDAY:
    print("MONDAY")
elif chosen == chosen.TUESDAY:
    print("TUESDAY")
elif chosen == chosen.WEDNESDAY:
    print("WEDNESDAY")
else:
    assert_never(chosen)
    # MyPy считает эту точку достижимой, тогда как другой линтер видит «Never»
Что на самом деле происходит
Дело не в семантике Enum. Это баг MyPy, который влияет на сужение типов, когда к членам Enum обращаются через экземпляр, а не через сам класс. В таком виде MyPy не делает вывода, что ветка else невозможна, хотя эквивалентный код с прямыми обращениями к классу Enum распознаётся корректно. Поэтому переход на прямые ссылки на класс сразу заставляет тот же код пройти проверку исчерпываемости.
Рабочие обходные варианты до исправления
Сравнивайте напрямую с членами класса Enum. Такой вариант MyPy считает полностью исчерпывающим, а assert_never — недостижимым.
from enum import Enum
from typing import assert_never
class WeekNum(Enum):
    MONDAY = 1
    TUESDAY = 2
    WEDNESDAY = 3
picked = WeekNum(1)
if picked == WeekNum.MONDAY:
    print("MONDAY")
elif picked == WeekNum.TUESDAY:
    print("TUESDAY")
elif picked == WeekNum.WEDNESDAY:
    print("WEDNESDAY")
else:
    assert_never(picked)  # MyPy показывает «Never»
Однако прямые ссылки на класс Enum могут вызвать проблемы в IPython с Autoreload: после перезагрузки класс Enum может уже не совпадать по типу с имеющимся в рантайме экземпляром. Прагматичный приём, который избегает такой связности, — вывести тип Enum из самого экземпляра. На практике это тоже устраивает MyPy в части исчерпывающей проверки и при этом устойчиво к поведению Autoreload.
from enum import Enum
from typing import assert_never
class WeekNum(Enum):
    MONDAY = 1
    TUESDAY = 2
    WEDNESDAY = 3
current = WeekNum(1)
enum_cls = type(current)
if current == enum_cls.MONDAY:
    print("MONDAY")
elif current == enum_cls.TUESDAY:
    print("TUESDAY")
elif current == enum_cls.WEDNESDAY:
    print("WEDNESDAY")
else:
    assert_never(current)  # MyPy показывает «Never» и это работает с IPython Autoreload
Статус апстрима
Это баг mypy. Я исправил его в #19422 несколько дней назад, ваш исходный сниппет работает на mypy master (скорее всего выйдет в 1.18.0).
Иными словами, когда исправление станет доступно, изначальный шаблон с обращением к членам через экземпляр должен проходить проверку типов без дополнительных изменений.
Почему это важно
assert_never — это страховка для исчерпывающих ветвлений по дискриминирующим объединениям и типам Enum. Если проверяющий типов помечает её как достижимую по ошибке, это подрывает доверие к статическому анализу и вынуждает к лишним рефакторингам или хрупким обходам. В итеративных сценариях с IPython Autoreload отказ от прямых ссылок на перезагружаемые классы также помогает избежать путаницы между рантаймом и проверкой типов. Понимание, что в данном случае сработал конкретный сбой в инструментах, позволяет сосредоточиться на предметной логике, а не плясать вокруг ложных срабатываний.
Вывод
Если вы столкнулись с предупреждением о достижимости assert_never при сравнении экземпляра Enum с его же членами, причина — описанный выше баг MyPy. В качестве немедленных вариантов либо сравнивайте с классом Enum напрямую, либо получайте тип Enum из экземпляра через type(...) — так код остаётся дружелюбным к IPython Autoreload. Как только исправление из PR #19422 попадёт в релиз, исходный, лаконичный вариант с членами через экземпляр будет работать как задумано. Сохраняйте проверки на исчерпываемость — они окупаются, находя реальные пробелы и удерживая высокий сигнал/шум в вашем пайплайне проверки типов.
Статья основана на вопросе на StackOverflow от Rylix и ответе STerliakov.