This is an automated email from the ASF dual-hosted git repository.

junrushao 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 528ce7c  feat(dataclass): emit __ffi_init__ stubs, harden field 
registration, and support Python 3.14 (#549)
528ce7c is described below

commit 528ce7cef74e3233d7082dbe017f44c7891fb372
Author: Junru Shao <[email protected]>
AuthorDate: Tue Apr 14 13:41:37 2026 -0700

    feat(dataclass): emit __ffi_init__ stubs, harden field registration, and 
support Python 3.14 (#549)
    
    ## Summary
    
    - **Stub generation**: emit `__ffi_init__` stubs alongside `__init__`
    for all reflected types, sourced from either TypeMethod (explicit
    `refl::init<>`) or synthesized from field metadata (auto-generated
    init).
    - **Field registration hardening**: reject duplicate field names within
    a type (C++ `ICHECK` + Python `assert`) and warn when child fields
    shadow ancestor fields, in both C++ (`object.cc`) and Cython
    (`type_info.pxi`).
    - **Python 3.14 compatibility (PEP 749)**: use `getattr(cls,
    "__annotations__", {})` on 3.14+ where annotations are lazily evaluated
    via `__annotate__`, falling back to `cls.__dict__` on older versions.
    - **Additional fixes**: null-guard `DecRefObjectHandle`,
    `TVM_FFI_UNREACHABLE()` in `creator.h`, `signed char` cast fix for
    clang-tidy, MSVC `/bigobj`, allow non-callable type attrs in
    `_collect_py_methods`, consolidate test skip markers, update stubgen
    docs.
---
 CMakeLists.txt                          |  2 +
 docs/packaging/stubgen.rst              | 11 ++---
 include/tvm/ffi/object.h                |  2 +-
 include/tvm/ffi/reflection/creator.h    |  1 +
 include/tvm/ffi/type_traits.h           |  2 +-
 python/tvm_ffi/cython/type_info.pxi     | 47 +++++++++++++++++-----
 python/tvm_ffi/dataclasses/py_class.py  | 25 +++++++++---
 python/tvm_ffi/structural.py            |  1 +
 python/tvm_ffi/stub/codegen.py          | 11 +++--
 python/tvm_ffi/stub/utils.py            | 71 ++++++++++++++++++++++++++++++---
 python/tvm_ffi/testing/testing.py       | 53 +++++++++++++++++++++---
 src/ffi/extra/json_writer.cc            |  1 -
 src/ffi/object.cc                       | 22 +++++++++-
 src/ffi/testing/testing.cc              |  1 -
 tests/python/test_dataclass_copy.py     |  3 --
 tests/python/test_dataclass_init.py     | 59 +++++++++++++++++++++++++--
 tests/python/test_dataclass_py_class.py | 28 ++++++-------
 tests/python/test_stubgen.py            |  9 ++---
 tests/python/test_type_converter.py     |  8 ++--
 19 files changed, 286 insertions(+), 71 deletions(-)

diff --git a/CMakeLists.txt b/CMakeLists.txt
index 427a106..7b09ee4 100644
--- a/CMakeLists.txt
+++ b/CMakeLists.txt
@@ -150,6 +150,8 @@ if (MSVC)
   target_link_libraries(tvm_ffi_objs PRIVATE DbgHelp.lib)
   target_link_libraries(tvm_ffi_shared PRIVATE DbgHelp.lib)
   target_link_libraries(tvm_ffi_static PRIVATE DbgHelp.lib)
+  # /bigobj: printer.cc exceeds default section limit
+  target_compile_options(tvm_ffi_objs PRIVATE /bigobj)
   # produce pdb file
   target_link_options(tvm_ffi_shared PRIVATE /DEBUG)
 endif ()
diff --git a/docs/packaging/stubgen.rst b/docs/packaging/stubgen.rst
index 789ae28..1e4ff1e 100644
--- a/docs/packaging/stubgen.rst
+++ b/docs/packaging/stubgen.rst
@@ -79,8 +79,8 @@ and regenerates the content within those blocks:
          a: int
          b: int
          if TYPE_CHECKING:
-             @staticmethod
-             def __c_ffi_init__(_0: int, _1: int, /) -> Object: ...
+             def __init__(self, a: int, b: int) -> None: ...
+             def __ffi_init__(self, a: int, b: int) -> None: ...
              def sum(self, /) -> int: ...
          # tvm-ffi-stubgen(end)
 
@@ -147,8 +147,8 @@ STUB_PREFIX (default: ``<STUB_PKG>.``)
             a: int
             b: int
             if TYPE_CHECKING:
-                @staticmethod
-                def __c_ffi_init__(_0: int, _1: int, /) -> Object: ...
+                def __init__(self, a: int, b: int) -> None: ...
+                def __ffi_init__(self, a: int, b: int) -> None: ...
                 def sum(self, /) -> int: ...
             # tvm-ffi-stubgen(end)
 
@@ -320,7 +320,8 @@ When you run the tool, it:
           b: int
           if TYPE_CHECKING:
               def __init__(self, a: int, b: int) -> None: ...
-              def sum(self) -> int: ...
+              def __ffi_init__(self, a: int, b: int) -> None: ...
+              def sum(self, /) -> int: ...
           # fmt: on
           # tvm-ffi-stubgen(end)
 
diff --git a/include/tvm/ffi/object.h b/include/tvm/ffi/object.h
index c95f2bd..9b22636 100644
--- a/include/tvm/ffi/object.h
+++ b/include/tvm/ffi/object.h
@@ -1178,7 +1178,7 @@ struct ObjectUnsafe {
   }
 
   TVM_FFI_INLINE static void DecRefObjectHandle(TVMFFIObjectHandle handle) {
-    reinterpret_cast<Object*>(handle)->DecRef();
+    if (handle) reinterpret_cast<Object*>(handle)->DecRef();
   }
 
   TVM_FFI_INLINE static void IncRefObjectHandle(TVMFFIObjectHandle handle) {
diff --git a/include/tvm/ffi/reflection/creator.h 
b/include/tvm/ffi/reflection/creator.h
index cf8fb09..ff1591d 100644
--- a/include/tvm/ffi/reflection/creator.h
+++ b/include/tvm/ffi/reflection/creator.h
@@ -63,6 +63,7 @@ inline ObjectPtr<Object> CreateEmptyObject(const 
TVMFFITypeInfo* type_info) {
   TVM_FFI_THROW(RuntimeError) << "Type `" << 
TypeIndexToTypeKey(type_info->type_index)
                               << "` does not support reflection creation"
                               << " (no native creator or __ffi_new__ type 
attr)";
+  TVM_FFI_UNREACHABLE();
 }
 
 /*!
diff --git a/include/tvm/ffi/type_traits.h b/include/tvm/ffi/type_traits.h
index e832559..21afde0 100644
--- a/include/tvm/ffi/type_traits.h
+++ b/include/tvm/ffi/type_traits.h
@@ -290,7 +290,7 @@ struct TypeTraits<Int, 
std::enable_if_t<std::is_integral_v<Int>>> : public TypeT
     }
     result->type_index = TypeIndex::kTVMFFIInt;
     result->zero_padding = 0;
-    result->v_int64 = static_cast<int64_t>(src);
+    result->v_int64 = static_cast<int64_t>(src);  // 
NOLINT(bugprone-signed-char-misuse)
   }
 
   TVM_FFI_INLINE static void MoveToAny(Int src, TVMFFIAny* result) { 
CopyToAnyView(src, result); }
diff --git a/python/tvm_ffi/cython/type_info.pxi 
b/python/tvm_ffi/cython/type_info.pxi
index a8c3cd3..7b799b3 100644
--- a/python/tvm_ffi/cython/type_info.pxi
+++ b/python/tvm_ffi/cython/type_info.pxi
@@ -698,12 +698,39 @@ class TypeInfo:
     def __post_init__(self):
         cdef int parent_type_index
         cdef str parent_type_key
+        # Assert no duplicate field names within this type's own fields.
+        if self.fields is not None:
+            seen = set()
+            for f in self.fields:
+                assert f.name not in seen, (
+                    f"duplicate field name {f.name!r} in TypeInfo for 
{self.type_key!r}; "
+                    f"TypeInfo.fields must only contain the type's own fields"
+                )
+                seen.add(f.name)
         if not self.type_ancestors:
             return
         parent_type_index = self.type_ancestors[-1]
         parent_type_key = _type_index_to_key(parent_type_index)
         # ensure parent is registered
         self.parent_type_info = 
_lookup_or_register_type_info_from_type_key(parent_type_key)
+        # Warn if own fields shadow any ancestor field.
+        if self.fields and self.parent_type_info is not None:
+            parent_names = set()
+            ti = self.parent_type_info
+            while ti is not None:
+                if ti.fields:
+                    for f in ti.fields:
+                        parent_names.add(f.name)
+                ti = ti.parent_type_info
+            for f in self.fields:
+                if f.name in parent_names:
+                    import warnings
+                    warnings.warn(
+                        f"Field {f.name!r} in {self.type_key!r} duplicates "
+                        f"an ancestor field. Child types should not "
+                        f"re-register inherited fields.",
+                        stacklevel=2,
+                    )
 
     @cached_property
     def total_size(self) -> int:
@@ -750,7 +777,8 @@ class TypeInfo:
         """Register user-defined dunder hooks and re-read the method table.
 
         Each entry whose name is in *type_attr_names* is registered as a
-        TypeAttrColumn entry (for C++ dispatch); all other entries are
+        TypeAttrColumn entry (for C++ dispatch); the value need not be
+        callable (e.g. ``__ffi_ir_traits__``).  All other entries are
         registered as TypeMethod (for reflection introspection).
 
         Regardless, the full method list is always re-read from the C
@@ -759,8 +787,8 @@ class TypeInfo:
 
         Parameters
         ----------
-        py_methods : list[tuple[str, callable, bool]] | None
-            Each entry is ``(name, func, is_static)``.
+        py_methods : list[tuple[str, Any, bool]] | None
+            Each entry is ``(name, value, is_static)``.
         type_attr_names : frozenset[str]
             Names to register as TypeAttrColumn instead of TypeMethod.
         """
@@ -1060,12 +1088,13 @@ cdef _register_type_metadata(int32_t type_index, 
int32_t total_size, int structu
 
 
 cdef _register_py_methods(int32_t type_index, list py_methods, frozenset 
type_attr_names):
-    """Register user-defined methods as TypeAttrColumn or TypeMethod.
+    """Register user-defined methods and type attrs as TypeAttrColumn or 
TypeMethod.
 
     For each entry in *py_methods*:
-    1. Convert the Python callable to a ``TVMFFIAny`` (``ffi::Function``).
+    1. Convert the Python object to a ``TVMFFIAny``.
     2. If the name is in *type_attr_names*, register as TypeAttrColumn
-       (for C++ dispatch via ``TypeAttrColumn``).
+       (for C++ dispatch via ``TypeAttrColumn``).  The value need not be
+       callable (e.g. ``__ffi_ir_traits__`` is an Object instance).
     3. Otherwise, register as TypeMethod (for reflection introspection
        via ``TypeInfo.methods``).
 
@@ -1073,8 +1102,8 @@ cdef _register_py_methods(int32_t type_index, list 
py_methods, frozenset type_at
     ----------
     type_index : int
         The runtime type index of the type.
-    py_methods : list[tuple[str, callable, bool]]
-        Each entry is ``(name, func, is_static)``.
+    py_methods : list[tuple[str, Any, bool]]
+        Each entry is ``(name, value, is_static)``.
     type_attr_names : frozenset[str]
         Names to register as TypeAttrColumn instead of TypeMethod.
     """
@@ -1094,7 +1123,7 @@ cdef _register_py_methods(int32_t type_index, list 
py_methods, frozenset type_at
             name_bytes = c_str(name)
             name_arg = ByteArrayArg(name_bytes)
 
-            # Convert Python callable -> TVMFFIAny (creates a FunctionObj)
+            # Convert Python object -> TVMFFIAny
             TVMFFIPyPyObjectToFFIAny(
                 TVMFFIPyArgSetterFactory_,
                 <PyObject*>func,
diff --git a/python/tvm_ffi/dataclasses/py_class.py 
b/python/tvm_ffi/dataclasses/py_class.py
index ae9bd3b..a85965c 100644
--- a/python/tvm_ffi/dataclasses/py_class.py
+++ b/python/tvm_ffi/dataclasses/py_class.py
@@ -85,7 +85,6 @@ _PENDING_CLASSES: list[_PendingClass] = []
 #: variable by Python.
 _PY_CLASS_BY_MODULE: dict[str, dict[str, type]] = {}
 
-
 # ---------------------------------------------------------------------------
 # Phase 1: type registration
 # ---------------------------------------------------------------------------
@@ -151,7 +150,15 @@ def _collect_own_fields(  # noqa: PLR0912
     """
     fields: list[Field] = []
     kw_only_active = decorator_kw_only
-    own_annotations: dict[str, str] = cls.__dict__.get("__annotations__", {})
+    # Python 3.14+ (PEP 749): annotations are lazily evaluated via
+    # __annotate__ and no longer stored directly in __dict__.  getattr()
+    # triggers evaluation and returns per-class annotations correctly.
+    # On Python < 3.14, getattr() follows MRO and returns *parent*
+    # annotations when the child has none — use __dict__ to avoid that.
+    if sys.version_info >= (3, 14):
+        own_annotations: dict[str, str] = getattr(cls, "__annotations__", {})
+    else:
+        own_annotations = cls.__dict__.get("__annotations__", {})
 
     for name in own_annotations:
         resolved_type = hints.get(name)
@@ -209,12 +216,15 @@ def _collect_own_fields(  # noqa: PLR0912
 
 
 def _collect_py_methods(cls: type) -> list[tuple[str, Any, bool]] | None:
-    """Extract recognized FFI dunder methods from the class body.
+    """Extract recognized FFI dunder methods and type attrs from the class 
body.
 
     Only names listed in :data:`_FFI_RECOGNIZED_METHODS` are collected.
+    Callables are collected with their ``is_static`` flag; non-callable
+    values (e.g. ``__ffi_ir_traits__``) are collected as-is — the Cython
+    layer routes them to ``TVMFFITypeRegisterAttr`` based on name.
 
-    Returns a list of ``(name, func, is_static)`` tuples, or ``None``
-    if no eligible methods were found.
+    Returns a list of ``(name, value, is_static)`` tuples, or ``None``
+    if no eligible entries were found.
     """
     methods: list[tuple[str, Any, bool]] = []
     for name, value in cls.__dict__.items():
@@ -227,7 +237,8 @@ def _collect_py_methods(cls: type) -> list[tuple[str, Any, 
bool]] | None:
             func = value
             is_static = False
         else:
-            continue
+            func = value
+            is_static = False
         methods.append((name, func, is_static))
     return methods if methods else None
 
@@ -260,6 +271,8 @@ def _register_fields_into_type(
     structure_kind = _STRUCTURE_KIND_MAP.get(params.get("structural_eq"))
     type_info._register_fields(own_fields, structure_kind)
     # 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)
 
diff --git a/python/tvm_ffi/structural.py b/python/tvm_ffi/structural.py
index 941fbda..54434d8 100644
--- a/python/tvm_ffi/structural.py
+++ b/python/tvm_ffi/structural.py
@@ -207,6 +207,7 @@ class StructuralKey(Object):
     hash_i64: int
     if TYPE_CHECKING:
         def __init__(self, key: Any, hash_i64: int) -> None: ...
+        def __ffi_init__(self, _0: Any, /) -> None: ...  # ty: 
ignore[invalid-method-override]
     # fmt: on
     # tvm-ffi-stubgen(end)
 
diff --git a/python/tvm_ffi/stub/codegen.py b/python/tvm_ffi/stub/codegen.py
index 6ca36f4..bc31103 100644
--- a/python/tvm_ffi/stub/codegen.py
+++ b/python/tvm_ffi/stub/codegen.py
@@ -110,7 +110,13 @@ def generate_object(
     method_names = {m.schema.name.rsplit(".", 1)[-1] for m in info.methods}
     fn_ty_map = _type_suffix_and_record(ty_map, imports, 
func_names=method_names)
     init_lines = info.gen_init(fn_ty_map, indent=opt.indent)
-    if info.methods or init_lines:
+    ffi_init_lines = info.gen_ffi_init(fn_ty_map, indent=opt.indent)
+    type_checking_lines = [
+        *init_lines,
+        *ffi_init_lines,
+        *info.gen_methods(fn_ty_map, indent=opt.indent),
+    ]
+    if type_checking_lines:
         imports.append(
             ImportItem(
                 "typing.TYPE_CHECKING",
@@ -121,8 +127,7 @@ def generate_object(
             "# fmt: off",
             *info.gen_fields(fn_ty_map, indent=0),
             "if TYPE_CHECKING:",
-            *init_lines,
-            *info.gen_methods(fn_ty_map, indent=opt.indent),
+            *type_checking_lines,
             "# fmt: on",
         ]
     else:
diff --git a/python/tvm_ffi/stub/utils.py b/python/tvm_ffi/stub/utils.py
index 935a3e0..5ff79d2 100644
--- a/python/tvm_ffi/stub/utils.py
+++ b/python/tvm_ffi/stub/utils.py
@@ -213,8 +213,10 @@ class ObjectInfo:
         if type_info.parent_type_info is not None:
             parent_type_key = type_info.parent_type_info.type_key
 
-        # Detect __ffi_init__ from TypeAttrColumn.
-        has_init = _lookup_type_attr(type_info.type_index, "__ffi_init__") is 
not None
+        # Detect __ffi_init__ from TypeMethod or TypeAttrColumn.
+        has_init = any(m.name == "__ffi_init__" for m in type_info.methods)
+        if not has_init:
+            has_init = _lookup_type_attr(type_info.type_index, "__ffi_init__") 
is not None
 
         # Walk parent chain (parent-first) to collect all init-eligible fields.
         init_fields: list[InitFieldInfo] = []
@@ -274,21 +276,62 @@ class ObjectInfo:
         indent_str = " " * indent
         ret = []
         for method in self.methods:
-            # __ffi_init__ is an internal protocol consumed by _gen_c_init to
-            # produce __init__; hide it from the public method stubs.
             func_name = method.schema.name.rsplit(".", 1)[-1]
             if func_name == "__ffi_init__":
+                # __ffi_init__ is installed as an instance method (self, 
*args, **kwargs) -> None
+                # by _install_ffi_init_attr, regardless of the C++ static 
registration.
+                ret.append(self._gen_ffi_init_from_method(method, ty_map, 
indent))
                 continue
             if not method.is_member:
                 ret.append(f"{indent_str}@staticmethod")
             ret.append(method.gen(ty_map, indent))
         return ret
 
+    @staticmethod
+    def _gen_ffi_init_from_method(
+        method: FuncInfo, ty_map: Callable[[str], str], indent: int
+    ) -> str:
+        """Render ``__ffi_init__`` TypeMethod as an instance method returning 
None."""
+        indent_str = " " * indent
+        schema = method.schema
+        # Subclass __ffi_init__ signatures legitimately differ from the parent
+        # (different fields → different constructor params), so suppress LSP.
+        ignore = "  # ty: ignore[invalid-method-override]"
+        if schema.origin != "Callable" or not schema.args:
+            ty_map("Any")
+            return f"{indent_str}def __ffi_init__(self, *args: Any) -> None: 
...{ignore}"
+        # schema.args[0] is return type, schema.args[1:] are param types.
+        parts: list[str] = []
+        for i, arg in enumerate(schema.args[1:]):
+            parts.append(f"_{i}: {arg.repr(ty_map)}")
+        if parts:
+            params = ", ".join(parts)
+            return f"{indent_str}def __ffi_init__(self, {params}, /) -> None: 
...{ignore}"
+        return f"{indent_str}def __ffi_init__(self) -> None: ...{ignore}"
+
+    def gen_ffi_init(self, ty_map: Callable[[str], str], indent: int) -> 
list[str]:
+        """Generate a ``__ffi_init__`` stub when it's not already in 
TypeMethod.
+
+        For types whose ``__ffi_init__`` is auto-generated by 
``RegisterFFIInit``
+        (TypeAttrColumn only), synthesize a static-method stub from field 
metadata.
+        Types that already have ``__ffi_init__`` in TypeMethod (from explicit
+        ``refl::init<>``) get it via ``gen_methods`` instead.
+        """
+        if not self.has_init:
+            return []
+        # If __ffi_init__ is already in methods (from TypeMethod), gen_methods 
handles it.
+        if any(m.schema.name.rsplit(".", 1)[-1] == "__ffi_init__" for m in 
self.methods):
+            return []
+        return self._gen_ffi_init_from_fields(ty_map, indent)
+
     def gen_init(self, ty_map: Callable[[str], str], indent: int) -> list[str]:
         """Generate an ``__init__`` stub from init-eligible field metadata."""
         if not self.has_init:
             return []
-        indent_str = " " * indent
+        return self._gen_init_from_fields(ty_map, indent)
+
+    def _format_field_params(self, ty_map: Callable[[str], str]) -> str:
+        """Format init-eligible fields as a parameter string with defaults and 
kw_only."""
         positional = [f for f in self.init_fields if not f.kw_only]
         kw_only = [f for f in self.init_fields if f.kw_only]
 
@@ -309,7 +352,23 @@ class ObjectInfo:
             for f in kw_default:
                 parts.append(f"{f.name}: {f.schema.repr(ty_map)} = ...")
 
-        params = ", ".join(parts)
+        return ", ".join(parts)
+
+    def _gen_init_from_fields(self, ty_map: Callable[[str], str], indent: int) 
-> list[str]:
+        """Generate ``__init__`` from init-eligible field metadata 
(auto-generated init)."""
+        indent_str = " " * indent
+        params = self._format_field_params(ty_map)
         if params:
             return [f"{indent_str}def __init__(self, {params}) -> None: ..."]
         return [f"{indent_str}def __init__(self) -> None: ..."]
+
+    def _gen_ffi_init_from_fields(self, ty_map: Callable[[str], str], indent: 
int) -> list[str]:
+        """Generate ``__ffi_init__`` stub from field metadata for 
auto-generated init."""
+        indent_str = " " * indent
+        # Subclass __ffi_init__ signatures legitimately differ from the parent
+        # (different fields → different constructor params), so suppress LSP.
+        ignore = "  # ty: ignore[invalid-method-override]"
+        params = self._format_field_params(ty_map)
+        if params:
+            return [f"{indent_str}def __ffi_init__(self, {params}) -> None: 
...{ignore}"]
+        return [f"{indent_str}def __ffi_init__(self) -> None: ...{ignore}"]
diff --git a/python/tvm_ffi/testing/testing.py 
b/python/tvm_ffi/testing/testing.py
index 69e7d84..c5b773a 100644
--- a/python/tvm_ffi/testing/testing.py
+++ b/python/tvm_ffi/testing/testing.py
@@ -30,13 +30,33 @@ if TYPE_CHECKING:
 # fmt: on
 # tvm-ffi-stubgen(end)
 
-from typing import ClassVar
+import sys
+from typing import Any, ClassVar
+
+import pytest
+
+from tvm_ffi import Object, get_global_func
+from tvm_ffi.dataclasses import c_class
 
 from .. import _ffi_api
 from .. import core as tvm_ffi_core
-from ..core import Object
-from ..dataclasses import c_class
-from ..registry import get_global_func
+
+requires_py39 = pytest.mark.skipif(
+    sys.version_info < (3, 9),
+    reason="requires Python 3.9+",
+)
+requires_py310 = pytest.mark.skipif(
+    sys.version_info < (3, 10),
+    reason="requires Python 3.10+",
+)
+requires_py312 = pytest.mark.skipif(
+    sys.version_info < (3, 12),
+    reason="requires Python 3.12+",
+)
+requires_py313 = pytest.mark.skipif(
+    sys.version_info < (3, 13),
+    reason="requires Python 3.13+",
+)
 
 
 @c_class("testing.TestObjectBase")
@@ -44,6 +64,7 @@ class TestObjectBase(Object):
     """Test object base class."""
 
     __test__ = False
+
     # tvm-ffi-stubgen(begin): object/testing.TestObjectBase
     # fmt: off
     v_i64: int
@@ -51,6 +72,7 @@ class TestObjectBase(Object):
     v_str: str
     if TYPE_CHECKING:
         def __init__(self, v_i64: int = ..., v_f64: float = ..., v_str: str = 
...) -> None: ...
+        def __ffi_init__(self, v_i64: int = ..., v_f64: float = ..., v_str: 
str = ...) -> None: ...  # ty: ignore[invalid-method-override]
         def add_i64(self, _1: int, /) -> int: ...
     # fmt: on
     # tvm-ffi-stubgen(end)
@@ -60,7 +82,7 @@ class TestObjectBase(Object):
 class TestIntPair(Object):
     """Test Int Pair."""
 
-    __test__ = False
+    __test__: ClassVar[bool] = False
 
     # tvm-ffi-stubgen(begin): object/testing.TestIntPair
     # fmt: off
@@ -68,6 +90,7 @@ class TestIntPair(Object):
     b: int
     if TYPE_CHECKING:
         def __init__(self, a: int, b: int) -> None: ...
+        def __ffi_init__(self, _0: int, _1: int, /) -> None: ...  # ty: 
ignore[invalid-method-override]
         def sum(self, /) -> int: ...
     # fmt: on
     # tvm-ffi-stubgen(end)
@@ -78,12 +101,14 @@ class TestObjectDerived(TestObjectBase):
     """Test object derived class."""
 
     __test__ = False
+
     # tvm-ffi-stubgen(begin): object/testing.TestObjectDerived
     # fmt: off
     v_map: Mapping[Any, Any]
     v_array: Sequence[Any]
     if TYPE_CHECKING:
         def __init__(self, v_map: Mapping[Any, Any], v_array: Sequence[Any], 
v_i64: int = ..., v_f64: float = ..., v_str: str = ...) -> None: ...
+        def __ffi_init__(self, v_map: Mapping[Any, Any], v_array: 
Sequence[Any], v_i64: int = ..., v_f64: float = ..., v_str: str = ...) -> None: 
...  # ty: ignore[invalid-method-override]
     # fmt: on
     # tvm-ffi-stubgen(end)
 
@@ -93,11 +118,13 @@ class TestNonCopyable(Object):
     """Test object with deleted copy constructor."""
 
     __test__ = False
+
     # tvm-ffi-stubgen(begin): object/testing.TestNonCopyable
     # fmt: off
     value: int
     if TYPE_CHECKING:
         def __init__(self, value: int) -> None: ...
+        def __ffi_init__(self, _0: int, /) -> None: ...  # ty: 
ignore[invalid-method-override]
     # fmt: on
     # tvm-ffi-stubgen(end)
 
@@ -184,6 +211,7 @@ class TestCompare(Object):
     ignored_field: int
     if TYPE_CHECKING:
         def __init__(self, key: int, name: str, ignored_field: int) -> None: 
...
+        def __ffi_init__(self, _0: int, _1: str, _2: int, /) -> None: ...  # 
ty: ignore[invalid-method-override]
     # fmt: on
     # tvm-ffi-stubgen(end)
 
@@ -201,6 +229,7 @@ class TestHash(Object):
     hash_ignored: int
     if TYPE_CHECKING:
         def __init__(self, key: int, name: str, hash_ignored: int) -> None: ...
+        def __ffi_init__(self, _0: int, _1: str, _2: int, /) -> None: ...  # 
ty: ignore[invalid-method-override]
     # fmt: on
     # tvm-ffi-stubgen(end)
 
@@ -217,6 +246,7 @@ class TestCustomHash(Object):
     label: str
     if TYPE_CHECKING:
         def __init__(self, key: int, label: str) -> None: ...
+        def __ffi_init__(self, _0: int, _1: str, /) -> None: ...  # ty: 
ignore[invalid-method-override]
     # fmt: on
     # tvm-ffi-stubgen(end)
 
@@ -233,6 +263,7 @@ class TestCustomCompare(Object):
     label: str
     if TYPE_CHECKING:
         def __init__(self, key: int, label: str) -> None: ...
+        def __ffi_init__(self, _0: int, _1: str, /) -> None: ...  # ty: 
ignore[invalid-method-override]
     # fmt: on
     # tvm-ffi-stubgen(end)
 
@@ -249,6 +280,7 @@ class TestEqWithoutHash(Object):
     label: str
     if TYPE_CHECKING:
         def __init__(self, key: int, label: str) -> None: ...
+        def __ffi_init__(self, _0: int, _1: str, /) -> None: ...  # ty: 
ignore[invalid-method-override]
     # fmt: on
     # tvm-ffi-stubgen(end)
 
@@ -262,6 +294,7 @@ class _TestCxxClassBase(Object):
     v_i32: int
     if TYPE_CHECKING:
         def __init__(self, v_i64: int, v_i32: int) -> None: ...
+        def __ffi_init__(self, v_i64: int, v_i32: int) -> None: ...  # ty: 
ignore[invalid-method-override]
     # fmt: on
     # tvm-ffi-stubgen(end)
     not_field_1 = 1
@@ -282,6 +315,7 @@ class _TestCxxClassDerived(_TestCxxClassBase):
     v_f32: float
     if TYPE_CHECKING:
         def __init__(self, v_i64: int, v_i32: int, v_f64: float, v_f32: float 
= ...) -> None: ...
+        def __ffi_init__(self, v_i64: int, v_i32: int, v_f64: float, v_f32: 
float = ...) -> None: ...  # ty: ignore[invalid-method-override]
     # fmt: on
     # tvm-ffi-stubgen(end)
 
@@ -295,6 +329,7 @@ class _TestCxxClassDerivedDerived(_TestCxxClassDerived):
     v_bool: bool
     if TYPE_CHECKING:
         def __init__(self, v_i64: int, v_i32: int, v_f64: float, v_bool: bool, 
v_f32: float = ..., v_str: str = ...) -> None: ...
+        def __ffi_init__(self, v_i64: int, v_i32: int, v_f64: float, v_bool: 
bool, v_f32: float = ..., v_str: str = ...) -> None: ...  # ty: 
ignore[invalid-method-override]
     # fmt: on
     # tvm-ffi-stubgen(end)
 
@@ -309,6 +344,7 @@ class _TestCxxInitSubset(Object):
     note: str
     if TYPE_CHECKING:
         def __init__(self, required_field: int) -> None: ...
+        def __ffi_init__(self, required_field: int) -> None: ...  # ty: 
ignore[invalid-method-override]
     # fmt: on
     # tvm-ffi-stubgen(end)
 
@@ -324,6 +360,7 @@ class _TestCxxKwOnly(Object):
     w: int
     if TYPE_CHECKING:
         def __init__(self, *, x: int, y: int, z: int, w: int = ...) -> None: 
...
+        def __ffi_init__(self, *, x: int, y: int, z: int, w: int = ...) -> 
None: ...  # ty: ignore[invalid-method-override]
     # fmt: on
     # tvm-ffi-stubgen(end)
 
@@ -343,6 +380,7 @@ class _TestCxxAutoInit(Object):
     d: int
     if TYPE_CHECKING:
         def __init__(self, a: int, d: int = ..., *, c: int) -> None: ...
+        def __ffi_init__(self, a: int, d: int = ..., *, c: int) -> None: ...  
# ty: ignore[invalid-method-override]
     # fmt: on
     # tvm-ffi-stubgen(end)
 
@@ -360,6 +398,7 @@ class _TestCxxAutoInitSimple(Object):
     y: int
     if TYPE_CHECKING:
         def __init__(self, x: int, y: int) -> None: ...
+        def __ffi_init__(self, x: int, y: int) -> None: ...  # ty: 
ignore[invalid-method-override]
     # fmt: on
     # tvm-ffi-stubgen(end)
 
@@ -378,6 +417,7 @@ class _TestCxxAutoInitAllInitOff(Object):
     z: int
     if TYPE_CHECKING:
         def __init__(self) -> None: ...
+        def __ffi_init__(self) -> None: ...  # ty: 
ignore[invalid-method-override]
     # fmt: on
     # tvm-ffi-stubgen(end)
 
@@ -398,6 +438,7 @@ class _TestCxxAutoInitKwOnlyDefaults(Object):
     hidden: int
     if TYPE_CHECKING:
         def __init__(self, p_required: int, p_default: int = ..., *, 
k_required: int, k_default: int = ...) -> None: ...
+        def __ffi_init__(self, p_required: int, p_default: int = ..., *, 
k_required: int, k_default: int = ...) -> None: ...  # ty: 
ignore[invalid-method-override]
     # fmt: on
     # tvm-ffi-stubgen(end)
 
@@ -430,6 +471,7 @@ class _TestCxxAutoInitParent(Object):
     parent_default: int
     if TYPE_CHECKING:
         def __init__(self, parent_required: int, parent_default: int = ...) -> 
None: ...
+        def __ffi_init__(self, parent_required: int, parent_default: int = 
...) -> None: ...  # ty: ignore[invalid-method-override]
     # fmt: on
     # tvm-ffi-stubgen(end)
 
@@ -447,5 +489,6 @@ class _TestCxxAutoInitChild(_TestCxxAutoInitParent):
     child_kw_only: int
     if TYPE_CHECKING:
         def __init__(self, parent_required: int, child_required: int, 
parent_default: int = ..., *, child_kw_only: int) -> None: ...
+        def __ffi_init__(self, parent_required: int, child_required: int, 
parent_default: int = ..., *, child_kw_only: int) -> None: ...  # ty: 
ignore[invalid-method-override]
     # fmt: on
     # tvm-ffi-stubgen(end)
diff --git a/src/ffi/extra/json_writer.cc b/src/ffi/extra/json_writer.cc
index 3061e31..240360d 100644
--- a/src/ffi/extra/json_writer.cc
+++ b/src/ffi/extra/json_writer.cc
@@ -34,7 +34,6 @@
 #include <cinttypes>
 #include <cmath>
 #include <cstdint>
-#include <limits>
 #include <string>
 #include <unordered_set>
 #include <utility>
diff --git a/src/ffi/object.cc b/src/ffi/object.cc
index ce2aab9..b3ed6b5 100644
--- a/src/ffi/object.cc
+++ b/src/ffi/object.cc
@@ -36,7 +36,7 @@
 #include <tvm/ffi/reflection/registry.h>
 #include <tvm/ffi/string.h>
 
-#include <memory>
+#include <string_view>
 #include <utility>
 #include <vector>
 
@@ -215,6 +215,26 @@ class TypeTable {
 
   void RegisterTypeField(int32_t type_index, const TVMFFIFieldInfo* info) {
     Entry* entry = GetTypeEntry(type_index);
+    std::string_view new_name(info->name.data, info->name.size);
+    std::string_view type_key(entry->type_key.data, entry->type_key.size);
+    // Check: no duplicate field name within this type's own fields.
+    for (const auto& existing : entry->type_fields_data) {
+      TVM_FFI_ICHECK(std::string_view(existing.name.data, existing.name.size) 
!= new_name)
+          << "Duplicate field name \"" << new_name << "\" in type \"" << 
type_key << "\"";
+    }
+    // Warn: field name should not shadow any ancestor field.
+    for (int32_t d = 0; d < entry->type_depth; ++d) {
+      const TVMFFITypeInfo* ancestor = entry->type_ancestors[d];
+      for (int32_t i = 0; i < ancestor->num_fields; ++i) {
+        if (std::string_view(ancestor->fields[i].name.data, 
ancestor->fields[i].name.size) ==
+            new_name) {
+          std::cerr << "[WARNING] Field \"" << new_name << "\" in type \"" << 
type_key
+                    << "\" duplicates an ancestor field in \""
+                    << std::string_view(ancestor->type_key.data, 
ancestor->type_key.size)
+                    << "\". Child types should not re-register inherited 
fields." << std::endl;
+        }
+      }
+    }
     TVMFFIFieldInfo field_data = *info;
     // Retain FunctionObj setter via any_pool_ so it outlives the Entry.
     if ((field_data.flags & kTVMFFIFieldFlagBitSetterIsFunctionObj) &&
diff --git a/src/ffi/testing/testing.cc b/src/ffi/testing/testing.cc
index 2751f83..9418580 100644
--- a/src/ffi/testing/testing.cc
+++ b/src/ffi/testing/testing.cc
@@ -478,7 +478,6 @@ TVM_FFI_STATIC_INIT_BLOCK() {
 
   refl::ObjectDef<TestUnregisteredObject>()
       .def(refl::init<int64_t, int64_t>(), "Constructor of 
TestUnregisteredObject")
-      .def_ro("v1", &TestUnregisteredObject::v1)
       .def_ro("v2", &TestUnregisteredObject::v2)
       .def("get_v2_plus_two", &TestUnregisteredObject::GetV2PlusTwo,
            "Get (v2 + 2) from TestUnregisteredObject");
diff --git a/tests/python/test_dataclass_copy.py 
b/tests/python/test_dataclass_copy.py
index 73b1287..8324ee4 100644
--- a/tests/python/test_dataclass_copy.py
+++ b/tests/python/test_dataclass_copy.py
@@ -22,7 +22,6 @@ from __future__ import annotations
 import copy
 import itertools
 import pickle
-import sys
 from typing import Dict, List, Optional
 
 import pytest
@@ -32,8 +31,6 @@ from tvm_ffi._ffi_api import DeepCopy
 from tvm_ffi.core import Object
 from tvm_ffi.dataclasses import py_class
 
-_needs_310 = pytest.mark.skipif(sys.version_info < (3, 10), reason="X | Y 
syntax requires 3.10+")
-
 _counter_pc = itertools.count()
 
 
diff --git a/tests/python/test_dataclass_init.py 
b/tests/python/test_dataclass_init.py
index 62d0b1e..b39c73b 100644
--- a/tests/python/test_dataclass_init.py
+++ b/tests/python/test_dataclass_init.py
@@ -31,11 +31,11 @@ from __future__ import annotations
 
 import copy
 import inspect
-import sys
 from typing import Any
 
 import pytest
 from tvm_ffi import core
+from tvm_ffi.dataclasses import py_class
 from tvm_ffi.testing import (
     TestCompare,
     TestHash,
@@ -50,6 +50,7 @@ from tvm_ffi.testing import (
     _TestCxxClassDerivedDerived,
     _TestCxxNoAutoInit,
 )
+from tvm_ffi.testing.testing import requires_py313
 
 
 def _ffi_init(obj: Any, *args: Any) -> None:
@@ -671,7 +672,7 @@ class TestAutoInitCopyBehavior:
         assert obj_copy.y == 20
         assert not obj.same_as(obj_copy)
 
-    @pytest.mark.skipif(sys.version_info < (3, 13), reason="copy.replace 
requires Python 3.13+")
+    @requires_py313
     def test_replace(self) -> None:
         obj = _TestCxxAutoInit(1, c=3)
         replaced = copy.replace(obj, a=100, c=300)  # type: 
ignore[attr-defined]
@@ -707,7 +708,7 @@ class TestAutoInitCopyBehavior:
         assert obj_copy.z == 333
         assert not obj.same_as(obj_copy)
 
-    @pytest.mark.skipif(sys.version_info < (3, 13), reason="copy.replace 
requires Python 3.13+")
+    @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]
@@ -1035,7 +1036,7 @@ class TestFfiInitAsTypeMethod:
                 assert method.func is not None
                 break
         else:
-            pytest.fail(f"__ffi_init__ not found in {cls.__name__} methods")
+            pytest.fail(f"__ffi_init__ not found in {cls.__name__} methods")  
# ty: ignore[invalid-argument-type]
 
 
 class TestFfiInitAsInstanceMethod:
@@ -1145,3 +1146,53 @@ class TestFfiInitDualRegistration:
         obj2.__init_handle_by_constructor__(attr_func, 10, 20)
         assert obj1.a == obj2.a == 10
         assert obj1.b == obj2.b == 20
+
+
+# ###########################################################################
+#  Python 3.14 annotation regression (PEP 749)
+#
+#  Python 3.14 stores annotations lazily via __annotate_func__ instead of
+#  directly in cls.__dict__["__annotations__"].  This broke @py_class field
+#  discovery when it used cls.__dict__.get("__annotations__", {}).
+# ###########################################################################
+
+
+@py_class("testing.PyClassSimple")
+class _PyClassSimple(core.Object):
+    x: int
+    y: int
+
+
+@py_class("testing.PyClassWithDefault")
+class _PyClassWithDefault(core.Object):
+    a: int
+    b: int = 42
+
+
+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]
+        names = [f.name for f in ti.fields]
+        assert names == ["x", "y"]
+
+    def test_construct_kwargs(self) -> None:
+        obj = _PyClassSimple(x=1, y=2)
+        assert obj.x == 1
+        assert obj.y == 2
+
+    def test_construct_positional(self) -> None:
+        obj = _PyClassSimple(10, 20)
+        assert obj.x == 10
+        assert obj.y == 20
+
+    def test_default_field(self) -> None:
+        obj = _PyClassWithDefault(a=7)
+        assert obj.a == 7
+        assert obj.b == 42
+
+    def test_override_default(self) -> None:
+        obj = _PyClassWithDefault(a=1, b=2)
+        assert obj.a == 1
+        assert obj.b == 2
diff --git a/tests/python/test_dataclass_py_class.py 
b/tests/python/test_dataclass_py_class.py
index cc8daeb..4078bf5 100644
--- a/tests/python/test_dataclass_py_class.py
+++ b/tests/python/test_dataclass_py_class.py
@@ -24,7 +24,6 @@ import gc
 import inspect
 import itertools
 import math
-import sys
 from typing import Any, ClassVar, Dict, List, Optional
 
 import pytest
@@ -36,8 +35,7 @@ from tvm_ffi.core import MISSING, Object, TypeInfo, 
TypeSchema, _to_py_class_val
 from tvm_ffi.dataclasses import KW_ONLY, Field, field, py_class
 from tvm_ffi.registry import _add_class_attrs
 from tvm_ffi.testing import TestObjectBase as _TestObjectBase
-
-_needs_310 = pytest.mark.skipif(sys.version_info < (3, 10), reason="X | Y 
syntax requires 3.10+")
+from tvm_ffi.testing.testing import requires_py310
 
 # ---------------------------------------------------------------------------
 # Unique type key generator (avoids collisions across tests)
@@ -195,7 +193,7 @@ class TestFieldParsing:
         obj = BoolFld(x=True)
         assert obj.x is True
 
-    @_needs_310
+    @requires_py310
     def test_optional_field(self) -> None:
         @py_class(_unique_key("OptFld"))
         class OptFld(Object):
@@ -710,7 +708,7 @@ class TestInheritance:
 class TestForwardReferences:
     """Deferred annotation resolution for mutual and self-references."""
 
-    @_needs_310
+    @requires_py310
     def test_self_reference(self) -> None:
         @py_class(_unique_key("SelfRef"))
         class SelfRef(Object):
@@ -722,7 +720,7 @@ class TestForwardReferences:
         assert head.next_node is not None
         assert head.next_node.value == 2
 
-    @_needs_310
+    @requires_py310
     def test_mutual_reference(self) -> None:
         """Two classes that reference each other."""
 
@@ -741,7 +739,7 @@ class TestForwardReferences:
         assert foo.bar is not None
         assert foo.bar.value == 2
 
-    @_needs_310
+    @requires_py310
     def test_deferred_resolution_on_instantiation(self) -> None:
         """Forward ref resolved on first instantiation."""
 
@@ -916,7 +914,7 @@ class TestHashTriState:
 class TestDeferredInitPreservation:
     """Deferred resolution preserves user-defined __init__ and init=False."""
 
-    @_needs_310
+    @requires_py310
     def test_deferred_with_user_init(self) -> None:
         """User-defined __init__ is preserved after deferred resolution."""
 
@@ -938,7 +936,7 @@ class TestDeferredInitPreservation:
         assert obj.value == 42
         assert obj.ref is None
 
-    @_needs_310
+    @requires_py310
     def test_deferred_with_init_false(self) -> None:
         """init=False is respected after deferred resolution."""
 
@@ -1152,7 +1150,7 @@ class TestInitReorderingAdversarial:
         PostReorder(y=10, x=20)
         assert seen == {"x": 20, "y": 10}
 
-    @_needs_310
+    @requires_py310
     def test_deferred_forward_ref_with_reordering(self) -> None:
         """Deferred forward-reference resolution still produces correct 
reordering."""
 
@@ -3776,7 +3774,7 @@ class TestContainerFieldAnnotations:
 class TestOptionalContainerFields:
     """Optional[List[T]], Optional[Dict[K,V]] via @py_class."""
 
-    @_needs_310
+    @requires_py310
     def test_optional_list_int(self) -> None:
         @py_class(_unique_key("OptListInt"))
         class OptListInt(Object):
@@ -3789,7 +3787,7 @@ class TestOptionalContainerFields:
         obj.items = [4, 5]
         assert len(obj.items) == 2
 
-    @_needs_310
+    @requires_py310
     def test_optional_dict_str_int(self) -> None:
         @py_class(_unique_key("OptDictStrInt"))
         class OptDictStrInt(Object):
@@ -3802,7 +3800,7 @@ class TestOptionalContainerFields:
         obj.data = {"b": 2}
         assert obj.data["b"] == 2
 
-    @_needs_310
+    @requires_py310
     def test_optional_list_list_int(self) -> None:
         @py_class(_unique_key("OptLLI"))
         class OptLLI(Object):
@@ -3813,7 +3811,7 @@ class TestOptionalContainerFields:
         obj.matrix = None
         assert obj.matrix is None
 
-    @_needs_310
+    @requires_py310
     def test_optional_dict_str_list_int(self) -> None:
         @py_class(_unique_key("OptDSLI"))
         class OptDSLI(Object):
@@ -3872,7 +3870,7 @@ class TestFunctionField:
         obj.func = fn2
         assert obj.func(1) == 3
 
-    @_needs_310
+    @requires_py310
     def test_optional_function_field(self) -> None:
         @py_class(_unique_key("OptFunc"))
         class OptFunc(Object):
diff --git a/tests/python/test_stubgen.py b/tests/python/test_stubgen.py
index d615c33..c32f6b2 100644
--- a/tests/python/test_stubgen.py
+++ b/tests/python/test_stubgen.py
@@ -445,7 +445,7 @@ def test_generate_object_with_methods() -> None:
         fields=[],
         methods=[
             FuncInfo.from_schema(
-                "demo.IntPair.__c_ffi_init__",
+                "demo.IntPair.__ffi_init__",
                 TypeSchema("Callable", (TypeSchema("None"), TypeSchema("int"), 
TypeSchema("int"))),
                 is_member=True,
             ),
@@ -465,10 +465,9 @@ def test_generate_object_with_methods() -> None:
     assert code.lines[-1] == C.STUB_END
     assert "# fmt: off" in code.lines[1]
     assert any("if TYPE_CHECKING:" in line for line in code.lines)
-    method_lines = [
-        line for line in code.lines if "def __c_ffi_init__" in line or "def 
sum" in line
-    ]
-    assert any(line.strip().startswith("def __c_ffi_init__") for line in 
method_lines)
+    method_lines = [line for line in code.lines if "def __ffi_init__" in line 
or "def sum" in line]
+    # __ffi_init__ from TypeMethod is rendered as an instance method (self, 
...) -> None
+    assert any(line.strip().startswith("def __ffi_init__(self") for line in 
method_lines)
     assert any(line.strip().startswith("def sum") for line in method_lines)
 
 
diff --git a/tests/python/test_type_converter.py 
b/tests/python/test_type_converter.py
index d685edf..28d885a 100644
--- a/tests/python/test_type_converter.py
+++ b/tests/python/test_type_converter.py
@@ -40,9 +40,6 @@ from tvm_ffi.core import (
 # Python 3.9+ supports list[int], dict[str, int], tuple[int, ...] at runtime.
 # On 3.8, these raise TypeError("'type' object is not subscriptable").
 _PY39 = sys.version_info >= (3, 9)
-requires_py39 = pytest.mark.skipif(
-    not _PY39, reason="builtin generic subscripts require Python 3.9+"
-)
 from tvm_ffi.testing import (
     TestIntPair,
     TestObjectBase,
@@ -51,6 +48,7 @@ from tvm_ffi.testing import (
     _TestCxxClassDerived,
     _TestCxxClassDerivedDerived,
 )
+from tvm_ffi.testing.testing import requires_py39, requires_py310
 
 
 # ---------------------------------------------------------------------------
@@ -4299,7 +4297,7 @@ class TestFromAnnotationOptional:
         """Union[int, None] normalizes to Optional[int]."""
         assert A(Union[int, None]) == S("Optional", S("int"))
 
-    @pytest.mark.skipif(sys.version_info < (3, 10), reason="X | Y requires 
3.10+")
+    @requires_py310
     def test_pipe_syntax(self) -> None:
         """Int | None."""
         assert A(eval("int | None")) == S("Optional", S("int"))
@@ -4316,7 +4314,7 @@ class TestFromAnnotationUnion:
         """Nested unions flatten to a single Union schema."""
         assert A(Union[int, Union[str, float]]) == S("Union", S("int"), 
S("str"), S("float"))
 
-    @pytest.mark.skipif(sys.version_info < (3, 10), reason="X | Y requires 
3.10+")
+    @requires_py310
     def test_pipe_syntax(self) -> None:
         """Int | str."""
         assert A(eval("int | str")) == S("Union", S("int"), S("str"))


Reply via email to