This is an automated email from the ASF dual-hosted git repository.
tlopex 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 e3831a03 [STUBGEN][RUST] Error on dependencies a partial run does not
ask for (#748)
e3831a03 is described below
commit e3831a0378819136c7e71ac233e5ca3a5f5f6b46
Author: Linzhang Li <[email protected]>
AuthorDate: Sat Sep 5 21:18:30 2026 -0400
[STUBGEN][RUST] Error on dependencies a partial run does not ask for (#748)
[STUBGEN][RUST] Error on dependencies a partial stubgen
## Summary
A binding refers to other type keys: its parent (`base`), its ancestors
(upcasts), and its field types. The generator spelled every non-`ffi.*`
key by module path (`super::ir::ExprObj`) whether or not anything
provided it, so a partial run silently referenced modules that did not
exist. Now a referenced key must come from the crate (`ffi.*`), a
`ty-map` in the file (hand-written), or an `object/<key>` block in any
file of the run; otherwise the object fails with every missing key
listed:
```text
`tirx.Add` (line 3) depends on `ir.Expr`, `ir.Span`, `ir.Type`, which this
run does not ask for:
add an `object/<key>` block or a `ty-map` directive for each
```
`ty-map` now covers the parent too: with `ir.Expr -> crate::ir::Expr`
the child embeds `crate::ir::ExprObj` as `base`, derefs and upcasts to
it, and its allocator calls `ExprObj::new(...)` with the parent's
fields, which is the contract a hand-written parent must honour. Before,
only field types were mapped and `base` still pointed at
`super::ir::ExprObj`. Dependencies are not closed over automatically, as
discussed: the error is the to-do list.
## Changes
- `rust_generator/codegen.py`: `_generated()` becomes `_provider()`
(crate / mapped / generated / missing) and the base slot, upcasts,
allocator recursion, classification, and field resolution branch on it;
`generate_rust_object` takes `declared` and raises once per object.
- `stub/cli.py`: computes `declared` (every `object/` key of the run)
before stage 3 and passes it through `_stage_3`; `generate_object_block`
gains the parameter in the protocol and both backends (Python ignores
it).
- `examples/rust_stubgen/README.md`: "Partial generation" section.
- `tests/python/test_stubgen_rust.py`: missing keys reported together, a
key declared in another file resolves, a `ty-map`'d parent redirects
base / `Deref` / upcast, the registry case through `_stage_3`.
## Testing
`test_stubgen_rust.py` + `test_stubgen.py`: 105 passed; pre-commit lint
clean. Against `libtvm_compiler.so`, a skeleton with only
`object/tirx.Add` fails with the message above (exit 2); with three
`ty-map` lines it generates `base: ExprObj` from `crate::ir`.
Signed-off-by: yuchuan <[email protected]>
---
examples/rust_stubgen/README.md | 11 +++
python/tvm_ffi/stub/cli.py | 13 +++-
python/tvm_ffi/stub/generator.py | 8 ++-
python/tvm_ffi/stub/python_generator/generator.py | 2 +
python/tvm_ffi/stub/rust_generator/codegen.py | 70 +++++++++++++++----
python/tvm_ffi/stub/rust_generator/generator.py | 4 +-
tests/python/test_stubgen_rust.py | 83 +++++++++++++++++++++--
7 files changed, 169 insertions(+), 22 deletions(-)
diff --git a/examples/rust_stubgen/README.md b/examples/rust_stubgen/README.md
index 42b2758c..f4d257e3 100644
--- a/examples/rust_stubgen/README.md
+++ b/examples/rust_stubgen/README.md
@@ -71,3 +71,14 @@ wraps it in `Option` (`// tvm-ffi-stubgen(nullable):
rust_stubgen.IntPair.a`),
(`// tvm-ffi-stubgen(upcast): rust_stubgen.IntPair -> MyView`), and
`custom-new` names the generated allocator `from_complete_fields` when `new`
is hand-written (`// tvm-ffi-stubgen(custom-new): rust_stubgen.IntPair`).
+
+## Partial generation
+
+Every type an object refers to (its parent, its ancestors, the types of its
+fields) has to be provided in the same `tvm-ffi-stubgen` run: `ffi.*` types
+come from the `tvm-ffi` crate, an `object/<key>` block in any processed file
+generates it, and a `ty-map` points at a hand-written binding whose object
+struct is named `<Name>Obj`
+(`// tvm-ffi-stubgen(ty-map): rust_stubgen.IntPair -> crate::hand::IntPair`).
+Anything else is an error listing the missing keys, so a partial binding never
+references a module that does not exist.
diff --git a/python/tvm_ffi/stub/cli.py b/python/tvm_ffi/stub/cli.py
index a1afe73f..3fdffa4b 100644
--- a/python/tvm_ffi/stub/cli.py
+++ b/python/tvm_ffi/stub/cli.py
@@ -39,6 +39,8 @@ from .lib_state import (
from .utils import FuncInfo, InitConfig, Options
if TYPE_CHECKING:
+ from collections.abc import Container
+
from .generator import Generator
@@ -97,6 +99,13 @@ def __main__() -> int:
# Stage 3: Process
# - `tvm-ffi-stubgen(begin): global/...`
# - `tvm-ffi-stubgen(begin): object/...`
+ # Every `object/` block of the run: what a generated binding may refer to.
+ declared = frozenset(
+ code.param
+ for file in files
+ for code in file.code_blocks
+ if code.kind == "object" and isinstance(code.param, str)
+ )
for file in files:
if opt.verbose:
print(f"{C.TERM_CYAN}[File] {file.path}{C.TERM_RESET}")
@@ -107,6 +116,7 @@ def __main__() -> int:
ty_map,
global_funcs,
generator=generator,
+ declared=declared,
)
except Exception:
failed += 1
@@ -238,6 +248,7 @@ def _stage_3( # noqa: PLR0912
ty_map: dict[str, str],
global_funcs: dict[str, list[FuncInfo]],
generator: Generator,
+ declared: Container[str] = frozenset(),
) -> bool:
"""Process one file's blocks; return whether its content is (or would be)
changed."""
defined_funcs: set[str] = set()
@@ -268,7 +279,7 @@ def _stage_3( # noqa: PLR0912
obj_info = object_info_from_type_key(type_key)
type_key = ty_map.get(type_key, type_key)
defined_types.add(generator.canonical_type_name(type_key))
- generator.generate_object_block(code, ty_map, imports, opt,
obj_info)
+ generator.generate_object_block(code, ty_map, imports, opt,
obj_info, declared)
# Stage 4. Add imports for used types.
for code in file.code_blocks:
if code.kind == "import-section":
diff --git a/python/tvm_ffi/stub/generator.py b/python/tvm_ffi/stub/generator.py
index 19322274..add7503a 100644
--- a/python/tvm_ffi/stub/generator.py
+++ b/python/tvm_ffi/stub/generator.py
@@ -45,6 +45,7 @@ from .python_generator import PythonGenerator
from .rust_generator import RustGenerator
if TYPE_CHECKING:
+ from collections.abc import Container
from pathlib import Path
from .file_utils import CodeBlock
@@ -133,8 +134,13 @@ class Generator(Protocol):
imports: Any,
opt: Options,
obj_info: ObjectInfo,
+ declared: Container[str] = frozenset(),
) -> None:
- """Emit a type definition (fields + methods + init) for an
``object/<key>`` block."""
+ """Emit a type definition (fields + methods + init) for an
``object/<key>`` block.
+
+ ``declared`` lists the type keys that have an ``object/`` block
anywhere in
+ this run, for generators that must know what a binding may refer to.
+ """
...
def generate_import_section_block(
diff --git a/python/tvm_ffi/stub/python_generator/generator.py
b/python/tvm_ffi/stub/python_generator/generator.py
index 906c9a8f..cd81821d 100644
--- a/python/tvm_ffi/stub/python_generator/generator.py
+++ b/python/tvm_ffi/stub/python_generator/generator.py
@@ -32,6 +32,7 @@ from . import consts as PC
from .utils import ImportItem, PythonImports
if TYPE_CHECKING:
+ from collections.abc import Container
from pathlib import Path
from ..file_utils import CodeBlock
@@ -94,6 +95,7 @@ class PythonGenerator:
imports: PythonImports,
opt: Options,
obj_info: ObjectInfo,
+ declared: Container[str] = frozenset(),
) -> None:
"""Emit a Python class definition for an ``object/<key>`` block."""
G.generate_python_object(code, ty_map, imports.items, opt, obj_info)
diff --git a/python/tvm_ffi/stub/rust_generator/codegen.py
b/python/tvm_ffi/stub/rust_generator/codegen.py
index bed9af62..c5da9b4f 100644
--- a/python/tvm_ffi/stub/rust_generator/codegen.py
+++ b/python/tvm_ffi/stub/rust_generator/codegen.py
@@ -35,6 +35,12 @@ reflected field size. A builtin parent (``ffi.IntEnum``,
say) has no
``<Leaf>Obj`` in the crate: the import section defines a header-only stand-in
per builtin ancestor so ``derive(Object)`` computes the registry's
``TYPE_DEPTH``, and everything under such a parent stays opaque
(``no-mirror``).
+
+Every type key an object refers to (parent, ancestors, field types) must be
+provided in the same run: ``ffi.*`` by the crate, a ``ty-map`` by a
hand-written
+binding whose object struct is ``<Name>Obj``, anything else by an ``object/``
+block in one of the processed files. Otherwise the block is an error naming the
+missing keys, so a partial binding never references a module that does not
exist.
"""
from __future__ import annotations
@@ -49,6 +55,7 @@ from . import consts as C_RUST
from .utils import RustImports, builtin_mirror_name, render_rust_type,
rust_ident
if TYPE_CHECKING:
+ from collections.abc import Container
from pathlib import Path
from ..file_utils import CodeBlock
@@ -83,6 +90,10 @@ class _ObjectRenderer:
ty_map: dict[str, str]
#: Module segments of the file this object lands in (``tirx.transform.X``
-> ``("tirx", "transform")``).
mod_segments: tuple[str, ...]
+ #: Type keys with an ``object/`` block somewhere in this run (see
:meth:`_provider`).
+ declared: Container[str]
+ #: Referenced type keys nobody provides; reported together once the body
is built.
+ missing: set[str] = dataclasses.field(default_factory=set)
@property
def type_key(self) -> str:
@@ -108,6 +119,7 @@ class _ObjectRenderer:
if mapped is None:
if "." not in origin or origin.startswith("ctypes."):
return None
+ self._provider(origin) # the crate or this run; a key nobody
provides is recorded
mapped = self._generated_type_path(origin)
return imports.record(mapped)
@@ -129,23 +141,38 @@ class _ObjectRenderer:
supers = "super::" * len(self.mod_segments)
return f"{supers or 'self::'}{type_key.replace('.', '::')}"
- def _generated(self, type_key: str) -> bool:
- """Whether ``type_key`` has a generated binding (builtin ``ffi.*``
types live in the crate)."""
- return type_key.partition(".")[0] not in C_RUST.RUST_MOD_MAP
+ def _provider(self, type_key: str) -> str | None:
+ """Who provides the binding of ``type_key``; a key nobody does is
recorded in :attr:`missing`.
+
+ ``"crate"`` for builtin ``ffi.*`` types, ``"mapped"`` for a ``ty-map``
to a
+ hand-written binding, ``"generated"`` for an ``object/`` block in this
run.
+ """
+ if type_key.partition(".")[0] in C_RUST.RUST_MOD_MAP:
+ return "crate"
+ if type_key in self.ty_map:
+ return "mapped"
+ if type_key in self.declared:
+ return "generated"
+ self.missing.add(type_key)
+ return None
def _base_type(self) -> tuple[str, bool]:
- """Resolve the ``base`` struct and whether it is a generated parent.
+ """Resolve the ``base`` struct and whether it is a generated or
``ty-map``'d parent.
A builtin parent below ``ffi.Object`` is embedded as its header-only
stand-in (see :meth:`RustImports.record_builtin_base`).
"""
parent = self.info.parent_type_key
- if parent is not None and self._generated(parent):
- return self.imports.record(self._generated_type_path(parent) +
"Obj"), True
+ if parent is not None:
+ provider = self._provider(parent)
+ if provider == "mapped":
+ return self.imports.record(self.ty_map[parent] + "Obj"), True
+ if provider != "crate": # generated in this run, or missing
(reported after the body)
+ return self.imports.record(self._generated_type_path(parent) +
"Obj"), True
chain = [key for key in self.info.ancestors if key !=
C_RUST.RUST_ROOT_TYPE_KEY]
if parent not in (None, C_RUST.RUST_ROOT_TYPE_KEY, *chain):
chain.append(parent)
- assert not any(self._generated(key) for key in chain), (self.type_key,
chain)
+ assert all(self._provider(key) == "crate" for key in chain),
(self.type_key, chain)
return self.imports.record_builtin_base(chain), False
# --- classification ----------------------------------------------------
@@ -164,7 +191,7 @@ class _ObjectRenderer:
unmirrored = {
key
for key in self.info.ancestors
- if key != C_RUST.RUST_ROOT_TYPE_KEY and not self._generated(key)
+ if key != C_RUST.RUST_ROOT_TYPE_KEY and self._provider(key) ==
"crate"
}
verdicts = classify(
infos,
@@ -317,11 +344,13 @@ class _ObjectRenderer:
def _upcast_lines(self) -> list[str]:
"""``impl_object_upcast!`` to every ancestor's wrapper, then the
``upcast`` directives."""
- targets = [
- self.imports.record(self._generated_type_path(key))
- for key in self.info.ancestors
- if self._generated(key)
- ]
+ targets: list[str] = []
+ for key in self.info.ancestors:
+ provider = self._provider(key)
+ if provider == "mapped":
+ targets.append(self.imports.record(self.ty_map[key]))
+ elif provider != "crate":
+
targets.append(self.imports.record(self._generated_type_path(key)))
for view in self.imports.directives.upcasts.get(self.type_key, []):
targets.append(self.imports.record(view) if "::" in view else view)
if not targets:
@@ -368,7 +397,7 @@ class _ObjectRenderer:
"""``(field, type)`` of every physical field root to leaf, as
``<key>Obj::new`` takes."""
parent = info.parent_type_key
inherited: list[tuple[str, str]] = []
- if parent is not None and self._generated(parent):
+ if parent is not None and self._provider(parent) != "crate":
inherited = self._allocator_params(parent,
object_info_from_type_key(parent))
return self._level_params(key, info, inherited)
@@ -518,8 +547,12 @@ def generate_rust_object(
imports: RustImports,
opt: Options,
obj_info: ObjectInfo,
+ declared: Container[str],
) -> None:
- """Emit the Rust binding of ``obj_info`` into an ``object/<key>`` block."""
+ """Emit the Rust binding of ``obj_info`` into an ``object/<key>`` block.
+
+ Raises ``ValueError`` when it refers to a type key this run does not
provide.
+ """
assert len(code.lines) >= 2
assert isinstance(obj_info.type_key, str)
renderer = _ObjectRenderer(
@@ -527,8 +560,15 @@ def generate_rust_object(
imports=imports,
ty_map=ty_map,
mod_segments=tuple(obj_info.type_key.split(".")[:-1]),
+ declared=declared,
)
body = renderer.body()
+ if renderer.missing:
+ keys = ", ".join(f"`{key}`" for key in sorted(renderer.missing))
+ raise ValueError(
+ f"`{obj_info.type_key}` (line {code.lineno_start}) depends on
{keys}, which this run "
+ "does not ask for: add an `object/<key>` block or a `ty-map`
directive for each"
+ )
indent = " " * code.indent
code.lines = [
code.lines[0],
diff --git a/python/tvm_ffi/stub/rust_generator/generator.py
b/python/tvm_ffi/stub/rust_generator/generator.py
index 85b7e398..30b44dbd 100644
--- a/python/tvm_ffi/stub/rust_generator/generator.py
+++ b/python/tvm_ffi/stub/rust_generator/generator.py
@@ -35,6 +35,7 @@ from .codegen import (
from .utils import RustImports, RustUse
if TYPE_CHECKING:
+ from collections.abc import Container
from pathlib import Path
from ..file_utils import CodeBlock
@@ -93,9 +94,10 @@ class RustGenerator:
imports: RustImports,
opt: Options,
obj_info: ObjectInfo,
+ declared: Container[str] = frozenset(),
) -> None:
"""Emit the opaque Rust binding for an ``object/<key>`` block."""
- generate_rust_object(code, ty_map, imports, opt, obj_info)
+ generate_rust_object(code, ty_map, imports, opt, obj_info, declared)
def generate_import_section_block(
self, code: CodeBlock, imports: RustImports, opt: Options,
defined_types: set[str]
diff --git a/tests/python/test_stubgen_rust.py
b/tests/python/test_stubgen_rust.py
index 438a1131..39493400 100644
--- a/tests/python/test_stubgen_rust.py
+++ b/tests/python/test_stubgen_rust.py
@@ -19,7 +19,7 @@
from __future__ import annotations
import re
-from collections.abc import Iterator
+from collections.abc import Container, Iterator
from pathlib import Path
import pytest
@@ -27,7 +27,7 @@ import tvm_ffi.stub.cli as stub_cli
import tvm_ffi.testing # noqa: F401 (loads the `testing.*` fixture types)
from tvm_ffi.core import TypeSchema
from tvm_ffi.stub import consts as C
-from tvm_ffi.stub.cli import _stage_3
+from tvm_ffi.stub.cli import _stage_1, _stage_3
from tvm_ffi.stub.file_utils import CodeBlock, FileInfo
from tvm_ffi.stub.generator import get_generator
from tvm_ffi.stub.lib_state import object_info_from_type_key
@@ -118,12 +118,26 @@ def _object_block(type_key: str) -> CodeBlock:
)
-def _render(info: ObjectInfo, imports: RustImports | None = None) ->
tuple[str, RustImports]:
+class _Everything:
+ """Stands in for the run's declared type keys when a test does not care:
all are asked for."""
+
+ def __contains__(self, key: object) -> bool:
+ return True
+
+
+ALL_DECLARED = _Everything()
+
+
+def _render(
+ info: ObjectInfo,
+ imports: RustImports | None = None,
+ declared: Container[str] = ALL_DECLARED,
+) -> tuple[str, RustImports]:
"""Render ``info`` into a fresh object block; return the body text and the
collector."""
imports = RustImports() if imports is None else imports
assert info.type_key is not None
block = _object_block(info.type_key)
- generate_rust_object(block, RUST.default_ty_map(), imports, Options(),
info)
+ generate_rust_object(block, RUST.default_ty_map(), imports, Options(),
info, declared)
return "\n".join(block.lines[1:-1]), imports
@@ -1227,3 +1241,64 @@ def test_cli_check_rejects_init_flags(tmp_path: Path,
monkeypatch: pytest.Monkey
with pytest.raises(SystemExit) as excinfo:
stub_cli.__main__()
assert excinfo.value.code == 2
+
+
+# ---------------------------------------------------------------------------
+# Partial generation: every referenced type key must be provided in the run
+# ---------------------------------------------------------------------------
+
+
+def test_unasked_dependencies_are_an_error() -> None:
+ info = _info("tirx.Add", (_field("a", "tirx.Var"),), parent="ir.Expr")
+ expected = (
+ r"`tirx.Add` \(line 1\) depends on `ir.Expr`, `tirx.Var`, which this
run does not ask"
+ )
+ with pytest.raises(ValueError, match=expected):
+ _render(info, declared=frozenset({"tirx.Add"}))
+ # Declared in another file of the run: reached by module path, as before.
+ text, imports = _render(info, declared=frozenset({"tirx.Add", "ir.Expr",
"tirx.Var"}))
+ assert " base: ExprObj," in text
+ assert "pub fn a(&self) -> Result<Var> {" in text # same module: a bare
name
+ assert {"super::ir::ExprObj", "super::ir::Expr"} <= _uses(imports)
+
+
+def test_ty_map_parent_redirects_base_deref_and_upcast() -> None:
+ info = _info("tirx.Add", parent="ir.Expr", ancestors=["ffi.Object",
"ir.Expr"])
+ ty_map = {**RUST.default_ty_map(), "ir.Expr": "crate::ir::Expr"}
+ block = _object_block("tirx.Add")
+ imports = RustImports()
+ generate_rust_object(block, ty_map, imports, Options(), info,
frozenset({"tirx.Add"}))
+ text = "\n".join(block.lines[1:-1])
+ assert " base: ExprObj," in text
+ assert "impl Deref for AddObj {\n type Target = ExprObj;" in text
+ assert text.endswith("tvm_ffi::impl_object_upcast!(Add => Expr);")
+ assert {"crate::ir::ExprObj", "crate::ir::Expr"} <= _uses(imports)
+ assert not any(path.startswith("super::") for path in _uses(imports))
+
+
+def test_stage_3_checks_dependencies_across_the_run(tmp_path: Path) -> None:
+ src = tmp_path / "mod.rs"
+ blocks = [
+ f"{C.RUST_SYNTAX.begin} import-section",
+ C.RUST_SYNTAX.end,
+ f"{C.RUST_SYNTAX.begin} object/testing.TestCxxClassDerived",
+ C.RUST_SYNTAX.end,
+ "",
+ ]
+ declared = frozenset({"testing.TestCxxClassDerived"})
+ src.write_text("\n".join(blocks), encoding="utf-8")
+ info = FileInfo.from_file(src)
+ assert info is not None
+ with pytest.raises(ValueError, match=r"depends on
`testing\.TestCxxClassBase`"):
+ _stage_3(info, Options(dry_run=True), RUST.default_ty_map(), {}, RUST,
declared)
+ # A `ty-map` names a hand-written parent; its `<Name>Obj` becomes the base
slot.
+ mapped = f"{C.RUST_SYNTAX.ty_map} testing.TestCxxClassBase ->
crate::hand::TestCxxClassBase"
+ src.write_text("\n".join([mapped, *blocks]), encoding="utf-8")
+ info = FileInfo.from_file(src)
+ assert info is not None
+ ty_map = RUST.default_ty_map()
+ _stage_1(info, ty_map)
+ _stage_3(info, Options(dry_run=True), ty_map, {}, RUST, declared)
+ text = "\n".join(line for block in info.code_blocks for line in
block.lines)
+ assert " base: TestCxxClassBaseObj," in text
+ assert "use crate::hand::TestCxxClassBaseObj;" in text