junrushao commented on code in PR #663: URL: https://github.com/apache/tvm-ffi/pull/663#discussion_r3576146273
########## python/tvm_ffi/dataclasses/_resolve_fields.py: ########## @@ -0,0 +1,462 @@ +# 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 Review Comment: it's too defensive -- This is an automated message from the Apache Git Service. To respond to the message, please log on to GitHub and use the URL above to go to the specific comment. To unsubscribe, e-mail: [email protected] For queries about this service, please contact Infrastructure at: [email protected] --------------------------------------------------------------------- To unsubscribe, e-mail: [email protected] For additional commands, e-mail: [email protected]
