2025, Oct 05 05:17

Как заставить get_type_hints видеть аннотации с импортами под TYPE_CHECKING без изменения исходного кода

Почему get_type_hints в Python рушится при аннотациях с импортами под TYPE_CHECKING и как исправить: загрузить импорты через libcst и передать в globalns.

Построение схем во время выполнения на основе аннотаций типов в Python обычно проходит без проблем — пока не встречаются аннотации, закрытые условием TYPE_CHECKING. В этот момент интроспекция с помощью get_type_hints начинает сбоить: имена, на которые вы рассчитываете, попросту отсутствуют на рантайме. Ниже — практический разбор того, как возникает ошибка, и конкретный способ «научить» get_type_hints видеть такие аннотации, не меняя исходный код.

Problem overview

У вас есть класс в отдельном файле. Его методы аннотированы типами, которые импортируются только внутри блока if TYPE_CHECKING. Далее вы динамически загружаете этот файл и пытаетесь извлечь подсказки типов из функций. Вызов get_type_hints падает, потому что имена, использованные в аннотациях, недоступны во время выполнения.

Minimal failing example

Класс находится в отдельном модуле и опирается на импорт, существующий исключительно для статической проверки типов:

from typing import TYPE_CHECKING

if TYPE_CHECKING:
    import qxx

class Gadget:
    def do_work(self) -> qxx: ...

В другом месте вы импортируете модуль, находите функции и пытаетесь разрешить их аннотации:

from importlib import import_module
import inspect
from typing import get_type_hints

mod_obj = import_module("gadget_module")
for fn in inspect.getmembers(Gadget, predicate=inspect.isfunction).values():
    get_type_hints(fn, globalns=mod_obj.__dict__)

Это завершается ошибкой, потому что qxx не определён. Импорт, защищённый TYPE_CHECKING, никогда не выполняется на рантайме, поэтому get_type_hints не может разрешить это имя. Как уже отмечалось, get_type_hints пытается разворачивать ссылки вперёд (forward references) и выбрасывает исключение, если не может, что здесь и происходит.

Why this happens

TYPE_CHECKING «запирает» импорты так, чтобы их видели только средства статической проверки, но не рантайм. Когда get_type_hints оценивает аннотации, ему нужны все используемые в них имена в переданных globalns или localns. Поскольку qxx никогда не импортируется во время выполнения, символ отсутствует и разрешение срывается. Это напрямую связано с тем, что get_type_hints жадно вычисляет выражения в аннотациях, включая ссылки вперёд.

A practical fix: pre-load TYPE_CHECKING-only imports and pass them to get_type_hints

Надёжный путь — разобрать целевой файл, найти импорты внутри if TYPE_CHECKING, загрузить соответствующие модули или объекты вручную и передать получившееся отображение в get_type_hints через параметр globalns. Так вы не трогаете исходники и снабжаете get_type_hints всем, что нужно для разрешения.

Подход ниже использует libcst, чтобы отыскать импорты в блоке TYPE_CHECKING и собрать словарь, отображающий псевдонимы в соответствующие объекты или модули.

import importlib
from pathlib import Path
from typing import Any
import libcst as cst

def collect_typing_guarded_imports(file_loc: str) -> dict[str, Any]:
    """
    Locate all imports under an `if TYPE_CHECKING:` block in the given file
    and import them so they can be injected into get_type_hints.
    """

    class ImportAccumulator(cst.CSTVisitor):
        def __init__(self) -> None:
            self.bucket: list[cst.Import | cst.ImportFrom] = []

        def visit_Import(self, node: cst.Import) -> None:
            self.bucket.append(node)

        def visit_ImportFrom(self, node: cst.ImportFrom) -> None:
            self.bucket.append(node)

    class TCImportsScanner(cst.CSTVisitor):
        def __init__(self) -> None:
            self.matches: list[cst.Import | cst.ImportFrom] = []

        def visit_If(self, node: cst.If) -> bool:
            if isinstance(node.test, cst.Name) and node.test.value == "TYPE_CHECKING":
                acc = ImportAccumulator()
                node.body.visit(acc)
                self.matches.extend(acc.bucket)
                return False
            return True

    unit = cst.parse_module(Path(file_loc).read_text(encoding="utf8"))
    scout = TCImportsScanner()
    unit.visit(scout)

    resolved: dict[str, Any] = {}
    for item in scout.matches:
        if isinstance(item, cst.Import):
            # импорт pandas как pd
            for alias in item.names:
                mod_name = unit.code_for_node(alias.name)
                imported = importlib.import_module(mod_name)
                alias_name = (
                    unit.code_for_node(alias.asname.name)
                    if alias.asname
                    else mod_name
                )
                resolved[alias_name] = imported
        else:
            # из dataclasses импортировать dataclass как dc
            pkg_name = unit.code_for_node(item.module)
            imported_pkg = importlib.import_module(pkg_name)
            for alias in item.names:
                origin = unit.code_for_node(alias.name)
                alias_name = (
                    unit.code_for_node(alias.asname.name)
                    if alias.asname
                    else origin
                )
                resolved[alias_name] = getattr(imported_pkg, origin)

    return resolved

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

from importlib import import_module
import inspect
from typing import get_type_hints

# Загрузите модуль, в котором определён класс с импортами под TYPE_CHECKING
mod_obj = import_module("gadget_module")

# Получите дополнительные имена, которые присутствуют в этом файле только под TYPE_CHECKING
extra_ns = collect_typing_guarded_imports(mod_obj.__file__)

# Объедините пространства имён и успешно разрешите аннотации
merged_ns = {**mod_obj.__dict__, **extra_ns}
for fn in inspect.getmembers(Gadget, predicate=inspect.isfunction).values():
    hints = get_type_hints(fn, globalns=merged_ns)
    # Используйте `hints` для построения схемы

Why this matters

Когда генерация схем или любая другая рантайм-интроспекция завязана на аннотации, отсутствие импорта под TYPE_CHECKING может развалить всю цепочку. Явное обеспечение контекста разрешения делает ваш инструмент устойчивым. Вы признаёте, что аннотации могут ссылаться на имена, недоступные во время выполнения, и при этом доводите процесс до конца без изменений в исходном коде. Как справедливо замечено, get_type_hints пытается разрешать ссылки вперёд и бросает исключение, если не может, поэтому предварительное заполнение контекста критически важно. Обсуждаются также PEP 563 и 749, но описанный подход фокусируется на том, чтобы текущее разрешение успешно сработало с имеющимся кодом.

Takeaways

Если нужно разрешать аннотации, зависящие от импортов, доступных только под TYPE_CHECKING, сначала программно извлеките и загрузите эти импорты, а затем передайте их в get_type_hints через globalns. Эта небольшая доработка делает генерацию схем надёжной, не затрагивая анализируемый модуль. Сохраняйте чёткое разделение между исходниками и инструментами: пусть исходный код оставляет TYPE_CHECKING-блоки как есть, а инструменты перекрывают разрыв на рантайме.

Статья основана на вопросе на StackOverflow от XXXHHHH и ответе от XXXHHHH.