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

Reply via email to