Skip to content

Commit bb2e458

Browse files
authored
[PY] Distinguish FFI input and output annotations (#621)
## Summary - Add separate input/output rendering APIs for `TypeSchema` so stubgen can widen callable and constructor inputs without weakening returned or field types. - Add a `__ffi_convert_type_schema__` TypeAttr hook for object conversions and wire `py_class`/`c_class` dataclass transform converter metadata. - Generate `type_schema` metadata for Python-defined `@method` TypeMethods so reflection-driven stubgen can render their signatures. - Cover container asymmetry, object convert schema recursion, stubgen signatures, partial type maps, `@method` metadata, and dataclass metadata with regression tests. - Update the PR CI workflow to avoid Apache-disallowed third-party setup actions and run pre-commit without syncing the project during lint. ## Validation - pre-commit hooks during commits, including ruff, ty, clang-format, cython-lint, CMake lint/format, and workflow YAML checks - `python -m pytest tests/python/test_stubgen.py::test_py_class_method_metadata_renders_stub_signature tests/python/test_typed_method.py -q` - `python -m pytest tests/python/test_stubgen.py -q` - `CUDA_VISIBLE_DEVICES="" python -m pytest tests/python -q` (`2322 passed, 18 skipped, 2 xfailed`) - Manual output dumped under `tmp/2026-06-14-update-ffi/manual_check.out` - GitHub CI is green for lint, docs, Ubuntu, Ubuntu ARM, macOS, and Windows
1 parent fbb2b5c commit bb2e458

16 files changed

Lines changed: 452 additions & 49 deletions

File tree

CMakeLists.txt

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -247,10 +247,13 @@ if (TVM_FFI_BUILD_PYTHON_MODULE)
247247
set(_cython_sources
248248
${CMAKE_CURRENT_SOURCE_DIR}/python/tvm_ffi/cython/core.pyx
249249
${CMAKE_CURRENT_SOURCE_DIR}/python/tvm_ffi/cython/base.pxi
250+
${CMAKE_CURRENT_SOURCE_DIR}/python/tvm_ffi/cython/type_info.pxi
250251
${CMAKE_CURRENT_SOURCE_DIR}/python/tvm_ffi/cython/device.pxi
251252
${CMAKE_CURRENT_SOURCE_DIR}/python/tvm_ffi/cython/dtype.pxi
252253
${CMAKE_CURRENT_SOURCE_DIR}/python/tvm_ffi/cython/error.pxi
253254
${CMAKE_CURRENT_SOURCE_DIR}/python/tvm_ffi/cython/function.pxi
255+
${CMAKE_CURRENT_SOURCE_DIR}/python/tvm_ffi/cython/pycallback.pxi
256+
${CMAKE_CURRENT_SOURCE_DIR}/python/tvm_ffi/cython/pyclass_type_converter.pxi
254257
${CMAKE_CURRENT_SOURCE_DIR}/python/tvm_ffi/cython/tensor.pxi
255258
${CMAKE_CURRENT_SOURCE_DIR}/python/tvm_ffi/cython/object.pxi
256259
${CMAKE_CURRENT_SOURCE_DIR}/python/tvm_ffi/cython/string.pxi

cmake/Utils/AddGoogleTest.cmake

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -91,7 +91,7 @@ macro (TVM_FFI_ADD_GTEST target_name)
9191
target_link_libraries(${target_name} PRIVATE gtest_main)
9292
gtest_discover_tests(
9393
${target_name}
94-
WORKING_DIRECTORY ${PROJECT_SOURCE_DIR} TEST_DISCOVERY_TIMEOUT 600 DISCOVERY_MODE PRE_TEST
94+
WORKING_DIRECTORY ${PROJECT_SOURCE_DIR} DISCOVERY_TIMEOUT 600 DISCOVERY_MODE PRE_TEST
9595
PROPERTIES VS_DEBUGGER_WORKING_DIRECTORY "${PROJECT_SOURCE_DIR}"
9696
)
9797
set_target_properties(${target_name} PROPERTIES FOLDER tests)

include/tvm/ffi/reflection/accessor.h

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -350,6 +350,14 @@ inline constexpr const char* kInit = "__ffi_init__";
350350
* Signature: ``(AnyView src) -> TSelf``, where ``TSelf`` is a subclass of ObjectRef.
351351
*/
352352
inline constexpr const char* kConvert = "__ffi_convert__";
353+
/*!
354+
* \brief Type schema accepted by ``kConvert`` before it returns ``TSelf``.
355+
*
356+
* Stored as a JSON type schema string or schema-like object. Python stub
357+
* generation uses this attribute to render widened input annotations while
358+
* keeping output annotations precise.
359+
*/
360+
inline constexpr const char* kConvertTypeSchema = "__ffi_convert_type_schema__";
353361
/*!
354362
* \brief Shallow-copy factory.
355363
*

python/tvm_ffi/core.pyi

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -274,6 +274,8 @@ class TypeSchema:
274274
@staticmethod
275275
def from_annotation(annotation: object) -> TypeSchema: ...
276276
def repr(self, ty_map: Callable[[str], str] | None = None) -> str: ...
277+
def input_repr(self, ty_map: Callable[[str], str] | None = None) -> str: ...
278+
def output_repr(self, ty_map: Callable[[str], str] | None = None) -> str: ...
277279
def check_value(self, value: object) -> None: ...
278280
def convert(self, value: object) -> CAny: ...
279281
def to_json(self) -> dict[str, Any]: ...

python/tvm_ffi/cython/type_info.pxi

Lines changed: 59 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -116,6 +116,8 @@ _TYPE_SCHEMA_ORIGIN_CONVERTER = {
116116
"ObjectRValueRef": "Object",
117117
}
118118

119+
cdef str _TYPE_ATTR_FFI_CONVERT_TYPE_SCHEMA = "__ffi_convert_type_schema__"
120+
119121
# Sentinel for structural types (Optional, Union) that have no single type_index
120122
_ORIGIN_TYPE_INDEX_STRUCTURAL = -2
121123
# Sentinel for unknown/unresolved origins
@@ -217,6 +219,19 @@ class TypeSchema:
217219
def __repr__(self) -> str:
218220
return self.repr(ty_map=None)
219221

222+
@staticmethod
223+
def _from_maybe_json(raw: object) -> "TypeSchema":
224+
"""Construct a TypeSchema from a TypeSchema, JSON string, or JSON dict."""
225+
if isinstance(raw, TypeSchema):
226+
return raw
227+
if isinstance(raw, str):
228+
return TypeSchema.from_json_str(raw)
229+
if isinstance(raw, dict):
230+
return TypeSchema.from_json_obj(raw)
231+
raise TypeError(
232+
f"expected TypeSchema, JSON string, or JSON dict, got {type(raw).__name__}"
233+
)
234+
220235
@staticmethod
221236
def from_json_obj(obj: dict[str, Any]) -> "TypeSchema":
222237
"""Construct a :class:`TypeSchema` from a parsed JSON object.
@@ -517,12 +532,40 @@ class TypeSchema:
517532
assert s.repr() == "Array[int]"
518533

519534
"""
520-
if ty_map is None:
521-
origin = self.origin
522-
else:
523-
origin = ty_map(self.origin)
535+
return self.output_repr(ty_map)
536+
537+
def input_repr(self, ty_map: "Optional[Callable[[str], str]]" = None) -> str:
538+
"""Render the Python input annotation accepted by this schema."""
539+
return self._repr_impl(ty_map, input_mode=True, expanded_convert_types=frozenset())
540+
541+
def output_repr(self, ty_map: "Optional[Callable[[str], str]]" = None) -> str:
542+
"""Render the precise Python output annotation produced by this schema."""
543+
return self._repr_impl(ty_map, input_mode=False, expanded_convert_types=frozenset())
544+
545+
def _repr_impl(
546+
self,
547+
ty_map: "Optional[Callable[[str], str]]",
548+
input_mode: bool,
549+
expanded_convert_types: "frozenset[int]",
550+
) -> str:
551+
if input_mode and self.origin_type_index >= kTVMFFIStaticObjectBegin:
552+
if self.origin_type_index not in expanded_convert_types:
553+
raw_convert_schema = _lookup_type_attr(
554+
self.origin_type_index, _TYPE_ATTR_FFI_CONVERT_TYPE_SCHEMA
555+
)
556+
if raw_convert_schema is not None:
557+
return TypeSchema._from_maybe_json(raw_convert_schema)._repr_impl(
558+
ty_map,
559+
input_mode=True,
560+
expanded_convert_types=expanded_convert_types | {self.origin_type_index},
561+
)
562+
563+
origin = self.origin if ty_map is None else ty_map(self.origin)
524564
schema_args = self.args
525-
args = [i.repr(ty_map) for i in (() if schema_args is None else schema_args)]
565+
args = [
566+
i._repr_impl(ty_map, input_mode, expanded_convert_types)
567+
for i in (() if schema_args is None else schema_args)
568+
]
526569
if origin == "Union":
527570
return " | ".join(args)
528571
elif origin == "Optional":
@@ -1129,8 +1172,8 @@ cdef _register_py_methods(int32_t type_index, list py_methods, frozenset type_at
11291172
----------
11301173
type_index : int
11311174
The runtime type index of the type.
1132-
py_methods : list[tuple[str, Any, bool]]
1133-
Each entry is ``(name, value, is_static)``.
1175+
py_methods : list[tuple[str, Any, bool, str | None]]
1176+
Each entry is ``(name, value, is_static, metadata_json)``.
11341177
type_attr_names : frozenset[str]
11351178
Names to register as TypeAttrColumn instead of TypeMethod.
11361179
"""
@@ -1139,11 +1182,12 @@ cdef _register_py_methods(int32_t type_index, list py_methods, frozenset type_at
11391182
cdef TVMFFIAny sentinel_any
11401183
cdef int c_api_ret_code
11411184
cdef ByteArrayArg name_arg
1185+
cdef ByteArrayArg metadata_arg
11421186

11431187
sentinel_any.type_index = kTVMFFINone
11441188
sentinel_any.v_int64 = 0
11451189

1146-
for name, func, is_static in py_methods:
1190+
for name, func, is_static, metadata_json in py_methods:
11471191
func_any.type_index = kTVMFFINone
11481192
func_any.v_int64 = 0
11491193
try:
@@ -1169,8 +1213,13 @@ cdef _register_py_methods(int32_t type_index, list py_methods, frozenset type_at
11691213
method_info.doc.size = 0
11701214
method_info.flags = kTVMFFIFieldFlagBitMaskIsStaticMethod if is_static else 0
11711215
method_info.method = func_any
1172-
method_info.metadata.data = NULL
1173-
method_info.metadata.size = 0
1216+
if metadata_json is not None:
1217+
metadata_bytes = c_str(metadata_json)
1218+
metadata_arg = ByteArrayArg(metadata_bytes)
1219+
method_info.metadata = metadata_arg.cdata
1220+
else:
1221+
method_info.metadata.data = NULL
1222+
method_info.metadata.size = 0
11741223
CHECK_CALL(TVMFFITypeRegisterMethod(type_index, &method_info))
11751224
finally:
11761225
if func_any.type_index >= kTVMFFIStaticObjectBegin and func_any.v_obj != NULL:

python/tvm_ffi/dataclasses/c_class.py

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -24,7 +24,7 @@
2424

2525
from typing_extensions import dataclass_transform
2626

27-
from .field import Field
27+
from .field import Field, _field_converter, field
2828

2929
_T = TypeVar("_T", bound=type)
3030

@@ -60,7 +60,12 @@ def _attach_field_objects(cls: type, type_info: Any) -> None:
6060
tf.dataclass_field = f
6161

6262

63-
@dataclass_transform(eq_default=False, order_default=False)
63+
@dataclass_transform(
64+
eq_default=False,
65+
order_default=False,
66+
field_specifiers=(field, Field),
67+
converter=_field_converter,
68+
)
6469
def c_class(
6570
type_key: str,
6671
*,

python/tvm_ffi/dataclasses/field.py

Lines changed: 18 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -37,6 +37,11 @@ class KW_ONLY:
3737
"""Sentinel type: annotations after ``_: KW_ONLY`` are keyword-only."""
3838

3939

40+
def _field_converter(value: Any) -> Any:
41+
"""Static-analysis marker for fields whose values are converted by FFI."""
42+
return value
43+
44+
4045
class Field:
4146
"""Descriptor for a single field in a Python-defined TVM-FFI type.
4247
@@ -102,12 +107,16 @@ class Field:
102107
parameters that reference outer-scope vars.
103108
doc : str | None
104109
Optional docstring for the field.
110+
converter : Callable[[Any], Any]
111+
Static-analysis marker for field conversion. Runtime conversion is
112+
still handled by the FFI type converter.
105113
106114
"""
107115

108116
__slots__ = (
109117
"_ty_schema",
110118
"compare",
119+
"converter",
111120
"default",
112121
"default_factory",
113122
"doc",
@@ -130,6 +139,7 @@ class Field:
130139
repr: bool
131140
hash: bool | None
132141
compare: bool
142+
converter: Callable[[Any], Any]
133143
kw_only: bool | None
134144
structural_eq: str | None
135145
doc: str | None
@@ -158,6 +168,7 @@ def __init__( # noqa: PLR0913
158168
kw_only: bool | None = False,
159169
structural_eq: str | None = None,
160170
doc: str | None = None,
171+
converter: Callable[[Any], Any] = _field_converter,
161172
) -> None:
162173
# MISSING means "parameter not provided".
163174
# An explicit None from the user fails the callable() check,
@@ -185,12 +196,13 @@ def __init__( # noqa: PLR0913
185196
self.repr = repr
186197
self.hash = hash
187198
self.compare = compare
199+
self.converter = converter
188200
self.kw_only = kw_only
189201
self.structural_eq = structural_eq
190202
self.doc = doc
191203

192204

193-
def field(
205+
def field( # noqa: PLR0913
194206
*,
195207
default: object = MISSING,
196208
default_factory: Callable[[], object] | None = MISSING, # type: ignore[assignment]
@@ -202,6 +214,7 @@ def field(
202214
kw_only: bool | None = None,
203215
structural_eq: str | None = None,
204216
doc: str | None = None,
217+
converter: Callable[[Any], Any] = _field_converter,
205218
) -> Any:
206219
"""Customize a field in a ``@py_class``-decorated class.
207220
@@ -248,6 +261,9 @@ def field(
248261
binding.
249262
doc
250263
Optional docstring for the field.
264+
converter
265+
Static-analysis marker for field conversion. Runtime conversion is
266+
still handled by the FFI type converter.
251267
252268
Returns
253269
-------
@@ -282,4 +298,5 @@ class MyFunc(Object):
282298
kw_only=kw_only,
283299
structural_eq=structural_eq,
284300
doc=doc,
301+
converter=converter,
285302
)

0 commit comments

Comments
 (0)