This is an automated email from the ASF dual-hosted git repository.
tqchen pushed a commit to branch main
in repository https://gitbox.apache.org/repos/asf/tvm-ffi.git
The following commit(s) were added to refs/heads/main by this push:
new 491f1682 [FEAT] Add object construction and dataclass extension hooks
(#808)
491f1682 is described below
commit 491f16820f560b1f0e7193b13e480c8848e61467
Author: Tianqi Chen <[email protected]>
AuthorDate: Mon Sep 21 14:02:16 2026 -0400
[FEAT] Add object construction and dataclass extension hooks (#808)
Add reusable object construction helpers and class/field extension
points to TVM FFI.
- Support field-based reconstruction and checked named tuples using FFI
type conversion.
- Run callbacks after field resolution, with isolated decorator state
and deferred parent resolution.
- Register additional type attributes through `__ffi_type_attr_names__`
and select parent values with `__ffi_inherit_type_attrs__`.
- Expose opaque field metadata, callback-consumed keyword options, and
annotation positions through `__ffi_field_markers__`.
- Allow fields named `self` and propagate invalid field/default
conversions through construction and deserialization.
Validation: 1,421 Python tests passed; C++ CTest suite and changed-file
formatting/lint checks passed.
---
include/tvm/ffi/reflection/accessor.h | 29 +-
include/tvm/ffi/reflection/creator.h | 6 +-
python/tvm_ffi/_dunder.py | 76 ++++-
python/tvm_ffi/dataclasses/_resolve_fields.py | 25 +-
python/tvm_ffi/dataclasses/common.py | 50 +++-
python/tvm_ffi/dataclasses/field.py | 47 +++-
python/tvm_ffi/dataclasses/py_class.py | 165 +++++++++--
python/tvm_ffi/registry.py | 2 +-
src/ffi/extra/dataclass.cc | 46 ++-
src/ffi/extra/reflection_extra.cc | 6 +-
src/ffi/extra/serialization.cc | 7 +-
tests/cpp/test_reflection.cc | 40 +++
tests/python/test_dataclass_init.py | 27 +-
tests/python/test_dataclass_namedtuple.py | 270 ++++++++++++++++++
tests/python/test_dataclass_py_class.py | 390 +++++++++++++++++++++++++-
15 files changed, 1110 insertions(+), 76 deletions(-)
diff --git a/include/tvm/ffi/reflection/accessor.h
b/include/tvm/ffi/reflection/accessor.h
index 94249176..08aa021b 100644
--- a/include/tvm/ffi/reflection/accessor.h
+++ b/include/tvm/ffi/reflection/accessor.h
@@ -230,25 +230,44 @@ inline Function GetMethod(std::string_view type_key,
const char* method_name) {
}
/*!
- * \brief Set a field to its default value, calling the factory if applicable.
+ * \brief Set a field to its default value, returning the setter's status code.
*
* When kTVMFFIFieldFlagBitMaskDefaultFromFactory is set, extracts the
* Function from default_value_or_factory, calls it with no arguments,
* and uses the result. Otherwise, passes default_value_or_factory directly
* to the setter.
*
+ * A default is converted by the same setter as an explicitly passed value, so
+ * a default whose type does not match the field fails here exactly as that
+ * value would. Callers must not drop the status: the setter reports failure
+ * through the safe-call slot rather than by throwing, so ignoring it leaves
+ * the field at its zero-initialized value -- a silently null object field --
+ * and strands the raised error for an unrelated call to pick up.
+ *
* \param field_info The field info (must have
kTVMFFIFieldFlagBitMaskHasDefault set).
* \param field_addr The address of the field in the object.
+ *
+ * \return 0 on success, non-zero if the setter rejected the default value.
*/
-inline void SetFieldToDefault(const TVMFFIFieldInfo* field_info, void*
field_addr) {
+inline int CallFieldSetterToDefault(const TVMFFIFieldInfo* field_info, void*
field_addr) {
if (field_info->flags & kTVMFFIFieldFlagBitMaskDefaultFromFactory) {
Function factory =
AnyView::CopyFromTVMFFIAny(field_info->default_value_or_factory).cast<Function>();
Any default_val = factory();
- CallFieldSetter(field_info, field_addr, reinterpret_cast<const
TVMFFIAny*>(&default_val));
- } else {
- CallFieldSetter(field_info, field_addr,
&(field_info->default_value_or_factory));
+ return CallFieldSetter(field_info, field_addr,
+ reinterpret_cast<const TVMFFIAny*>(&default_val));
}
+ return CallFieldSetter(field_info, field_addr,
&(field_info->default_value_or_factory));
+}
+
+/*!
+ * \brief Set a field to its default value, throwing if the default is
rejected.
+ *
+ * \param field_info The field info (must have
kTVMFFIFieldFlagBitMaskHasDefault set).
+ * \param field_addr The address of the field in the object.
+ */
+inline void SetFieldToDefault(const TVMFFIFieldInfo* field_info, void*
field_addr) {
+ TVM_FFI_CHECK_SAFE_CALL(CallFieldSetterToDefault(field_info, field_addr));
}
/*!
diff --git a/include/tvm/ffi/reflection/creator.h
b/include/tvm/ffi/reflection/creator.h
index 8aa35de4..588a035a 100644
--- a/include/tvm/ffi/reflection/creator.h
+++ b/include/tvm/ffi/reflection/creator.h
@@ -151,7 +151,11 @@ class ObjectCreator {
void* field_addr = reinterpret_cast<char*>(ptr.get()) +
field_info->offset;
if (fields.count(field_name) != 0) {
Any field_value = fields[field_name];
- CallFieldSetter(field_info, field_addr, reinterpret_cast<const
TVMFFIAny*>(&field_value));
+ // The setter reports a type mismatch through the safe-call slot, not
+ // by throwing; dropping the status would leave the field at its
+ // zero-initialized value and strand the raised error.
+ TVM_FFI_CHECK_SAFE_CALL(CallFieldSetter(field_info, field_addr,
+ reinterpret_cast<const
TVMFFIAny*>(&field_value)));
++match_field_count;
} else if (field_info->flags & kTVMFFIFieldFlagBitMaskHasDefault) {
SetFieldToDefault(field_info, field_addr);
diff --git a/python/tvm_ffi/_dunder.py b/python/tvm_ffi/_dunder.py
index 77129628..f96d7f94 100644
--- a/python/tvm_ffi/_dunder.py
+++ b/python/tvm_ffi/_dunder.py
@@ -22,7 +22,7 @@ import inspect
from typing import TYPE_CHECKING, Any, Callable
from . import core
-from .core import TypeInfo, object_repr
+from .core import Object, TypeInfo, object_repr
if TYPE_CHECKING:
from .core import Function
@@ -124,6 +124,80 @@ def _make_init(
return __init__
+def _make_constructor(
+ cls: type[Object],
+ *,
+ positional_fields: list[str] | None = None,
+ field_normalizers: dict[str, Callable[[Any], Any]] | None = None,
+) -> Callable[..., Object]:
+ """Restore registered fields without replaying Python initialization hooks.
+
+ By default every field accepts positional arguments in schema order. When
+ supplied, ``positional_fields`` gives the positional argument order and
+ makes every remaining field keyword-only. ``field_normalizers`` restores
+ values whose text representation needs conversion before FFI
initialization.
+ """
+ from . import dataclasses as dc # noqa: PLC0415
+
+ fields = dc.fields(cls)
+ _, native_fields = _init_normalization_fields(getattr(cls,
"__tvm_ffi_type_info__"))
+
+ # Keep real objects in globals; defaults never become source expressions.
+ env: dict[str, Any] = {}
+ names = {field.name for field in fields} | {"construct"}
+
+ def bind(name: str, value: Any) -> str:
+ while name in names:
+ name = "_" + name
+ names.add(name)
+ env[name] = value
+ return name
+
+ object_name = bind("__tvm_ffi_Object__", Object)
+ class_name = bind("__tvm_ffi_class__", cls)
+ missing_name = bind("__tvm_ffi_missing__", dc.MISSING)
+ result_name = bind("__tvm_ffi_result__", None)
+
+ # MISSING lets native initialization check required fields and run
factories.
+ parameters_by_name = {}
+ for field in fields:
+ default_name = bind(f"__tvm_ffi_default_{field.name}__", field.default)
+ parameters_by_name[field.name] = f"{field.name}={default_name}"
+ if positional_fields is None:
+ parameters = list(parameters_by_name.values())
+ else:
+ parameters = [parameters_by_name.pop(name) for name in
positional_fields]
+ if parameters_by_name:
+ parameters.extend(["*", *parameters_by_name.values()])
+
+ normalizers = dict(field_normalizers or {})
+ for native_field in native_fields.values():
+ if native_field._is_payload_enum_field():
+ normalizers[native_field.name] = native_field.normalize_value
+ body = []
+ for name, normalize in normalizers.items():
+ normalize_name = bind(f"__tvm_ffi_normalize_{name}__", normalize)
+ body.extend(
+ [
+ f" if {name} is not {missing_name}:",
+ f" {name} = {normalize_name}({name})",
+ ]
+ )
+ body.append(f" {result_name} = {object_name}.__new__({class_name})")
+ arguments = ", ".join(f"{field.name}={field.name}" for field in fields)
+ body.append(
+ f" {class_name}.__ffi_init__({result_name}{', ' if arguments else
''}{arguments})"
+ )
+ body.append(f" return {result_name}")
+ source = f"def construct({', '.join(parameters)}):\n" + "\n".join(body) +
"\n"
+ exec(compile(source, f"<field constructor {cls.__qualname__}>", "exec"),
env)
+ construct = env["construct"]
+ construct.__name__ = cls.__name__
+ construct.__qualname__ = cls.__qualname__
+ construct.__module__ = cls.__module__
+ return construct
+
+
def _collect_init_property_funcs(type_cls: type) -> list[tuple[str,
Callable[..., Any]]]:
"""Collect ``(name, func)`` pairs for all ``init_property`` fields in MRO
order.
diff --git a/python/tvm_ffi/dataclasses/_resolve_fields.py
b/python/tvm_ffi/dataclasses/_resolve_fields.py
index b15b4ac6..405cd3ec 100644
--- a/python/tvm_ffi/dataclasses/_resolve_fields.py
+++ b/python/tvm_ffi/dataclasses/_resolve_fields.py
@@ -293,7 +293,26 @@ def resolve_type_hints_by_owner(
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.
+
+ A parent can exist while its field types are unresolved. Finalize its
+ schema before the child computes its layout and inherited metadata.
+ Assigning a forward alias does not flush pending classes; the first
+ construction resolves the parent before resolving the child.
"""
+ # Field order and inherited boundaries require the parent's complete
schema,
+ # including when a forward alias becomes available only at first
construction.
+ info = _registered_type_info(cls)
+ if info is not None:
+ parent = info.parent_type_info
+ if parent is not None and parent.fields is None:
+ resolved_parent = resolve_type_hints_by_owner(parent.type_cls,
globalns)
+ if resolved_parent is None:
+ return None
+ from .py_class import on_fields_resolved # noqa: PLC0415
+
+ on_fields_resolved(parent, resolved_parent)
+ _remove_from_pending(parent.type_cls)
+
# Resolve string annotations to types; return None (defer) on NameError.
#
# First try with module-scoped localns (standard Python name resolution).
@@ -436,14 +455,14 @@ def _install_deferred_init(
# 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._py_class_user_init = user_init # ty: ignore[unresolved-attribute]
- cls.__init__ = _make_temporary_init( # type: ignore[assignment]
+ cls.__init__ = _make_temporary_init( # ty: ignore[invalid-assignment]
cls,
type_info,
globalns,
)
- cls.__ffi_py_class_is_deferred_init__ = True # type: ignore[attr-defined]
+ cls.__ffi_py_class_is_deferred_init__ = True # ty:
ignore[unresolved-attribute]
def defer_field_registration(
diff --git a/python/tvm_ffi/dataclasses/common.py
b/python/tvm_ffi/dataclasses/common.py
index f852114d..671f7672 100644
--- a/python/tvm_ffi/dataclasses/common.py
+++ b/python/tvm_ffi/dataclasses/common.py
@@ -19,10 +19,13 @@
from __future__ import annotations
import copy
-from collections.abc import Callable
-from typing import Any, TypeVar
+from collections import namedtuple
+from collections.abc import Callable, Iterable
+from functools import wraps
+from typing import Any, TypeVar, cast
from ..container import Array, Dict, List, Map
+from ..core import TypeSchema, _to_py_class_value
from .field import Field
__all__ = ["asdict", "astuple", "fields", "is_dataclass", "replace"]
@@ -93,6 +96,49 @@ def fields(obj_or_cls: Any) -> tuple[Field, ...]:
return tuple(out)
+def _make_namedtuple(typename: str, fields: tuple[Field, ...]) -> Any:
+ """Build a native named tuple with FFI conversion for each resolved Field.
+
+ All supplied fields are required, in input order, including ``init=False``
+ fields. Source defaults and initialization hooks do not participate.
Schemas
+ are cached in the constructor closure without changing the input metadata.
+
+ For example, ``Values = _make_namedtuple("Values", fields(Source))``
creates
+ a tuple class whose construction, ``_make``, and ``_replace`` convert
values
+ just as the source fields do, without constructing a ``Source`` instance.
+ """
+ # Field names and constructor signatures are supplied at runtime.
+ tuple_cls = cast(Any, namedtuple(typename, [f.name for f in fields]))
+ names = tuple_cls._fields
+ schemas = tuple(
+ f._ty_schema if f._ty_schema is not None else
TypeSchema.from_annotation(f.type)
+ for f in fields
+ )
+ tuple_cls.__annotations__ = {f.name: f.type for f in fields if f.type is
not None}
+ native_new = tuple_cls.__new__
+ native_make = tuple_cls._make.__func__
+
+ @wraps(native_new)
+ def checked_new(cls: type, /, *args: Any, **kwargs: Any) -> tuple[Any,
...]:
+ raw = native_new(cls, *args, **kwargs)
+ converted = []
+ for name, schema, value in zip(names, schemas, raw):
+ try:
+ converted.append(_to_py_class_value(schema.convert(value)))
+ except TypeError as exc:
+ raise TypeError(f"{typename}.{name}: {exc}") from exc
+ return native_new(cls, *converted)
+
+ @classmethod
+ def checked_make(cls: Any, iterable: Iterable[Any]) -> tuple[Any, ...]:
+ # Native _make bypasses __new__; reuse its length check before
conversion.
+ return cls(*native_make(cls, iterable))
+
+ tuple_cls.__new__ = staticmethod(checked_new)
+ tuple_cls._make = checked_make
+ return tuple_cls
+
+
def replace(obj: _T, /, **changes: Any) -> _T:
"""Return a copy of ``obj`` with selected fields replaced.
diff --git a/python/tvm_ffi/dataclasses/field.py
b/python/tvm_ffi/dataclasses/field.py
index cb6f99e9..51c90708 100644
--- a/python/tvm_ffi/dataclasses/field.py
+++ b/python/tvm_ffi/dataclasses/field.py
@@ -20,7 +20,7 @@ from __future__ import annotations
import importlib
import sys
-from collections.abc import Callable
+from collections.abc import Callable, Mapping
from typing import Any, ClassVar, Generic, TypeVar, overload
from ..core import MISSING, TypeSchema
@@ -144,6 +144,10 @@ class Field:
kw_only : bool | None
Whether this field is keyword-only in ``__init__``.
``None`` means "inherit from the decorator-level *kw_only* flag".
+ metadata : Mapping[str, Any] | None
+ Extension metadata, copied into a fresh mutable dictionary. Field
+ copies also receive a fresh dictionary; nested values are shared.
+ The runtime assigns no semantics to keys or values.
structural_eq : str | None
Structural equality/hashing annotation for this field. Valid
values are:
@@ -166,6 +170,9 @@ class Field:
converter : Callable[[Any], Any]
Static-analysis marker for field conversion. Runtime conversion is
still handled by the FFI type converter.
+ extra_kwargs
+ Extension options retained for field-resolution callbacks to consume.
+ ``@py_class`` rejects options left unconsumed after all callbacks.
"""
@@ -176,10 +183,12 @@ class Field:
"default",
"default_factory",
"doc",
+ "extra_kwargs",
"frozen",
"hash",
"init",
"kw_only",
+ "metadata",
"name",
"repr",
"structural_eq",
@@ -197,6 +206,8 @@ class Field:
compare: bool
converter: Callable[[Any], Any]
kw_only: bool | None
+ metadata: dict[str, Any]
+ extra_kwargs: dict[str, Any]
structural_eq: str | None
doc: str | None
@@ -215,16 +226,18 @@ class Field:
_ty_schema: TypeSchema | None = None,
*,
default: object = MISSING,
- default_factory: Callable[[], object] | None = MISSING, # type:
ignore[assignment]
+ default_factory: Callable[[], object] | None = MISSING, # ty:
ignore[invalid-parameter-default]
frozen: bool = False,
init: bool = True,
repr: bool = True,
hash: bool | None = True,
compare: bool = False,
kw_only: bool | None = False,
+ metadata: Mapping[str, Any] | None = None,
structural_eq: str | None = None,
doc: str | None = None,
converter: Callable[[Any], Any] = _field_converter,
+ **extra_kwargs: Any,
) -> None:
# MISSING means "parameter not provided".
# An explicit None from the user fails the callable() check,
@@ -254,23 +267,42 @@ class Field:
self.compare = compare
self.converter = converter
self.kw_only = kw_only
+ self.metadata = dict(metadata) if metadata is not None else {}
self.structural_eq = structural_eq
self.doc = doc
+ self.extra_kwargs = dict(extra_kwargs)
+
+ def __copy__(self) -> Field:
+ result = object.__new__(type(self))
+ if hasattr(self, "__dict__"):
+ result.__dict__.update(self.__dict__)
+ for cls in type(self).__mro__:
+ slots = cls.__dict__.get("__slots__", ())
+ if isinstance(slots, str):
+ slots = (slots,)
+ for name in slots:
+ if name not in {"__dict__", "__weakref__"} and hasattr(self,
name):
+ setattr(result, name, getattr(self, name))
+ result.metadata = dict(self.metadata)
+ result.extra_kwargs = dict(self.extra_kwargs)
+ return result
def field( # noqa: PLR0913
*,
default: object = MISSING,
- default_factory: Callable[[], object] | None = MISSING, # type:
ignore[assignment]
+ default_factory: Callable[[], object] | None = MISSING, # ty:
ignore[invalid-parameter-default]
frozen: bool = False,
init: bool = True,
repr: bool = True,
hash: bool | None = None,
compare: bool = True,
kw_only: bool | None = None,
+ metadata: Mapping[str, Any] | None = None,
structural_eq: str | None = None,
doc: str | None = None,
converter: Callable[[Any], Any] = _field_converter,
+ **extra_kwargs: Any,
) -> Any:
"""Customize a field in a ``@py_class``-decorated class.
@@ -306,6 +338,10 @@ def field( # noqa: PLR0913
kw_only
Whether this field is keyword-only in ``__init__``.
``None`` means "inherit from the decorator-level ``kw_only`` flag".
+ metadata
+ Extension metadata copied into a fresh mutable dictionary. Nested
+ values are shared; extensions own key semantics and may update it in
+ field-resolution callbacks.
structural_eq
Structural equality/hashing annotation. ``None`` (default) means
the field participates normally. ``"ignore"`` excludes the field
@@ -320,6 +356,9 @@ def field( # noqa: PLR0913
converter
Static-analysis marker for field conversion. Runtime conversion is
still handled by the FFI type converter.
+ extra_kwargs
+ Extension options retained on ``Field.extra_kwargs``. Field-resolution
+ callbacks must consume them; any remaining options cause a TypeError.
Returns
-------
@@ -352,7 +391,9 @@ def field( # noqa: PLR0913
hash=hash,
compare=compare,
kw_only=kw_only,
+ metadata=metadata,
structural_eq=structural_eq,
doc=doc,
converter=converter,
+ **extra_kwargs,
)
diff --git a/python/tvm_ffi/dataclasses/py_class.py
b/python/tvm_ffi/dataclasses/py_class.py
index 12ad81e0..1c581e75 100644
--- a/python/tvm_ffi/dataclasses/py_class.py
+++ b/python/tvm_ffi/dataclasses/py_class.py
@@ -73,10 +73,57 @@ _FFI_TYPE_ATTR_NAMES: frozenset[str] = frozenset(
}
)
-# Names collected directly from the class body. Names in
-# ``_FFI_TYPE_ATTR_NAMES`` are registered as TypeAttrColumn entries; other
-# names require explicit ``@method`` marking and register as TypeMethod.
-_FFI_RECOGNIZED_METHODS: frozenset[str] = _FFI_TYPE_ATTR_NAMES
+# Core behavioral hooks copied explicitly at Python schema registration time.
+# The ABI table remains flat: native subclasses never inherit entries
implicitly.
+_DEFAULT_INHERIT_TYPE_ATTRS = (
+ "__s_equal__",
+ "__s_hash__",
+ "__s_visit__",
+ "__data_to_json__",
+ "__data_from_json__",
+)
+
+
+def _type_attr_options(cls: type) -> tuple[frozenset[str], tuple[str, ...]]:
+ """Read explicit class policies; inherited lists follow ordinary Python
MRO.
+
+ Both lists are per class and may be declared once on an extension base
+ class; neither modifies the runtime's global set of recognized names.
+ ``__ffi_type_attr_names__`` registers class-local extension attributes.
+ ``__ffi_inherit_type_attrs__`` additionally selects parent entries to copy,
+ replacing the core default list (an empty tuple opts out). Inherited names
+ are already recognized and need not appear in both lists.
+ Metadata is never copied unless explicitly listed. A class-body definition,
+ including None, always wins over a parent registration.
+ """
+ extra = getattr(cls, "__ffi_type_attr_names__", ())
+ inherited = getattr(cls, "__ffi_inherit_type_attrs__",
_DEFAULT_INHERIT_TYPE_ATTRS)
+ for value in (extra, inherited):
+ if not isinstance(value, (tuple, list)) or not all(isinstance(n, str)
for n in value):
+ raise TypeError("TypeAttr registration lists must be tuples or
lists of strings")
+ return _FFI_TYPE_ATTR_NAMES | frozenset(extra) | frozenset(inherited),
tuple(inherited)
+
+
+def _inherited_type_attrs(type_info: Any, names: tuple[str, ...]) ->
list[tuple[Any, ...]]:
+ from ..core import _lookup_type_attr # noqa: PLC0415
+
+ cls = type_info.type_cls
+ entries = []
+ for name in names:
+ if name in cls.__dict__:
+ continue
+ parent = type_info.parent_type_info
+ while parent is not None:
+ value = _lookup_type_attr(parent.type_index, name)
+ if value is not None:
+ entries.append((name, value, True, None))
+ break
+ parent_cls = parent.type_cls
+ if parent_cls is not None and name in parent_cls.__dict__:
+ # An explicit null value suppresses the hook for descendants
too.
+ break
+ parent = parent.parent_type_info
+ return entries
@overload
@@ -133,8 +180,8 @@ def method(fn: Any) -> Any:
Decorate any staticmethod or plain instance method on a ``@py_class``
body to have it collected during class registration. Ordinary names land
in the C-level ``TVMFFITypeInfo.methods[]`` table. Names reserved for
- TypeAttrColumn dispatch, such as ``__ffi_repr__``, are routed to the
- type-attribute table instead.
+ TypeAttrColumn dispatch, such as ``__ffi_repr__``, are routed to the
type-attribute table
+ instead.
Once registered as a TypeMethod, the method is resolvable by name from any
FFI consumer — Python-side reflection via ``TypeInfo.methods``, C++, Rust —
@@ -270,6 +317,7 @@ def _collect_py_methods(
``(name, value, is_static, metadata_json)`` tuples when *globalns* is
provided by the registration path.
"""
+ type_attr_names, _ = _type_attr_options(cls)
legacy_shape = globalns is None
if globalns is None:
globalns = vars(sys.modules[cls.__module__])
@@ -277,7 +325,7 @@ def _collect_py_methods(
methods: list[tuple[Any, ...]] = []
for name, value in cls.__dict__.items():
marked = _is_method_marked(value)
- if name not in _FFI_RECOGNIZED_METHODS and not marked:
+ if name not in type_attr_names and not marked:
continue
# In every case, registering a classmethod as a TypeMethod is
# wrong: the packed-call convention places ``self`` (an instance)
@@ -295,7 +343,7 @@ def _collect_py_methods(
is_static = isinstance(value, staticmethod)
func = value.__func__ if is_static else value
metadata_json = None
- if marked and name not in _FFI_TYPE_ATTR_NAMES:
+ if marked and name not in type_attr_names:
metadata_json = _method_type_schema_json(cls, func, is_static,
globalns)
if legacy_shape:
methods.append((name, func, is_static))
@@ -312,7 +360,8 @@ def on_fields_resolved( # noqa: PLR0912, PLR0915
``_resolve_fields`` supplies owner classes and their resolved type hints.
This function turns those hints into :class:`Field` objects, applies
- decorator-level defaults stored on ``type_info._decorator_args``, registers
+ decorator-level defaults stored on ``type_info._decorator_args``, invokes
+ marked ``__ffi_on_fields_resolved__`` hooks over those fields, registers
field metadata and structural-equality kind with the Cython layer,
registers
Python-defined TypeMethods and TypeAttrColumn values, restores any deferred
user ``__init__``, and installs the dataclass-style dunder methods.
@@ -321,30 +370,46 @@ def on_fields_resolved( # noqa: PLR0912, PLR0915
assert cls is not None
params = type_info._decorator_args
owners, hints_by_owner = resolved_fields
+ field_hooks = [
+ hook
+ for base in reversed(cls.__mro__)
+ for hook in base.__dict__.values()
+ if callable(hook) and getattr(hook, "__ffi_on_fields_resolved__",
False) is True
+ ]
fields_map: dict[str, Field] = {}
ip_funcs: dict[str, Any] = {}
kw_only_active = params["kw_only"]
+ marker_types = getattr(cls, "__ffi_field_markers__", ())
+ if not isinstance(marker_types, (tuple, list)) or not all(
+ isinstance(marker, type) for marker in marker_types
+ ):
+ raise TypeError("__ffi_field_markers__ must be a tuple or list of
types")
+ parent = type_info.parent_type_info
+ parent_params = getattr(parent, "_decorator_args", {})
+ markers = list(parent_params.get("field_markers", ()))
+ inherited_count = 0
+ while parent is not None:
+ inherited_count += len(parent.fields)
+ parent = parent.parent_type_info
for owner in owners:
- own_annotations = _resolve_fields.own_annotations(owner)
- for name in own_annotations:
- resolved_type = hints_by_owner[owner].get(name)
+ for name, resolved_type in hints_by_owner[owner].items():
# Skip ClassVar.
- if (
- resolved_type is None
- or resolved_type is ClassVar
- or typing.get_origin(resolved_type) is ClassVar
- ):
+ if resolved_type is ClassVar or typing.get_origin(resolved_type)
is ClassVar:
continue
# KW_ONLY sentinel.
if resolved_type is KW_ONLY:
kw_only_active = True
if owner is cls and name in cls.__dict__:
- try:
- delattr(cls, name)
- except AttributeError:
- pass
+ delattr(cls, name)
+ continue
+
+ if any(resolved_type is marker for marker in marker_types):
+ # Marker interpretation and validation belong to the extension.
+ # Record the declaring class and name so its callback can
inspect
+ # declarations, including explicit assigned values, without
loss.
+ markers.append((resolved_type, owner, name, inherited_count +
len(fields_map)))
continue
# Extract Field from class dict (inline of _pop_field_from_class).
@@ -366,10 +431,7 @@ def on_fields_resolved( # noqa: PLR0912, PLR0915
else:
f = field()
if owner is cls and class_val is not MISSING:
- try:
- delattr(cls, name)
- except AttributeError:
- pass
+ delattr(cls, name)
# Fill in name, schema, and resolved type.
f.name = name
@@ -394,10 +456,30 @@ def on_fields_resolved( # noqa: PLR0912, PLR0915
globalns = getattr(sys.modules.get(cls.__module__, None), "__dict__", {})
if ip_funcs:
setattr(cls, "__ffi_init_property_funcs__", ip_funcs)
- py_methods = _collect_py_methods(cls, globalns)
+ type_attr_names, inherited_attrs = _type_attr_options(cls)
+ py_methods = _collect_py_methods(cls, globalns) or []
+
+ params["field_markers"] = tuple(markers)
+
+ # Run marked hooks over the resolved fields. This happens before the
+ # fields reach the C layer so a hook can still settle metadata that
+ # registration freezes, such as a field's structural-equality treatment.
+ # ``type_info.fields`` is therefore not populated yet, which is why the
+ # hook is handed ``own_fields`` directly.
+ for hook in field_hooks:
+ hook(type_info, own_fields)
+ for f in own_fields:
+ if f.extra_kwargs:
+ raise TypeError(
+ f"{cls.__name__}.{f.name}: unrecognized field options: "
+ + ", ".join(sorted(f.extra_kwargs))
+ )
+
+ py_methods = _collect_py_methods(cls, globalns) or []
+ py_methods.extend(_inherited_type_attrs(type_info, inherited_attrs))
# Register fields and type-level structural eq/hash kind with the C layer.
- structure_kind = _STRUCTURE_KIND_MAP.get(params.get("structural_eq"))
+ structure_kind = _STRUCTURE_KIND_MAP[params["structural_eq"]]
type_info._register_fields(own_fields, structure_kind)
# Attach the user's Field sentinel to each TypeField so the
# ``tvm_ffi.dataclasses.fields()`` compat layer can recover defaults
@@ -409,8 +491,8 @@ def on_fields_resolved( # noqa: PLR0912, PLR0915
# Register user-defined dunder methods and read back system-generated ones.
# Non-callable entries whose names are in _FFI_TYPE_ATTR_NAMES are routed
# to TVMFFITypeRegisterAttr by the Cython layer.
- type_info._register_py_methods(py_methods,
type_attr_names=_FFI_TYPE_ATTR_NAMES)
- _add_class_attrs(cls, type_info, type_attr_names=_FFI_TYPE_ATTR_NAMES)
+ type_info._register_py_methods(py_methods, type_attr_names=type_attr_names)
+ _add_class_attrs(cls, type_info, type_attr_names=type_attr_names)
# Remove deferred __init__ and restore user-defined __init__ if saved.
if "__ffi_py_class_is_deferred_init__" in cls.__dict__:
@@ -554,6 +636,26 @@ def py_class( # noqa: PLR0913
it only configures how ``structural_equal`` / ``structural_hash``
walk the object in C++ and never installs or alters Python-level
``__eq__`` / ``__hash__``. See Notes below.
+ __ffi_type_attr_names__
+ Optional per-class list of additional names to publish in the FFI
+ type-attribute table. The list follows Python MRO, so an extension base
+ can declare it once for its subclasses. It does not change a global
+ allowlist or copy parent values. Use it for per-type metadata that
+ each class defines independently, as well as extension methods.
+ __ffi_inherit_type_attrs__
+ Optional per-class list selecting parent type-attribute values to copy
+ after schema resolution. These names are also recognized for direct
+ registration and need not appear in ``__ffi_type_attr_names__``.
+ Class-body values, including ``None``, take precedence. The list
+ replaces the default structural and serialization hooks; ``()`` opts
+ out. Low-level lookup remains exact, with no ancestor traversal.
+ __ffi_field_markers__
+ Optional class attribute listing annotation types that mark field
+ positions instead of declaring stored fields. Before field-resolution
+ callbacks run, ``type_info._decorator_args["field_markers"]`` holds
+ tuples ``(marker_type, owner_class, name, field_index)``. Indices count
+ inherited fields first; inherited marker tuples precede local tuples.
+ Extensions validate names, assigned values, multiplicity and semantics.
slots
Accepted for ``dataclass_transform`` compatibility. Object
subclasses always use ``__slots__ = ()`` via the metaclass.
@@ -618,7 +720,10 @@ def py_class( # noqa: PLR0913
globalns = getattr(sys.modules.get(cls.__module__, None), "__dict__",
{})
info = _resolve_fields.register_type_without_fields(cls,
effective_type_key)
- info._decorator_args = params
+ # Copy per class: one decorator object can decorate several classes,
+ # and a fields-resolved hook may adjust these arguments for its own
+ # class before they are read back.
+ info._decorator_args = dict(params)
try:
resolved = _resolve_fields.resolve_type_hints_by_owner(cls,
globalns)
diff --git a/python/tvm_ffi/registry.py b/python/tvm_ffi/registry.py
index 3eae09e1..74bcb77d 100644
--- a/python/tvm_ffi/registry.py
+++ b/python/tvm_ffi/registry.py
@@ -457,7 +457,7 @@ def _install_ffi_init_attr(cls: type, type_info: TypeInfo,
ffi_init: Function) -
missing = core.MISSING
type_name = cls.__name__
- def __ffi_init__(self: Any, *args: Any, **kwargs: Any) -> None:
+ def __ffi_init__(self: Any, /, *args: Any, **kwargs: Any) -> None:
if type_info is not type(self).__tvm_ffi_type_info__:
raise TypeError(
f"Calling `{type_name}.__ffi_init__()` on a
`{type(self).__name__}` "
diff --git a/src/ffi/extra/dataclass.cc b/src/ffi/extra/dataclass.cc
index f4c961d4..b1d7ffbc 100644
--- a/src/ffi/extra/dataclass.cc
+++ b/src/ffi/extra/dataclass.cc
@@ -2006,25 +2006,44 @@ void BindFieldArgs(Object* obj, const AutoInitInfo&
info, const TVMFFIAny* raw_a
const ObjectRef& kwargs_sentinel = GetKwargsSentinel();
std::vector<bool> field_set(info.all_fields.size(), false);
+ // `what` names the position the value came from: an argument the caller
+ // passed, or the field's declared default.
+ auto rethrow_setter_error = [&](size_t fi, const char* what) {
+ const TVMFFIFieldInfo* field_info = info.all_fields[fi].info;
+ Error err = details::MoveFromSafeCallRaised();
+ auto field_name = std::string_view(field_info->name.data,
field_info->name.size);
+ std::string message;
+ message.reserve(info.type_key.size() + field_name.size() +
err.message().size() + 48);
+ message.append(info.type_key);
+ message.append(".__ffi_init__() ");
+ message.append(what);
+ message.append(" '");
+ message.append(field_name);
+ message.append("'");
+ AppendNestedErrorMessage(&message, err.message());
+ throw Error(err.kind(), std::move(message), err.backtrace(), err,
std::nullopt);
+ };
+
auto set_field = [&](size_t fi, const TVMFFIAny* value) {
const TVMFFIFieldInfo* field_info = info.all_fields[fi].info;
void* addr = reinterpret_cast<char*>(obj) + field_info->offset;
- int ret_code = refl::CallFieldSetter(field_info, addr, value);
- if (ret_code != 0) {
- Error err = details::MoveFromSafeCallRaised();
- auto field_name = std::string_view(field_info->name.data,
field_info->name.size);
- std::string message;
- message.reserve(info.type_key.size() + field_name.size() +
err.message().size() + 32);
- message.append(info.type_key);
- message.append(".__ffi_init__() field '");
- message.append(field_name);
- message.append("'");
- AppendNestedErrorMessage(&message, err.message());
- throw Error(err.kind(), std::move(message), err.backtrace(), err,
std::nullopt);
+ if (refl::CallFieldSetter(field_info, addr, value) != 0) {
+ rethrow_setter_error(fi, "field");
}
field_set[fi] = true;
};
+ // A default goes through the same setter as a passed value. Dropping its
+ // status would leave the field zero-initialized -- reading back as `None`
+ // for an object field -- and leave the error raised but never delivered.
+ auto set_field_to_default = [&](size_t fi) {
+ const TVMFFIFieldInfo* field_info = info.all_fields[fi].info;
+ void* addr = reinterpret_cast<char*>(obj) + field_info->offset;
+ if (refl::CallFieldSetterToDefault(field_info, addr) != 0) {
+ rethrow_setter_error(fi, "default for field");
+ }
+ };
+
// ---- 1. Find KWARGS sentinel position ------------------------------------
int kwargs_pos = -1;
for (int i = 0; i < num_args; ++i) {
@@ -2088,8 +2107,7 @@ void BindFieldArgs(Object* obj, const AutoInitInfo& info,
const TVMFFIAny* raw_a
for (size_t fi = 0; fi < info.all_fields.size(); ++fi) {
if (field_set[fi]) continue;
if (info.all_fields[fi].has_default) {
- void* addr = reinterpret_cast<char*>(obj) +
info.all_fields[fi].info->offset;
- refl::SetFieldToDefault(info.all_fields[fi].info, addr);
+ set_field_to_default(fi);
} else if (info.all_fields[fi].init) {
auto fname = std::string_view(info.all_fields[fi].info->name.data,
info.all_fields[fi].info->name.size);
diff --git a/src/ffi/extra/reflection_extra.cc
b/src/ffi/extra/reflection_extra.cc
index f3930de9..143bfe96 100644
--- a/src/ffi/extra/reflection_extra.cc
+++ b/src/ffi/extra/reflection_extra.cc
@@ -71,8 +71,10 @@ void MakeObjectFromPackedArgs(ffi::PackedArgs args, Any*
ret) {
void* field_addr = reinterpret_cast<char*>(ptr.get()) +
field_info->offset;
if (arg_index < keys.size()) {
AnyView field_value = args[static_cast<int>(arg_index * 2 + 2)];
- reflection::CallFieldSetter(field_info, field_addr,
- reinterpret_cast<const
TVMFFIAny*>(&field_value));
+ // The setter reports a type mismatch through the safe-call slot, not
+ // by throwing; dropping the status would silently null the field.
+ TVM_FFI_CHECK_SAFE_CALL(reflection::CallFieldSetter(
+ field_info, field_addr, reinterpret_cast<const
TVMFFIAny*>(&field_value)));
keys_found[arg_index] = true;
} else if (field_info->flags & kTVMFFIFieldFlagBitMaskHasDefault) {
reflection::SetFieldToDefault(field_info, field_addr);
diff --git a/src/ffi/extra/serialization.cc b/src/ffi/extra/serialization.cc
index 982d9f17..046ab455 100644
--- a/src/ffi/extra/serialization.cc
+++ b/src/ffi/extra/serialization.cc
@@ -432,8 +432,11 @@ class ObjectGraphDeserializer {
void* field_addr = reinterpret_cast<char*>(ptr.get()) +
field_info->offset;
if (data_object.count(field_name) != 0) {
Any field_value = decode_field_value(field_info,
data_object[field_name]);
- reflection::CallFieldSetter(field_info, field_addr,
- reinterpret_cast<const
TVMFFIAny*>(&field_value));
+ // A payload whose type does not match the field must fail loudly:
+ // the setter reports that through the safe-call slot, so dropping the
+ // status would decode the field as null and carry on.
+ TVM_FFI_CHECK_SAFE_CALL(reflection::CallFieldSetter(
+ field_info, field_addr, reinterpret_cast<const
TVMFFIAny*>(&field_value)));
} else if (field_info->flags & kTVMFFIFieldFlagBitMaskHasDefault) {
reflection::SetFieldToDefault(field_info, field_addr);
} else {
diff --git a/tests/cpp/test_reflection.cc b/tests/cpp/test_reflection.cc
index af4629f2..f28a0574 100644
--- a/tests/cpp/test_reflection.cc
+++ b/tests/cpp/test_reflection.cc
@@ -522,6 +522,27 @@ TVM_FFI_STATIC_INIT_BLOCK() {
.def_ro("count", &TestObjWithFactory::count,
refl::default_value(static_cast<int64_t>(0)));
}
+// A declared default that cannot convert to the field it belongs to. The
+// auto-generated init has to reject it exactly as it rejects a passed value of
+// that type: the setter reports failure through the safe-call slot rather than
+// by throwing, so dropping its status left the field zero-initialized -- an
+// object field reading back as null -- and left the error raised but never
+// delivered, for an unrelated call to trip over.
+struct TestObjWithBadDefault : public Object {
+ Array<ObjectRef> items;
+
+ explicit TestObjWithBadDefault(UnsafeInit) {}
+
+ [[maybe_unused]] static constexpr bool _type_mutable = true;
+ TVM_FFI_DECLARE_OBJECT_INFO_FINAL("test.TestObjWithBadDefault",
TestObjWithBadDefault, Object);
+};
+
+TVM_FFI_STATIC_INIT_BLOCK() {
+ namespace refl = tvm::ffi::reflection;
+ refl::ObjectDef<TestObjWithBadDefault>().def_ro("items",
&TestObjWithBadDefault::items,
+
refl::default_value(static_cast<int64_t>(5)));
+}
+
struct TestObjWithAny : public Object {
Any value;
explicit TestObjWithAny(Any value) : value(std::move(value)) {}
@@ -587,6 +608,25 @@ TEST(Reflection, DefaultFactoryFlag) {
EXPECT_FALSE(info_count->flags & kTVMFFIFieldFlagBitMaskDefaultFromFactory);
}
+TEST(Reflection, InitRejectsUnconvertibleDefault) {
+ Function init = GetInitAttr("test.TestObjWithBadDefault");
+ // The field is omitted, so its declared default is what fails.
+ EXPECT_THROW(init(), Error);
+}
+
+TEST(Reflection, CreatorRejectsUnconvertibleDefault) {
+ namespace refl = tvm::ffi::reflection;
+ refl::ObjectCreator creator("test.TestObjWithBadDefault");
+ EXPECT_THROW(creator(Map<String, Any>()), Error);
+}
+
+TEST(Reflection, CreatorRejectsUnconvertibleFieldValue) {
+ namespace refl = tvm::ffi::reflection;
+ refl::ObjectCreator creator("test.TestObjWithFactory");
+ // `items` is an Array field; an int cannot convert to it.
+ EXPECT_THROW(creator(Map<String, Any>({{"items",
static_cast<int64_t>(5)}})), Error);
+}
+
TEST(Reflection, DefaultFactoryCreation) {
namespace refl = tvm::ffi::reflection;
refl::ObjectCreator creator("test.TestObjWithFactory");
diff --git a/tests/python/test_dataclass_init.py
b/tests/python/test_dataclass_init.py
index 6280d152..feb09fdc 100644
--- a/tests/python/test_dataclass_init.py
+++ b/tests/python/test_dataclass_init.py
@@ -674,7 +674,7 @@ class TestAutoInitCopyBehavior:
@requires_py313
def test_replace(self) -> None:
obj = _TestCxxAutoInit(1, c=3)
- replaced = copy.replace(obj, a=100, c=300) # type:
ignore[attr-defined]
+ replaced = copy.replace(obj, a=100, c=300) # ty:
ignore[unresolved-attribute]
assert replaced.a == 100
assert replaced.b == 42
assert replaced.c == 300
@@ -710,7 +710,7 @@ class TestAutoInitCopyBehavior:
@requires_py313
def test_replace_kw_only_defaults(self) -> None:
obj = _TestCxxAutoInitKwOnlyDefaults(1, k_required=2)
- replaced = copy.replace(obj, k_required=99, p_default=88) # type:
ignore[attr-defined]
+ replaced = copy.replace(obj, k_required=99, p_default=88) # ty:
ignore[unresolved-attribute]
assert replaced.p_required == 1
assert replaced.p_default == 88
assert replaced.k_required == 99
@@ -1109,11 +1109,19 @@ class _PyClassWithDefault(core.Object):
b: int = 42
+@py_class("testing.PyClassWithBadDefault")
+class _PyClassWithBadDefault(core.Object):
+ """A declared default that cannot convert to the field it belongs to."""
+
+ a: int
+ b: TestIntPair = 5 # ty: ignore[invalid-assignment]
+
+
class TestPyClassAnnotationDiscovery:
"""Regression: @py_class must discover fields on Python 3.14+ (PEP 749)."""
def test_fields_registered(self) -> None:
- ti: core.TypeInfo = _PyClassSimple.__tvm_ffi_type_info__ # type:
ignore[unresolved-attribute]
+ ti: core.TypeInfo = _PyClassSimple.__tvm_ffi_type_info__ # ty:
ignore[unresolved-attribute]
names = [f.name for f in ti.fields]
assert names == ["x", "y"]
@@ -1136,3 +1144,16 @@ class TestPyClassAnnotationDiscovery:
obj = _PyClassWithDefault(a=1, b=2)
assert obj.a == 1
assert obj.b == 2
+
+ def test_unconvertible_default_raises(self) -> None:
+ # A default is converted by the same setter as a passed value, so it
+ # must fail the same way. The setter reports failure through the
+ # safe-call slot instead of throwing, so dropping its status used to
+ # leave `b` zero-initialized -- reading back as None -- and strand the
+ # raised error for an unrelated call to pick up.
+ with pytest.raises(TypeError, match=r"default for field 'b'"):
+ _PyClassWithBadDefault(a=1)
+
+ def test_unconvertible_default_matches_the_explicit_error(self) -> None:
+ with pytest.raises(TypeError, match=r"field 'b'"):
+ _PyClassWithBadDefault(a=1, b=5) # ty:
ignore[invalid-argument-type]
diff --git a/tests/python/test_dataclass_namedtuple.py
b/tests/python/test_dataclass_namedtuple.py
new file mode 100644
index 00000000..411ea17d
--- /dev/null
+++ b/tests/python/test_dataclass_namedtuple.py
@@ -0,0 +1,270 @@
+# 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.
+"""Field metadata and FFI conversion integration for native named tuples."""
+
+from __future__ import annotations
+
+import copy
+import inspect
+import itertools
+from collections import namedtuple
+from typing import Any
+
+import pytest
+import tvm_ffi
+import tvm_ffi.testing
+from tvm_ffi import dataclasses as dc
+from tvm_ffi.core import Object, TypeSchema
+from tvm_ffi.dataclasses import common
+from tvm_ffi.dataclasses.common import _make_namedtuple
+
+_counter = itertools.count()
+
+
+def _key(name: str) -> str:
+ return f"testing.namedtuple.{name}_{next(_counter)}"
+
+
[email protected]
+def record_fields() -> tuple[dc.Field, ...]:
+ @dc.py_class(_key("Base"))
+ class Base(Object):
+ count: int
+
+ @dc.py_class(_key("Measurement"))
+ class Measurement(Base):
+ scale: float
+
+ return dc.fields(Measurement)
+
+
+def test_native_tuple_and_metadata(record_fields: tuple[dc.Field, ...]) ->
None:
+ before = [{slot: getattr(f, slot) for slot in dc.Field.__slots__} for f in
record_fields]
+ record: Any = _make_namedtuple("MeasurementFields", record_fields)
+ value = record(count=2, scale=3)
+
+ assert record.__bases__ == (tuple,)
+ assert record._fields == ("count", "scale")
+ assert record.__annotations__ == {"count": int, "scale": float}
+ assert str(inspect.signature(record)) == "(count, scale)"
+ assert tuple(value) == (2, 3.0)
+ assert type(value.scale) is float
+ assert value[1] == 3.0
+ assert value._asdict() == {"count": 2, "scale": 3.0}
+ assert repr(value) == "MeasurementFields(count=2, scale=3.0)"
+ assert copy.copy(value) == value
+ assert record._field_defaults == {}
+ assert not dc.is_dataclass(record)
+ assert not hasattr(record, "__tvm_ffi_type_info__")
+ assert not hasattr(record, "_field_schemas")
+ for field, snapshot in zip(record_fields, before):
+ assert all(getattr(field, slot) is old for slot, old in
snapshot.items())
+
+
+def test_selected_fields_and_c_class_metadata(record_fields: tuple[dc.Field,
...]) -> None:
+ selected: Any = _make_namedtuple("Selected", (record_fields[1],
record_fields[0]))
+ assert selected._fields == ("scale", "count")
+ assert type(selected(1, 2).scale) is float
+ assert tuple(selected(1, 2)) == (1.0, 2)
+
+ c_fields = dc.fields(tvm_ffi.testing.TestIntPair)
+ c_record: Any = _make_namedtuple("IntPairFields", c_fields)
+ assert c_record._fields == ("a", "b")
+ assert c_record.__annotations__ == {"a": int, "b": int}
+ assert c_record(True, 2) == (1, 2)
+ assert type(c_record(True, 2).a) is int
+
+
+def test_schema_fallback_does_not_change_field(record_fields: tuple[dc.Field,
...]) -> None:
+ field = copy.copy(record_fields[1])
+ field._ty_schema = None
+ record: Any = _make_namedtuple("Fallback", (field,))
+ assert type(record(2).scale) is float
+ assert field._ty_schema is None
+ assert field.type is float
+
+
+def test_cached_schema_needs_no_annotation(monkeypatch: pytest.MonkeyPatch) ->
None:
+ field = dc.Field("scale", TypeSchema.from_annotation(float))
+
+ def unexpected_resolution(annotation: Any) -> None:
+ pytest.fail("A cached schema must not be resolved again")
+
+ class SchemaResolver:
+ from_annotation = staticmethod(unexpected_resolution)
+
+ monkeypatch.setattr(common, "TypeSchema", SchemaResolver)
+ record: Any = _make_namedtuple("Cached", (field,))
+ assert record.__annotations__ == {}
+ assert type(record(2).scale) is float
+ assert field.type is None
+
+
[email protected](
+ "make",
+ [
+ pytest.param(lambda t, x: t(1, x), id="positional"),
+ pytest.param(lambda t, x: t(count=1, scale=x), id="keyword"),
+ pytest.param(lambda t, x: t(1, scale=x), id="mixed"),
+ pytest.param(lambda t, x: t._make(v for v in (1, x)),
id="make-generator"),
+ pytest.param(lambda t, x: t(1, 0)._replace(scale=x), id="replace"),
+ pytest.param(
+ lambda t, x: getattr(copy, "replace")(t(1, 0), scale=x),
+ id="copy-replace",
+ marks=pytest.mark.skipif(not hasattr(copy, "replace"),
reason="Python 3.13+"),
+ ),
+ ],
+)
+def test_conversion_entry_points(record_fields: tuple[dc.Field, ...], make:
Any) -> None:
+ record: Any = _make_namedtuple("MeasurementFields", record_fields)
+
+ class FloatInput:
+ calls = 0
+
+ def __tvm_ffi_float__(self) -> float:
+ self.calls += 1
+ return 2.5
+
+ raw = FloatInput()
+ value = make(record, raw)
+ assert type(value) is record
+ assert type(value.scale) is float
+ assert value.scale == 2.5
+ assert raw.calls == 1
+ with pytest.raises(TypeError, match=r"MeasurementFields\.scale:.*expected
float") as err:
+ make(record, "invalid")
+ assert isinstance(err.value.__cause__, TypeError)
+
+
+def test_container_and_object_conversion() -> None:
+ @dc.py_class(_key("Payload"))
+ class Payload(Object):
+ values: tuple[float, ...]
+ mapping: tvm_ffi.Map[str, float]
+ object: tvm_ffi.testing.TestIntPair
+
+ record: Any = _make_namedtuple("PayloadFields", dc.fields(Payload))
+ pair = tvm_ffi.testing.TestIntPair(1, 2)
+ value = record([1, 2], {"x": 3}, pair)
+ assert value.values == (1.0, 2.0)
+ assert all(type(v) is float for v in value.values)
+ assert isinstance(value.mapping, tvm_ffi.Map)
+ assert type(value.mapping["x"]) is float
+ assert value.mapping["x"] == 3.0
+ assert value.object.same_as(pair)
+
+
+def test_nested_conversion_error() -> None:
+ @dc.py_class(_key("Nested"))
+ class Nested(Object):
+ values: tuple[dict[str, float], ...]
+
+ record: Any = _make_namedtuple("NestedFields", dc.fields(Nested))
+ with pytest.raises(TypeError, match=r"NestedFields\.values:") as err:
+ record([{"x": "invalid"}])
+ assert "element [0]" in str(err.value)
+ assert "value for key 'x'" in str(err.value)
+ assert "expected float" in str(err.value)
+ assert isinstance(err.value.__cause__, TypeError)
+
+
[email protected](
+ "args, kwargs",
+ [((), {}), ((1,), {}), ((1, 2, 3), {}), ((1, 2), {"scale": 3}), ((1, 2),
{"other": 3})],
+)
+def test_native_constructor_errors(
+ record_fields: tuple[dc.Field, ...], args: tuple[Any, ...], kwargs:
dict[str, Any]
+) -> None:
+ record: Any = _make_namedtuple("MeasurementFields", record_fields)
+ with pytest.raises(TypeError):
+ record(*args, **kwargs)
+
+
+def test_native_helper_errors(record_fields: tuple[dc.Field, ...]) -> None:
+ record: Any = _make_namedtuple("MeasurementFields", record_fields)
+ native: Any = namedtuple(record.__name__, record._fields)
+ # Python versions differ in the native exception type; retain that
behavior.
+ for values in ([1], [1, 2, 3]):
+ with pytest.raises((TypeError, ValueError)) as expected:
+ native._make(values)
+ with pytest.raises(type(expected.value)) as actual:
+ record._make(iter(values))
+ assert str(actual.value) == str(expected.value)
+ with pytest.raises((TypeError, ValueError)) as expected:
+ native(1, 2)._replace(other=3)
+ with pytest.raises(type(expected.value)) as actual:
+ record(1, 2)._replace(other=3)
+ assert str(actual.value) == str(expected.value)
+
+
[email protected]("names", [("_private",), ("repeat", "repeat"),
("for",)])
+def test_native_field_name_errors(names: tuple[str, ...]) -> None:
+ fields = tuple(dc.Field(name, TypeSchema.from_annotation(int)) for name in
names)
+ with pytest.raises(ValueError):
+ _make_namedtuple("Invalid", fields)
+
+
+def test_empty_and_cls_field() -> None:
+ empty: Any = _make_namedtuple("Empty", ())
+ assert empty() == empty._make(iter(())) == empty()._replace() == ()
+ assert empty._fields == ()
+ record: Any = _make_namedtuple(
+ "ClassField", (dc.Field("cls", TypeSchema.from_annotation(float)),)
+ )
+ assert type(record(cls=3).cls) is float
+ assert record(cls=3).cls == 3.0
+
+
+def test_source_initialization_is_unused() -> None:
+ def forbidden() -> Any:
+ pytest.fail("Tuple creation must not run source initialization")
+
+ @dc.py_class(_key("Source"))
+ class Source(Object):
+ count: int = dc.field(default=7, kw_only=True)
+ values: tuple[int, ...] = dc.field(default_factory=forbidden)
+ hidden: int = dc.field(default=9, init=False)
+
+ def __init__(self) -> None:
+ forbidden()
+
+ def __post_init__(self) -> None:
+ forbidden()
+
+ @dc.init_property
+ def computed(self) -> int:
+ return forbidden()
+
+ record: Any = _make_namedtuple("SourceFields", dc.fields(Source))
+ assert record._fields == ("count", "values", "hidden", "computed")
+ assert record(1, [2], 3, 4) == (1, (2,), 3, 4)
+ assert record._make([1, [2], 3, 4])._replace(count=5) == (5, (2,), 3, 4)
+ for length in range(4):
+ with pytest.raises(TypeError):
+ record(*(1, [2], 3, 4)[:length])
+
+
+def test_subclass_helpers(record_fields: tuple[dc.Field, ...]) -> None:
+ record: Any = _make_namedtuple("MeasurementFields", record_fields)
+
+ class Subclass(record):
+ __slots__ = ()
+
+ value = Subclass._make([1, 2])._replace(scale=3)
+ assert type(value) is Subclass
+ assert type(value.scale) is float
+ assert value.scale == 3.0
diff --git a/tests/python/test_dataclass_py_class.py
b/tests/python/test_dataclass_py_class.py
index b4b8986b..de10a931 100644
--- a/tests/python/test_dataclass_py_class.py
+++ b/tests/python/test_dataclass_py_class.py
@@ -26,7 +26,7 @@ import itertools
import math
import sys
import types
-from typing import Any, ClassVar, Dict, List, Optional
+from typing import Any, ClassVar, Dict, List, Optional, Tuple, cast
import pytest
import tvm_ffi
@@ -189,6 +189,35 @@ class TestBasicRegistration:
assert isinstance(obj, InstCheck)
assert isinstance(obj, Object)
+ def test_fields_resolved_hook(self) -> None:
+ @py_class(_unique_key("HookBase"))
+ class HookBase(Object):
+ seen: ClassVar[list[tuple[type, str, tuple[str, ...]]]] = []
+
+ def __ffi_hook__(type_info: TypeInfo, own_fields: Any) -> None:
+ cls = type_info.type_cls
+ assert cls is not None
+ # The hook runs before the fields reach the C layer, so their
+ # schemas come from the resolved ``Field`` objects rather than
+ # from ``type_info.fields``, which is still empty here.
+ assert not type_info.fields
+ schemas = tuple(f._ty_schema for f in own_fields)
+ assert all(isinstance(schema, TypeSchema) for schema in
schemas)
+ getattr(cls, "seen").append(
+ (cls, type_info.type_key, tuple(str(schema) for schema in
schemas))
+ )
+
+ __ffi_hook__.__ffi_on_fields_resolved__ = True # ty:
ignore[unresolved-attribute]
+
+ @py_class(_unique_key("HookChild"))
+ class HookChild(HookBase):
+ x: int
+
+ assert HookBase.seen == [
+ (HookBase, _get_type_info(HookBase).type_key, ()),
+ (HookChild, _get_type_info(HookChild).type_key, ("int",)),
+ ]
+
# ###########################################################################
# 2. Field parsing
@@ -419,6 +448,78 @@ class TestClassVar:
assert CVPres.tag == "hello"
+ def test_custom_metadata_classvar_registered_as_type_attr(self) -> None:
+ @py_class(_unique_key("CVCustomMetadata"))
+ class CVCustomMetadata(Object):
+ __ffi_type_attr_names__ = ("__custom_metadata__",)
+ __custom_metadata__: ClassVar[Tuple[str, str]] = (
+ "test",
+ "CVCustomMetadata",
+ )
+ x: int
+
+ info = _get_type_info(CVCustomMetadata)
+ field_names = [f.name for f in info.fields]
+
+ assert CVCustomMetadata.__custom_metadata__ == ("test",
"CVCustomMetadata")
+ assert "__custom_metadata__" not in field_names
+ assert tuple(core._lookup_type_attr(info.type_index,
"__custom_metadata__")) == (
+ "test",
+ "CVCustomMetadata",
+ )
+
+ def test_custom_metadata_is_not_registered_as_type_method(self) -> None:
+ @py_class(_unique_key("CVCustomMetadataNoMethod"))
+ class CVCustomMetadataNoMethod(Object):
+ __ffi_type_attr_names__ = ("__custom_metadata__",)
+ __custom_metadata__: ClassVar[Tuple[str, str]] = (
+ "test",
+ "CVCustomMetadataNoMethod",
+ )
+ x: int
+
+ info = _get_type_info(CVCustomMetadataNoMethod)
+
+ assert "__custom_metadata__" not in [method.name for method in
info.methods]
+ assert tuple(core._lookup_type_attr(info.type_index,
"__custom_metadata__")) == (
+ "test",
+ "CVCustomMetadataNoMethod",
+ )
+
+ def test_custom_metadata_allows_non_tuple_value(self) -> None:
+ @py_class(_unique_key("CVCustomMetadataBadInt"))
+ class CVCustomMetadataBadInt(Object):
+ __ffi_type_attr_names__ = ("__custom_metadata__",)
+ __custom_metadata__: ClassVar[int] = 1
+ x: int
+
+ info = _get_type_info(CVCustomMetadataBadInt)
+ assert core._lookup_type_attr(info.type_index, "__custom_metadata__")
== 1
+
+ def test_custom_metadata_allows_bad_tuple_value(self) -> None:
+ @py_class(_unique_key("CVCustomMetadataBadTuple"))
+ class CVCustomMetadataBadTuple(Object):
+ __ffi_type_attr_names__ = ("__custom_metadata__",)
+ x: int
+ __custom_metadata__: ClassVar[Tuple[str, int]] = ("test", 1)
+
+ info = _get_type_info(CVCustomMetadataBadTuple)
+ assert tuple(core._lookup_type_attr(info.type_index,
"__custom_metadata__")) == (
+ "test",
+ 1,
+ )
+
+ def test_custom_metadata_allows_staticmethod_value(self) -> None:
+ @py_class(_unique_key("CVCustomMetadataBadStatic"))
+ class CVCustomMetadataBadStatic(Object):
+ __ffi_type_attr_names__ = ("__custom_metadata__",)
+ x: int
+ __custom_metadata__ = staticmethod(lambda: ("test", "Bad"))
+ x: int
+
+ info = _get_type_info(CVCustomMetadataBadStatic)
+ assert core._lookup_type_attr(info.type_index, "__custom_metadata__")
is not None
+
# ###########################################################################
# 6. Init generation
@@ -494,7 +595,7 @@ class TestInit:
@py_class(_unique_key("ReqChild"))
class ReqChild(OptParent):
- z: int
+ z: int # ty: ignore[dataclass-field-order]
sig = inspect.signature(ReqChild.__init__)
param_names = [n for n in sig.parameters if n != "self"]
@@ -635,7 +736,7 @@ class TestInitProperty:
_ = obj.computed
assert call_count == 1 # second access reads C++ field, not recomputed
# Verify the field is registered in C++ type metadata.
- type_info = _Cached.__tvm_ffi_type_info__
+ type_info = _Cached.__tvm_ffi_type_info__ # ty:
ignore[unresolved-attribute]
field_names = [f.name for f in type_info.fields]
assert "computed" in field_names
ip_field = next(f for f in type_info.fields if f.name == "computed")
@@ -1663,7 +1764,7 @@ class TestInitReorderingAdversarial:
@py_class(_unique_key("C1"))
class C1(P1):
- c: int # required
+ c: int # required # ty: ignore[dataclass-field-order]
sig = inspect.signature(C1.__init__)
param_names = [n for n in sig.parameters if n != "self"]
@@ -4789,7 +4890,7 @@ class TestInheritanceWithDefaults:
@py_class(_unique_key("DerivedIL"))
class DerivedIL(BaseIL):
- c: int
+ c: int # ty: ignore[dataclass-field-order]
d: Optional[str] = "default"
obj = DerivedIL(a=1, c=2)
@@ -4810,7 +4911,7 @@ class TestInheritanceWithDefaults:
@py_class(_unique_key("L3D"))
class L3D(L2D):
- d: str
+ d: str # ty: ignore[dataclass-field-order]
obj = L3D(a=1, d="world")
assert obj.a == 1
@@ -4975,7 +5076,7 @@ class TestDerivedDerivedContainers:
@py_class(_unique_key("DD_L3"))
class L3(L2):
- e: str
+ e: str # ty: ignore[dataclass-field-order]
obj = L3(a=1, e="world", b=[1, 2])
assert obj.a == 1
@@ -4995,7 +5096,7 @@ class TestDerivedDerivedContainers:
@py_class(_unique_key("DD2_L3"))
class L3(L2):
- c: str
+ c: str # ty: ignore[dataclass-field-order]
obj = L3(a=1, c="x")
assert obj.a == 1
@@ -5686,7 +5787,7 @@ class TestSuperInitPattern:
pass
with pytest.raises(TypeError):
- Plain() # type: ignore[missing-argument]
+ Plain() # ty: ignore[missing-argument]
def test_super_init_isinstance(self) -> None:
"""Objects created via super().__init__() pattern have correct
isinstance."""
@@ -6128,3 +6229,274 @@ class TestPyClassNoLeak:
obj = Mismatch(99)
assert obj.value == 99
assert obj.ref is None
+
+
+def test_selected_parent_type_attrs_are_registered_exactly() -> None:
+ @py_class(_unique_key("AttrParent"))
+ class Parent(Object):
+ __ffi_type_attr_names__ = ("__test_hook__", "__test_metadata__")
+ __ffi_inherit_type_attrs__ = ("__test_hook__",)
+ __test_metadata__ = 42
+
+ def __test_hook__(self) -> int:
+ return 11
+
+ @py_class(_unique_key("AttrChild"))
+ class Child(Parent):
+ value: int
+
+ info = _get_type_info(Child)
+ assert core._lookup_type_attr(info.type_index, "__test_hook__")(Child(7))
== 11
+ assert core._lookup_type_attr(info.type_index, "__test_metadata__") is None
+
+ @py_class(_unique_key("AttrOverride"))
+ class Override(Child):
+ def __test_hook__(self) -> int:
+ return self.value
+
+ assert (
+ core._lookup_type_attr(_get_type_info(Override).type_index,
"__test_hook__")(Override(9))
+ == 9
+ )
+
+ @py_class(_unique_key("AttrOptOut"))
+ class OptOut(Parent):
+ __ffi_inherit_type_attrs__ = ()
+
+ assert core._lookup_type_attr(_get_type_info(OptOut).type_index,
"__test_hook__") is None
+
+ @py_class(_unique_key("AttrNone"))
+ class NoneOverride(Parent):
+ __test_hook__ = None
+
+ assert core._lookup_type_attr(_get_type_info(NoneOverride).type_index,
"__test_hook__") is None
+
+ @py_class(_unique_key("AttrNoneGrandchild"))
+ class NoneGrandchild(NoneOverride):
+ pass
+
+ assert (
+ core._lookup_type_attr(_get_type_info(NoneGrandchild).type_index,
"__test_hook__") is None
+ )
+
+
+def test_parent_structural_hooks_register_after_deferred_fields() -> None:
+ @py_class(_unique_key("StructuralParent"))
+ class Parent(Object):
+ value: int
+
+ def __s_equal__(self, other: Any, compare: Any) -> bool:
+ return self.value % 2 == other.value % 2
+
+ def __s_hash__(self, seed: int, hash_value: Any) -> int:
+ return self.value % 2
+
+ @py_class(_unique_key("StructuralChild"))
+ class Child(Parent):
+ pending: DeferredAttrTarget
+
+ @py_class(_unique_key("DeferredAttrTarget"))
+ class DeferredAttrTarget(Object):
+ value: int
+
+ left = Child(1, DeferredAttrTarget(3))
+ right = Child(5, DeferredAttrTarget(7))
+ assert core._lookup_type_attr(_get_type_info(Child).type_index,
"__s_equal__") is not None
+ assert tvm_ffi.structural_equal(left, right)
+ assert tvm_ffi.structural_hash(left) == tvm_ffi.structural_hash(right)
+
+ @py_class(_unique_key("StructuralOverride"))
+ class Override(Parent):
+ def __s_equal__(self, other: Any, compare: Any) -> bool:
+ return self.value == other.value
+
+ assert not tvm_ffi.structural_equal(Override(1), Override(5))
+
+
+def test_selected_parent_serialization_attrs_are_exact_entries() -> None:
+ @py_class(_unique_key("SerializeParent"))
+ class Parent(Object):
+ value: int
+
+ def __data_to_json__(self) -> str:
+ return str(self.value)
+
+ @staticmethod
+ def __data_from_json__(data: str) -> Object:
+ return Child(int(data))
+
+ @py_class(_unique_key("SerializeChild"))
+ class Child(Parent):
+ pass
+
+ info = _get_type_info(Child)
+ assert core._lookup_type_attr(info.type_index, "__data_to_json__") is not
None
+ assert core._lookup_type_attr(info.type_index, "__data_from_json__") is
not None
+ restored = tvm_ffi.serialization.from_json_graph_str(
+ tvm_ffi.serialization.to_json_graph_str(Child(17))
+ )
+ assert type(restored) is Child
+ assert restored.value == 17
+
+
+class _AnnotationMarker:
+ """An extension-owned annotation marker, without runtime semantics."""
+
+
+class _OtherAnnotationMarker:
+ """A second marker exercises explicit selection and ordering."""
+
+
+def test_extension_field_markers_record_positions_and_inherit() -> None:
+ observed: dict[type, Any] = {}
+
+ @py_class(_unique_key("MarkerBase"))
+ class Base(Object):
+ __ffi_field_markers__ = (_AnnotationMarker, _OtherAnnotationMarker)
+ first: int
+ before: _AnnotationMarker
+ second: int
+ after: _OtherAnnotationMarker = cast(Any, "extension-owned value")
+
+ def __ffi_marker_hook__(info: TypeInfo, own_fields: Any) -> None:
+ assert info.type_cls is not None
+ observed[info.type_cls] = info._decorator_args["field_markers"]
+ assert [f.name for f in own_fields] == (
+ ["first", "second"] if info.type_cls.__name__ == "Base" else
["third"]
+ )
+
+ setattr(__ffi_marker_hook__, "__ffi_on_fields_resolved__", True)
+
+ @py_class(_unique_key("MarkerChild"))
+ class Child(Base):
+ middle: _AnnotationMarker
+ third: int
+
+ assert observed[Base] == (
+ (_AnnotationMarker, Base, "before", 1),
+ (_OtherAnnotationMarker, Base, "after", 2),
+ )
+ assert observed[Child] == (*observed[Base], (_AnnotationMarker, Child,
"middle", 2))
+ assert Base.after == "extension-owned value"
+ assert [f.name for f in fields(Child)] == ["first", "second", "third"]
+ # Runtime marker annotations do not participate in the generated
initializer.
+ assert cast(Any, Child)(1, 2, 3).third == 3
+
+
+def test_extension_field_markers_wait_for_parent_fields() -> None:
+ @py_class(_unique_key("MarkerDeferredParent"))
+ class Parent(Object):
+ __ffi_field_markers__ = (_AnnotationMarker,)
+ first: MarkerLaterValue
+ boundary: _AnnotationMarker
+
+ @py_class(_unique_key("MarkerDeferredChild"))
+ class Child(Parent):
+ second: int
+ boundary2: _AnnotationMarker
+
+ @py_class(_unique_key("MarkerLaterValue"))
+ class MarkerLaterValue(Object):
+ value: int
+
+ value = cast(Any, Child)(MarkerLaterValue(1), 2)
+ assert value.second == 2
+ assert _get_type_info(Child)._decorator_args["field_markers"] == (
+ (_AnnotationMarker, Parent, "boundary", 1),
+ (_AnnotationMarker, Child, "boundary2", 2),
+ )
+
+
+def test_field_metadata_is_copied_and_available_to_extensions() -> None:
+ source: dict[str, Any] = {"example.option": 1, "nested": []}
+ f = field(metadata=source)
+ source["example.option"] = 2
+ assert f.metadata["example.option"] == 1
+ assert f.metadata["nested"] is source["nested"]
+ copied = copy.copy(f)
+ copied.metadata["example.option"] = 3
+ assert f.metadata["example.option"] == 1
+ assert field().metadata == {}
+ assert Field(metadata={"example.option": 4}).metadata ==
{"example.option": 4}
+
+ @py_class(_unique_key("MetadataHook"))
+ class WithMetadata(Object):
+ value: int = field(metadata={"example.option": 5})
+
+ def __ffi_metadata_hook__(info: TypeInfo, own_fields: Any) -> None:
+ own_fields[0].metadata["example.resolved"] = info.type_key
+
+ setattr(__ffi_metadata_hook__, "__ffi_on_fields_resolved__", True)
+
+ resolved = fields(WithMetadata)[0]
+ assert resolved.metadata == {
+ "example.option": 5,
+ "example.resolved": _get_type_info(WithMetadata).type_key,
+ }
+ assert WithMetadata(7).value == 7
+
+
+def test_mixin_field_metadata_changes_do_not_leak_between_classes() -> None:
+ class Mixin:
+ value: int = field(metadata={"example.option": 1})
+
+ @py_class(_unique_key("MetadataMixinA"))
+ class First(Mixin, Object):
+ pass
+
+ @py_class(_unique_key("MetadataMixinB"))
+ class Second(Mixin, Object):
+ pass
+
+ first = fields(First)[0]
+ first.metadata["example.option"] = 9
+ assert fields(Second)[0].metadata["example.option"] == 1
+ assert vars(Mixin)["value"].metadata["example.option"] == 1
+
+
+def test_extension_field_keywords_are_consumed_by_hooks() -> None:
+ @py_class(_unique_key("KeywordBase"))
+ class Base(Object):
+ def __ffi_keyword_hook__(info: TypeInfo, own_fields: Any) -> None:
+ for f in own_fields:
+ if "custom_option" in f.extra_kwargs:
+ f.metadata["example.option"] =
f.extra_kwargs.pop("custom_option")
+
+ setattr(__ffi_keyword_hook__, "__ffi_on_fields_resolved__", True)
+
+ @py_class(_unique_key("KeywordChild"))
+ class Child(Base):
+ value: int = field(custom_option=None)
+
+ assert fields(Child)[0].metadata == {"example.option": None}
+ assert fields(Child)[0].extra_kwargs == {}
+ assert Child(1).value == 1
+
+ with pytest.raises(TypeError, match=r"Broken[.]value: unrecognized field
options: typo"):
+
+ @py_class(_unique_key("KeywordBroken"))
+ class Broken(Base):
+ value: int = field(custom_option=1, typo=2)
+
+ with pytest.raises(
+ TypeError, match=r"Plain[.]value: unrecognized field options:
custom_option"
+ ):
+
+ @py_class(_unique_key("KeywordPlain"))
+ class Plain(Object):
+ value: int = field(custom_option=1)
+
+ original = Field(custom_option=3)
+ copied = copy.copy(original)
+ assert copied.extra_kwargs.pop("custom_option") == 3
+ assert original.extra_kwargs == {"custom_option": 3}
+
+
+def test_raw_initializer_accepts_field_named_self() -> None:
+ @py_class(_unique_key("FieldNamedSelf"), init=False)
+ class Record(Object):
+ self: int
+
+ value = Object.__new__(Record)
+ getattr(Record, "__ffi_init__")(value, self=7)
+ assert value.self == 7