blob: 88bc215e4c29efcb7d2c29c41dd9899af3ddfd5c [file]
# Licensed to the Apache Software Foundation (ASF) under one
# or more contributor license agreements. See the NOTICE file
# distributed with this work for additional information
# regarding copyright ownership. The ASF licenses this file
# to you under the Apache License, Version 2.0 (the
# "License"); you may not use this file except in compliance
# with the License. You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing,
# software distributed under the License is distributed on an
# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
# KIND, either express or implied. See the License for the
# specific language governing permissions and limitations
# under the License.
"""Forward-annotation resolution for ``@py_class`` registration.
``@py_class`` registers a Python type before it resolves field annotations, so
self-referential and mutually recursive annotations can refer to classes that
are already present in the FFI type registry. This module owns that early
registration step, the per-module local namespace used by
``typing.get_type_hints``, and the queue of classes whose annotations must be
retried after later definitions become available.
When a class is ready to finalize, this module lazily imports
``py_class.on_fields_resolved``. Keeping the finalizer import local preserves
the boundary between annotation resolution and field registration while avoiding
a top-level import cycle.
"""
from __future__ import annotations
import sys
import typing
from collections.abc import Callable
from dataclasses import dataclass
from typing import TYPE_CHECKING, Any
from .. import core
from .field import KW_ONLY
if TYPE_CHECKING:
from ..core import TypeInfo
else:
TypeInfo = Any
ResolvedFields = tuple[list[type], dict[type, dict[str, Any]]]
# ---------------------------------------------------------------------------
# Module-level state
# ---------------------------------------------------------------------------
#
# ``@py_class`` registration happens in two phases:
#
# Phase 1 (register_type_without_fields)
# Allocates a C-level type index and inserts the class into the
# global type registry. This must happen early so that self-
# referential and mutually-referential annotations can resolve
# the class via ``TypeSchema.from_annotation()``. Phase 1 always
# succeeds or raises immediately for invalid parents. Decorator
# options are stored by py_class.py on ``TypeInfo._decorator_args``.
#
# Phase 2 (py_class.on_fields_resolved)
# Resolves string annotations via ``typing.get_type_hints``,
# returns per-owner resolved hints, and asks py_class.py to
# materialize ``Field`` objects, register fields/methods/type
# attributes with the Cython layer, and install dataclass dunders.
#
# If ``get_type_hints`` raises ``NameError`` (forward reference
# not yet defined), the class is added to ``_PENDING_CLASSES``
# and retried after each successful phase-2. If phase-2 fails
# for any other reason, ``rollback_registration`` undoes phase-1
# so the type key can be reused.
# ---------------------------------------------------------------------------
@dataclass
class _PendingClass:
"""Resolution state for a class waiting on unresolved annotations.
Only data needed to retry annotation resolution is stored here. Decorator
options live on ``type_info._decorator_args`` so deferred and immediate
finalization use the same source of truth.
"""
cls: type
type_info: TypeInfo
globalns: dict[str, Any]
#: Classes whose phase-2 (field registration) was deferred because
#: ``typing.get_type_hints`` raised ``NameError`` on an unresolved
#: forward reference. Retried after each successful phase-2 via
#: :func:`flush_pending`.
_PENDING_CLASSES: list[_PendingClass] = []
#: Per-module mapping of ``class.__name__ → class`` for every
#: ``@py_class``-decorated type. Used as *localns* when resolving
#: annotations so that mutual references between classes in the same
#: module work even before the second class is assigned to the module
#: variable by Python.
_PY_CLASS_BY_MODULE: dict[str, dict[str, type]] = {}
class _KWOnlyAnnotation:
"""Type-valued stand-in accepted by ``typing.get_type_hints``."""
def _is_kw_only_annotation(annotation: Any) -> bool:
if annotation is KW_ONLY or annotation is _KWOnlyAnnotation:
return True
if isinstance(annotation, str):
return annotation == "KW_ONLY" or annotation.endswith(".KW_ONLY")
forward_arg = getattr(annotation, "__forward_arg__", None)
if isinstance(forward_arg, str):
return forward_arg == "KW_ONLY" or forward_arg.endswith(".KW_ONLY")
return False
# ---------------------------------------------------------------------------
# Phase 1: type registration
# ---------------------------------------------------------------------------
def _registered_type_info(cls: type) -> TypeInfo | None:
"""Return the TypeInfo registered directly for *cls*, not inherited metadata."""
info = core._type_cls_to_type_info(cls)
if info is not None:
return info
return cls.__dict__.get("__tvm_ffi_type_info__", None)
def register_type_without_fields(cls: type, type_key: str | None) -> TypeInfo:
"""Register ``cls`` in the FFI type registry before resolving fields.
The returned :class:`~tvm_ffi.core.TypeInfo` has a type index, type key, and
Python class binding, but no field metadata yet. This early registration is
what lets ``TypeSchema.from_annotation`` resolve references to ``cls`` while
the decorator is still running.
The class is also inserted into the module-local annotation namespace used
by :func:`resolve_type_hints_by_owner`, allowing sibling ``@py_class``
declarations in the same module to reference each other before the second
class has been assigned to the module global by Python.
"""
parent_info = next(
(info for base in cls.__mro__[1:] if (info := _registered_type_info(base)) is not None),
None,
)
if parent_info is None:
raise TypeError(
f"{cls.__name__} must inherit from a registered FFI Object type (e.g. tvm_ffi.Object)"
)
if type_key is None:
type_key = f"{cls.__module__}.{cls.__qualname__}"
info = core._register_py_class(parent_info, type_key, cls)
setattr(cls, "__tvm_ffi_type_info__", info)
# Register in resolution namespace so sibling classes can find us.
_PY_CLASS_BY_MODULE.setdefault(cls.__module__, {})[cls.__name__] = cls
return info
def rollback_registration(cls: type, type_info: TypeInfo) -> None:
"""Undo Python-side state from :func:`register_type_without_fields`.
The C-level type index is permanently consumed (cannot be reclaimed),
but the Python-level registry dicts are cleaned up so a retry with
the same type key does not hit "already registered". The dataclass marker
is also removed because failed phase-2 finalization means the class is not a
usable FFI dataclass.
"""
# Remove from the Cython-level registry dicts (TYPE_KEY_TO_INFO,
# TYPE_CLS_TO_INFO, TYPE_INDEX_TO_INFO, TYPE_INDEX_TO_CLS).
core._rollback_py_class(type_info) # ty: ignore[unresolved-attribute]
# Remove from our own module-level resolution namespace.
_PY_CLASS_BY_MODULE.get(cls.__module__, {}).pop(cls.__name__, None)
for attr in ("__tvm_ffi_type_info__", "__ffi_is_dataclass__"):
try:
delattr(cls, attr)
except AttributeError:
pass
# ---------------------------------------------------------------------------
# Annotation resolution
# ---------------------------------------------------------------------------
def own_annotations(cls: type) -> dict[str, Any]:
"""Return annotations declared directly on ``cls`` without MRO merging."""
# Python 3.14+ (PEP 749): annotations are lazily evaluated via
# __annotate__ and no longer stored directly in __dict__. getattr()
# triggers evaluation and returns per-class annotations correctly.
# On Python < 3.14, getattr() follows MRO and returns *parent*
# annotations when the child has none — use __dict__ to avoid that.
if sys.version_info >= (3, 14):
return getattr(cls, "__annotations__", {})
return cls.__dict__.get("__annotations__", {})
def _field_owner_classes(cls: type) -> list[type]:
"""Return local MRO entries whose annotations become fields on ``cls``.
A Python subclass of a registered FFI parent may include unregistered mixin
bases before the registered parent. Those mixin annotations are owned by
the new Python type and should be registered together with ``cls``. Fields
already represented by the nearest registered parent are excluded.
"""
registered_parent = next(
(b for b in cls.__mro__[1:] if _registered_type_info(b) is not None), object
)
represented = set(registered_parent.__mro__)
return [
b
for b in reversed(cls.__mro__)
if b is not object and b not in represented and own_annotations(b)
]
def _build_localns(cls: type, *, cross_module: bool = False) -> dict[str, Any]:
"""Build the localns dict for resolving ``cls``'s annotations.
By default, includes only classes from ``cls.__module__``, preserving
standard Python name resolution semantics. When ``cross_module=True``,
also includes classes from all other registered modules as a fallback
— this is needed when ``cls`` has a forward reference to a class in
another module that can't appear in ``cls.__module__``'s globals due
to a circular import (e.g. the target is imported only under
``if TYPE_CHECKING:``).
Cross-module entries are added with ``setdefault`` so same-module
classes and the class itself always take precedence over foreign
classes with the same ``__name__``.
"""
localns = dict(_PY_CLASS_BY_MODULE.get(cls.__module__, {}))
localns[cls.__name__] = cls
if cross_module:
for mod_name, mod_classes in list(_PY_CLASS_BY_MODULE.items()):
if mod_name == cls.__module__:
continue
for name, klass in mod_classes.items():
localns.setdefault(name, klass)
return localns
def _resolve_own_type_hints(
owner: type,
globalns: dict[str, Any],
localns: dict[str, Any],
) -> dict[str, Any]:
"""Resolve only annotations declared directly on ``owner``.
``typing.get_type_hints(cls)`` merges annotations across the full MRO.
That is wrong for py_class phase 2 because inherited C++/c_class fields
are already registered by the parent type. Resolving only the owner
annotations also avoids evaluating parent annotations in the child
module's namespace.
Python 3.10's ``typing.get_type_hints`` rejects ``dataclasses.KW_ONLY``
because it is a singleton, not a type. Replace it in the temporary shim
annotations, then restore it in the resolved hints.
"""
annotations = own_annotations(owner)
if not annotations:
return {}
shim_annotations = dict(annotations)
kw_only_names: list[str] = []
for name, annotation in annotations.items():
if _is_kw_only_annotation(annotation):
shim_annotations[name] = _KWOnlyAnnotation
kw_only_names.append(name)
shim = type(
f"_{owner.__name__}OwnAnnotations",
(),
{"__annotations__": shim_annotations, "__module__": owner.__module__},
)
kwargs: dict[str, Any] = {"globalns": globalns, "localns": localns}
if sys.version_info >= (3, 11):
kwargs["include_extras"] = True
hints = typing.get_type_hints(shim, **kwargs)
for name in kw_only_names:
hints[name] = KW_ONLY
return hints
def resolve_type_hints_by_owner(
cls: type,
globalns: dict[str, Any],
) -> ResolvedFields | None:
"""Resolve field annotations grouped by owner class.
Returns ``(owners, hints_by_owner)`` when every annotation can be resolved.
Returns :data:`None` when a forward reference is still unavailable, signaling
that the caller should defer field registration and retry later.
"""
# Resolve string annotations to types; return None (defer) on NameError.
#
# First try with module-scoped localns (standard Python name resolution).
# On NameError, retry with a cross-module localns that includes classes
# from every registered module — this handles circular imports where the
# target of a forward reference is imported only under TYPE_CHECKING and
# therefore never enters the declaring module's globals.
owners = _field_owner_classes(cls)
localns = _build_localns(cls)
localns.update({owner.__name__: owner for owner in owners})
try:
hints_by_owner = {
owner: _resolve_own_type_hints(
owner,
getattr(sys.modules.get(owner.__module__, None), "__dict__", globalns),
localns,
)
for owner in owners
}
except (NameError, AttributeError):
localns = _build_localns(cls, cross_module=True)
localns.update({owner.__name__: owner for owner in owners})
try:
hints_by_owner = {
owner: _resolve_own_type_hints(
owner,
getattr(sys.modules.get(owner.__module__, None), "__dict__", globalns),
localns,
)
for owner in owners
}
except (NameError, AttributeError):
return None
return owners, hints_by_owner
# ---------------------------------------------------------------------------
# Deferred resolution
# ---------------------------------------------------------------------------
def flush_pending() -> None:
"""Retry deferred classes until no additional annotations resolve.
A successful finalization can make another pending class resolvable, so this
function runs to a fixed point. The field-registration finalizer is imported
lazily to keep this module focused on resolution and to avoid an import
cycle with :mod:`tvm_ffi.dataclasses.py_class`.
"""
from .py_class import on_fields_resolved # noqa: PLC0415
changed = True
while changed:
changed = False
remaining: list[_PendingClass] = []
for entry in _PENDING_CLASSES:
resolved = resolve_type_hints_by_owner(entry.cls, entry.globalns)
if resolved is None:
remaining.append(entry)
else:
on_fields_resolved(entry.type_info, resolved)
changed = True
_PENDING_CLASSES[:] = remaining
def _raise_unresolved_forward_reference(cls: type, globalns: dict[str, Any]) -> None:
"""Raise :class:`TypeError` listing the annotations that cannot be resolved."""
localns = _build_localns(cls, cross_module=True)
owners = _field_owner_classes(cls)
localns.update({owner.__name__: owner for owner in owners})
unresolved: list[str] = []
for owner in owners:
for name, ann_str in own_annotations(owner).items():
if isinstance(ann_str, str):
try:
eval(ann_str, globalns, localns) # pylint: disable=eval-used
except Exception as err:
unresolved.append(f"{name}: {ann_str} ({err})")
raise TypeError(
f"Cannot instantiate {cls.__name__}: unresolved forward references: {unresolved}"
)
def _remove_from_pending(cls: type) -> None:
_PENDING_CLASSES[:] = [p for p in _PENDING_CLASSES if p.cls is not cls]
def _make_temporary_init(
cls: type,
type_info: TypeInfo,
globalns: dict[str, Any],
) -> Callable[..., None]:
"""Build the ``__init__`` shim used while field registration is deferred.
The shim resolves annotations on first construction, invokes the normal
finalization path, removes the class from the pending queue, and then
dispatches to the real ``__init__`` that finalization installed or restored.
If finalization fails, it rolls back phase-1 registration so the type key can
be reused after the user fixes the annotation error.
"""
def __init__(self: Any, *args: Any, **kwargs: Any) -> None:
if type_info.fields is None:
try:
from .py_class import on_fields_resolved # noqa: PLC0415
resolved = resolve_type_hints_by_owner(cls, globalns)
if resolved is None:
_raise_unresolved_forward_reference(cls, globalns)
on_fields_resolved(type_info, resolved)
# cls stays in _PENDING_CLASSES after phase-2 succeeds; drop it
# before flush_pending so the loop doesn't hit the Cython-level
# "_register_fields already called" assertion on a second pass.
_remove_from_pending(cls)
flush_pending()
except Exception:
# Remove from pending list and roll back so the type key can be reused.
_remove_from_pending(cls)
rollback_registration(cls, type_info)
raise
# cls.__init__ has been replaced by the real init (or restored user init).
cls.__init__(self, *args, **kwargs)
__init__.__qualname__ = f"{cls.__qualname__}.__init__"
__init__.__module__ = cls.__module__
return __init__
def _install_deferred_init(
cls: type,
type_info: TypeInfo,
globalns: dict[str, Any],
) -> None:
"""Install a temporary ``__init__`` that completes registration on first call.
Preserves a user-defined ``__init__`` if present in *cls.__dict__*;
it is restored by the phase-2 callback after registration completes
so that ``_install_dataclass_dunders`` sees it and skips auto-generation.
"""
# Save user-defined __init__ before overwriting.
user_init = cls.__dict__.get("__init__")
if user_init is not None:
cls._py_class_user_init = user_init # type: ignore[attr-defined]
cls.__init__ = _make_temporary_init( # type: ignore[assignment]
cls,
type_info,
globalns,
)
cls.__ffi_py_class_is_deferred_init__ = True # type: ignore[attr-defined]
def defer_field_registration(
cls: type,
type_info: TypeInfo,
globalns: dict[str, Any],
) -> None:
"""Record unresolved field registration and install first-use finalization.
Deferred entries keep only the class, ``TypeInfo``, and globals needed to
retry annotation resolution. Decorator parameters are read later from
``type_info._decorator_args`` by ``py_class.on_fields_resolved``.
"""
_PENDING_CLASSES.append(_PendingClass(cls, type_info, globalns))
_install_deferred_init(cls, type_info, globalns)