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"))