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 ef5ce4e0 [STUBGEN] Generalize one-line directive handling (#736)
ef5ce4e0 is described below

commit ef5ce4e0c1700e028545d49a010b1689ed808234
Author: Linzhang Li <[email protected]>
AuthorDate: Thu Sep 3 14:39:02 2026 -0400

    [STUBGEN] Generalize one-line directive handling (#736)
    
    ## Summary
    
    Generalize parsing and dispatch for standalone one-line
    `tvm-ffi-stubgen`
    directives. Pipeline-owned and generator-owned directives now share one
    `CodeBlock` representation, while each layer remains responsible for its
    own
    payload grammar. This lets a target generator add directives without
    extending
    the language-neutral file parser.
    
    ## Changes
    
    - Add `MarkerSyntax.directive(name)` for constructing standalone
    directive
      markers with either Python or Rust comment syntax.
    - Parse standalone directives uniformly as
    `CodeBlock(kind="directive", param=(name, payload))` and preserve their
    source
      lines when rewriting files.
    - Add `PIPELINE_DIRECTIVE_KINDS` for directives consumed by the common
    pipeline.
    `ty-map` remains pipeline-owned and is processed before init-mode
    generation.
    - Add `Generator.directive_kinds` and `Generator.add_directive` so each
    target
    declares and handles its own directive names. Undeclared names fail with
    their
      source line number.
    - Route the existing Python `import-object` support through
    `add_directive`, and
    move its semicolon-delimited payload parsing from the common parser into
    the
      Python generator.
    
    ## Compatibility
    
    The undocumented `tvm-ffi-stubgen(begin): ty-map/...` block form is no
    longer
    accepted; `ty-map` is a standalone one-line directive. Out-of-tree
    `Generator`
    implementations must provide `directive_kinds` and `add_directive`;
    `add_imported_object` is no longer called by the pipeline.
    
    ## Tests
    
    - `uv run pytest -q tests/python/test_stubgen.py` (43 passed)
    - `uv run ruff check` on all changed Python files
    - `git diff --check`
    
    ---------
    
    Signed-off-by: yuchuan <[email protected]>
---
 python/tvm_ffi/stub/cli.py                        |  31 +++---
 python/tvm_ffi/stub/consts.py                     |  20 ++--
 python/tvm_ffi/stub/file_utils.py                 |  51 +++-------
 python/tvm_ffi/stub/generator.py                  |  23 +++--
 python/tvm_ffi/stub/python_generator/generator.py |  14 +--
 tests/python/test_stubgen.py                      | 118 ++++++++++++++++++++--
 6 files changed, 180 insertions(+), 77 deletions(-)

diff --git a/python/tvm_ffi/stub/cli.py b/python/tvm_ffi/stub/cli.py
index 2b054c72..3dfc8151 100644
--- a/python/tvm_ffi/stub/cli.py
+++ b/python/tvm_ffi/stub/cli.py
@@ -120,15 +120,15 @@ def _stage_1(
     ty_map: dict[str, str],
 ) -> None:
     for code in file.code_blocks:
-        if code.kind == "ty-map":
-            try:
-                assert isinstance(code.param, str)
-                lhs, rhs = code.param.split("->")
-            except ValueError as e:
-                raise ValueError(
-                    f"Invalid ty_map format at line {code.lineno_start}. 
Example: `A.B -> C.D`"
-                ) from e
-            ty_map[lhs.strip()] = rhs.strip()
+        if code.kind != "directive" or code.param[0] != "ty-map":
+            continue
+        try:
+            lhs, rhs = code.param[1].split("->")
+        except ValueError as e:
+            raise ValueError(
+                f"Invalid ty_map format at line {code.lineno_start}. Example: 
`A.B -> C.D`"
+            ) from e
+        ty_map[lhs.strip()] = rhs.strip()
 
 
 def _stage_2(
@@ -221,11 +221,16 @@ def _stage_3(  # noqa: PLR0912
     defined_funcs: set[str] = set()
     defined_types: set[str] = set()
     imports = generator.new_imports()
-    # Stage 1. Collect `tvm-ffi-stubgen(import-object): ...`
+    # Stage 1. Hand the one-line directives the pipeline does not consume 
itself to the generator.
     for code in file.code_blocks:
-        if code.kind == "import-object":
-            name, type_checking_only, alias = code.param
-            generator.add_imported_object(imports, name, type_checking_only, 
alias)
+        if code.kind != "directive":
+            continue
+        name, payload = code.param
+        if name in C.PIPELINE_DIRECTIVE_KINDS:
+            continue  # consumed by `_stage_1`
+        if name not in generator.directive_kinds:
+            raise ValueError(f"Unknown directive `{name}` at line 
{code.lineno_start}")
+        generator.add_directive(imports, name, payload, code.lineno_start)
     # Stage 2. Process `tvm-ffi-stubgen(begin): global/...`
     for code in file.code_blocks:
         if code.kind == "global":
diff --git a/python/tvm_ffi/stub/consts.py b/python/tvm_ffi/stub/consts.py
index 53115386..ba790681 100644
--- a/python/tvm_ffi/stub/consts.py
+++ b/python/tvm_ffi/stub/consts.py
@@ -54,19 +54,23 @@ class MarkerSyntax:
 
     @property
     def ty_map(self) -> str:
-        """One-line type-map directive: ``<comment> 
tvm-ffi-stubgen(ty-map):``."""
-        return f"{self.prefix}ty-map):"
+        """The ``ty-map`` directive marker: ``<comment> 
tvm-ffi-stubgen(ty-map):``."""
+        return self.directive("ty-map")
 
     @property
     def import_object(self) -> str:
-        """One-line import-object directive: ``<comment> 
tvm-ffi-stubgen(import-object):``."""
-        return f"{self.prefix}import-object):"
+        """The ``import-object`` directive marker: ``<comment> 
tvm-ffi-stubgen(import-object):``."""
+        return self.directive("import-object")
 
     @property
     def skip_file(self) -> str:
         """Whole-file opt-out directive: ``<comment> 
tvm-ffi-stubgen(skip-file)``."""
         return f"{self.prefix}skip-file)"
 
+    def directive(self, name: str) -> str:
+        """One-line directive marker: ``<comment> 
tvm-ffi-stubgen(<name>):``."""
+        return f"{self.prefix}{name}):"
+
 
 PYTHON_SYNTAX = MarkerSyntax(comment="#")
 RUST_SYNTAX = MarkerSyntax(comment="//")
@@ -80,12 +84,16 @@ SYNTAX_BY_EXT: dict[str, MarkerSyntax] = {
     ".rs": RUST_SYNTAX,
 }
 
+#: One-line directive names consumed by the language-neutral pipeline. 
Generators
+#: must not declare these names; every other name must be declared by the 
active
+#: generator (``Generator.directive_kinds``).
+PIPELINE_DIRECTIVE_KINDS: frozenset[str] = frozenset({"ty-map"})
+
 STUB_BLOCK_KINDS: TypeAlias = Literal[
     "global",
     "object",
-    "ty-map",
     "import-section",
-    "import-object",
+    "directive",
     "export",
     "__all__",
     None,
diff --git a/python/tvm_ffi/stub/file_utils.py 
b/python/tvm_ffi/stub/file_utils.py
index 94d0769c..381254cf 100644
--- a/python/tvm_ffi/stub/file_utils.py
+++ b/python/tvm_ffi/stub/file_utils.py
@@ -49,9 +49,8 @@ class CodeBlock:
         assert self.kind in {
             "global",
             "object",
-            "ty-map",
             "import-section",
-            "import-object",
+            "directive",
             "export",
             "__all__",
             None,
@@ -68,23 +67,15 @@ class CodeBlock:
     @staticmethod
     def from_begin_line(lineo: int, line: str, syntax: C.MarkerSyntax) -> 
CodeBlock:
         """Parse a line to create a CodeBlock if it contains a stub begin 
marker."""
-        if line.startswith(syntax.ty_map):
-            line = line[len(syntax.ty_map) :].strip()
+        if not line.startswith(syntax.begin):
+            # One-line directive `<comment> tvm-ffi-stubgen(<name>): 
<payload>`. The payload is
+            # kept verbatim for whoever consumes the name: the pipeline or the 
generator.
+            name, sep, payload = line[len(syntax.prefix) :].partition("):")
+            if not sep or not name or not name.replace("-", 
"_").isidentifier():
+                raise ValueError(f"Unknown stub type at line {lineo}: {line}")
             return CodeBlock(
-                kind="ty-map",
-                param=line,
-                lineno_start=lineo,
-                lineno_end=lineo,
-                lines=[],
-            )
-        elif line.startswith(syntax.import_object):
-            line = line[len(syntax.import_object) :].strip()
-            splits = [p.strip() for p in line.split(";")]
-            if len(splits) < 3:
-                splits += [""] * (3 - len(splits))
-            return CodeBlock(
-                kind="import-object",
-                param=tuple(splits),
+                kind="directive",
+                param=(name, payload.strip()),
                 lineno_start=lineo,
                 lineno_end=lineo,
                 lines=[],
@@ -99,9 +90,6 @@ class CodeBlock:
         elif stub.startswith("object/"):
             kind = "object"
             param = stub[len("object/") :].strip()
-        elif stub.startswith("ty-map/"):
-            kind = "ty-map"
-            param = stub[len("ty-map/") :].strip()
         elif stub == "import-section":
             kind = "import-section"
             param = ""
@@ -199,22 +187,13 @@ class FileInfo:
                 code.lines.append(line)
                 codes.append(code)
                 code = None
-            elif clean_line.startswith(syntax.ty_map):
-                # Process "<comment> tvm-ffi-stubgen(ty_map)"
-                ty_code = CodeBlock.from_begin_line(lineno, clean_line, syntax)
-                ty_code.lineno_end = lineno
-                ty_code.lines.append(line)
-                codes.append(ty_code)
-                del ty_code
-            elif clean_line.startswith(syntax.import_object):
-                # Process "<comment> tvm-ffi-stubgen(import-object)"
-                imp_code = CodeBlock.from_begin_line(lineno, clean_line, 
syntax)
-                imp_code.lineno_end = lineno
-                imp_code.lines.append(line)
-                codes.append(imp_code)
-                del imp_code
             elif clean_line.startswith(syntax.prefix):
-                raise ValueError(f"Unknown stub type at line {lineno}: 
{clean_line}")
+                # Process a one-line directive "<comment> 
tvm-ffi-stubgen(<name>): ..."
+                dir_code = CodeBlock.from_begin_line(lineno, clean_line, 
syntax)
+                dir_code.lineno_end = lineno
+                dir_code.lines.append(line)
+                codes.append(dir_code)
+                del dir_code
             elif code is None:
                 # Process a plain line outside of any stub block
                 codes.append(
diff --git a/python/tvm_ffi/stub/generator.py b/python/tvm_ffi/stub/generator.py
index 99c3aa0d..3cd896a3 100644
--- a/python/tvm_ffi/stub/generator.py
+++ b/python/tvm_ffi/stub/generator.py
@@ -30,9 +30,10 @@ The stub generator separates two concerns:
 A :class:`Generator` encapsulates concern (2); ``cli.py`` drives concern (1) 
and
 delegates every act of emitting text — and every act of collecting imports — to
 the active generator. The import collector is opaque to the pipeline: 
``cli.py``
-asks the generator to create one, seed it from ``import-object`` directives, 
and
-later render it, but never reaches inside. Adding a language is therefore
-"implement one more :class:`Generator`" rather than forking the pipeline.
+asks the generator to create one, seed it from the one-line directives the
+generator declares, and later render it, but never reaches inside. Adding a
+language is therefore "implement one more :class:`Generator`" rather than
+forking the pipeline.
 """
 
 from __future__ import annotations
@@ -74,6 +75,11 @@ class Generator(Protocol):
     #: Comment-marker syntax for the files this generator emits.
     syntax: C.MarkerSyntax
 
+    #: Names of the one-line directives (``<comment> tvm-ffi-stubgen(<name>): 
<payload>``)
+    #: this generator consumes. Names in 
:data:`consts.PIPELINE_DIRECTIVE_KINDS` belong
+    #: to the pipeline; any other undeclared name is an error.
+    directive_kinds: frozenset[str]
+
     def default_ty_map(self) -> dict[str, str]:
         """Return the default FFI-origin -> target-type name map for this 
language."""
         ...
@@ -84,10 +90,13 @@ class Generator(Protocol):
         """Create a fresh, empty import collector for one file."""
         ...
 
-    def add_imported_object(
-        self, imports: Any, name: str, type_checking_only: str, alias: str
-    ) -> None:
-        """Record an ``import-object`` directive (raw directive fields) into 
``imports``."""
+    def add_directive(self, imports: Any, name: str, payload: str, lineno: 
int) -> None:
+        """Record a one-line directive (raw payload) into ``imports``.
+
+        The collector is per file, so a directive applies to the blocks of the
+        file it appears in. ``name`` is always one of :attr:`directive_kinds`;
+        the payload's grammar is the generator's to define.
+        """
         ...
 
     def canonical_type_name(self, type_key: str) -> str:
diff --git a/python/tvm_ffi/stub/python_generator/generator.py 
b/python/tvm_ffi/stub/python_generator/generator.py
index d05b2f2a..906c9a8f 100644
--- a/python/tvm_ffi/stub/python_generator/generator.py
+++ b/python/tvm_ffi/stub/python_generator/generator.py
@@ -44,6 +44,7 @@ class PythonGenerator:
     name = "python"
     syntax = C.PYTHON_SYNTAX
     source_exts = frozenset({".py", ".pyi"})
+    directive_kinds: frozenset[str] = frozenset({"import-object"})
 
     def default_ty_map(self) -> dict[str, str]:
         """Return the default FFI-origin -> Python-type name map."""
@@ -55,13 +56,14 @@ class PythonGenerator:
         """Create an empty import collector."""
         return PythonImports()
 
-    def add_imported_object(
-        self, imports: PythonImports, name: str, type_checking_only: str, 
alias: str
-    ) -> None:
-        """Record an ``import-object`` directive into the collector."""
+    def add_directive(self, imports: PythonImports, name: str, payload: str, 
lineno: int) -> None:
+        """Record an ``import-object`` directive 
(``<full_name>;<type_checking_only>;<alias>``)."""
+        assert name == "import-object", name
+        parts = [part.strip() for part in payload.split(";")]
+        full_name, type_checking_only, alias = parts + [""] * (3 - len(parts))
         tco = type_checking_only.lower() == "true"
-        imports.items.append(ImportItem(name, type_checking_only=tco, 
alias=alias or None))
-        if alias == "_FFI_LOAD_LIB" or 
name.endswith("libinfo.load_lib_module"):
+        imports.items.append(ImportItem(full_name, type_checking_only=tco, 
alias=alias or None))
+        if alias == "_FFI_LOAD_LIB" or 
full_name.endswith("libinfo.load_lib_module"):
             imports.has_lib_load = True
 
     def canonical_type_name(self, type_key: str) -> str:
diff --git a/tests/python/test_stubgen.py b/tests/python/test_stubgen.py
index 199ad796..c2606e46 100644
--- a/tests/python/test_stubgen.py
+++ b/tests/python/test_stubgen.py
@@ -26,9 +26,10 @@ from tvm_ffi import Object, method
 from tvm_ffi.core import MISSING, TypeSchema, 
_lookup_or_register_type_info_from_type_key
 from tvm_ffi.dataclasses import py_class
 from tvm_ffi.stub import consts as C
-from tvm_ffi.stub.cli import _stage_2, _stage_3
+from tvm_ffi.stub.cli import _stage_1, _stage_2, _stage_3
 from tvm_ffi.stub.file_utils import CodeBlock, FileInfo, collect_files, 
syntax_for
 from tvm_ffi.stub.generator import generator_names, get_generator
+from tvm_ffi.stub.python_generator import PythonGenerator
 from tvm_ffi.stub.python_generator import consts as PC
 from tvm_ffi.stub.python_generator.codegen import (
     generate_python_all,
@@ -88,7 +89,6 @@ def test_codeblock_from_begin_line_variants() -> None:
         (f"{C.PYTHON_SYNTAX.begin} global/demo", "global", ("demo", "")),
         (f"{C.PYTHON_SYNTAX.begin} global/[email protected]", "global", ("demo", 
".registry")),
         (f"{C.PYTHON_SYNTAX.begin} object/demo.TypeBase", "object", 
"demo.TypeBase"),
-        (f"{C.PYTHON_SYNTAX.begin} ty-map/custom", "ty-map", "custom"),
         (f"{C.PYTHON_SYNTAX.begin} import-section", "import-section", ""),
     ]
     for lineno, (line, kind, param) in enumerate(cases, start=1):
@@ -103,8 +103,8 @@ def test_codeblock_from_begin_line_variants() -> None:
 def test_codeblock_from_begin_line_ty_map_and_unknown() -> None:
     line = f"{C.PYTHON_SYNTAX.ty_map} custom -> mapped"
     block = CodeBlock.from_begin_line(5, line, C.PYTHON_SYNTAX)
-    assert block.kind == "ty-map"
-    assert block.param == "custom -> mapped"
+    assert block.kind == "directive"
+    assert block.param == ("ty-map", "custom -> mapped")
     assert block.lineno_start == 5
     assert block.lineno_end == 5
 
@@ -153,8 +153,8 @@ def test_fileinfo_from_file_parses_blocks(tmp_path: Path) 
-> None:
         C.PYTHON_SYNTAX.end,
     ]
 
-    assert ty_map.kind == "ty-map"
-    assert ty_map.param == "x -> y"
+    assert ty_map.kind == "directive"
+    assert ty_map.param == ("ty-map", "x -> y")
     assert ty_map.lineno_start == ty_map.lineno_end == 5
     assert ty_map.lines == [f"{C.PYTHON_SYNTAX.ty_map} x -> y"]
 
@@ -804,8 +804,8 @@ def test_stage_3_adds_LIB_when_load_lib_imported(tmp_path: 
Path) -> None:
         lines=[f"{C.PYTHON_SYNTAX.begin} global/testing", C.PYTHON_SYNTAX.end],
     )
     import_obj_block = CodeBlock(
-        kind="import-object",
-        param=("tvm_ffi.libinfo.load_lib_module", "False", "_FFI_LOAD_LIB"),
+        kind="directive",
+        param=("import-object", 
"tvm_ffi.libinfo.load_lib_module;False;_FFI_LOAD_LIB"),
         lineno_start=1,
         lineno_end=1,
         lines=[
@@ -1075,8 +1075,9 @@ def test_rust_marker_syntax_parses_rs_file(tmp_path: 
Path) -> None:
     info = FileInfo.from_file(rs)
     assert info is not None
     assert info.syntax is C.RUST_SYNTAX
-    assert [block.kind for block in info.code_blocks] == ["object", "ty-map"]
+    assert [block.kind for block in info.code_blocks] == ["object", 
"directive"]
     assert info.code_blocks[0].param == "demo.Foo"
+    assert info.code_blocks[1].param == ("ty-map", "a -> b")
     assert info.code_blocks[0].lines[1] == "pub struct Foo;"
 
     # Python markers are plain text inside a Rust file.
@@ -1107,3 +1108,102 @@ def test_generator_registry_names() -> None:
     assert generator_names() == ["python"]
     with pytest.raises(ValueError, match="Known generators: python"):
         get_generator("rust")
+
+
+def test_codeblock_from_begin_line_directive() -> None:
+    """``<comment> tvm-ffi-stubgen(<name>): <payload>`` parses into a one-line 
directive block."""
+    for syntax in (C.PYTHON_SYNTAX, C.RUST_SYNTAX):
+        block = CodeBlock.from_begin_line(7, f"{syntax.directive('field')} 
a.B.c -> D ", syntax)
+        assert block.kind == "directive"
+        assert block.param == ("field", "a.B.c -> D")
+        assert (block.lineno_start, block.lineno_end) == (7, 7)
+        assert block.lines == []
+    empty = CodeBlock.from_begin_line(1, C.PYTHON_SYNTAX.directive("opaque"), 
C.PYTHON_SYNTAX)
+    assert empty.param == ("opaque", "")
+
+    # The `name):` shape is required; anything else stays an unknown marker.
+    for line in (f"{C.PYTHON_SYNTAX.prefix}field)", 
f"{C.PYTHON_SYNTAX.prefix}two words): x"):
+        with pytest.raises(ValueError, match="Unknown stub type at line 3"):
+            CodeBlock.from_begin_line(3, line, C.PYTHON_SYNTAX)
+
+
+def test_fileinfo_from_file_keeps_directive_lines(tmp_path: Path) -> None:
+    """A directive is parsed as its own block and written back untouched."""
+    src = tmp_path / "mod.rs"
+    lines = [
+        "  // tvm-ffi-stubgen(nullable):  ir.Expr.span",
+        f"{C.RUST_SYNTAX.begin} object/demo.Foo",
+        C.RUST_SYNTAX.end,
+        f"{C.RUST_SYNTAX.prefix}bogus)",
+    ]
+    src.write_text("\n".join(lines) + "\n", encoding="utf-8")
+    with pytest.raises(ValueError, match="Unknown stub type at line 4"):
+        FileInfo.from_file(src)
+
+    src.write_text("\n".join(lines[:3]) + "\n", encoding="utf-8")
+    info = FileInfo.from_file(src)
+    assert info is not None
+    assert [block.kind for block in info.code_blocks] == ["directive", 
"object"]
+    assert info.code_blocks[0].param == ("nullable", "ir.Expr.span")
+    assert info.code_blocks[0].lines == [lines[0]]
+    assert info.update(verbose=False, dry_run=False) is False
+    assert src.read_text(encoding="utf-8") == "\n".join(lines[:3]) + "\n"
+
+
+def test_stage_3_routes_directives_by_generator_declaration(tmp_path: Path) -> 
None:
+    """Pipeline-owned names stay in the pipeline; declared names reach the 
generator."""
+    path = tmp_path / "demo.py"
+    ty_map_directive = CodeBlock(
+        kind="directive",
+        param=("ty-map", "demo.Foo -> mapped.Foo"),
+        lineno_start=1,
+        lineno_end=1,
+        lines=[f"{C.PYTHON_SYNTAX.ty_map} demo.Foo -> mapped.Foo"],
+    )
+    directive = CodeBlock(
+        kind="directive",
+        param=("field", "demo.Foo.x -> Bar"),
+        lineno_start=2,
+        lineno_end=2,
+        lines=[f"{C.PYTHON_SYNTAX.directive('field')} demo.Foo.x -> Bar"],
+    )
+    all_block = CodeBlock(
+        kind="__all__",
+        param="",
+        lineno_start=3,
+        lineno_end=4,
+        lines=[f"{C.PYTHON_SYNTAX.begin} __all__", C.PYTHON_SYNTAX.end],
+    )
+    blocks = (ty_map_directive, directive, all_block)
+
+    def _file_info() -> FileInfo:
+        return FileInfo(
+            path=path,
+            lines=tuple(line for block in blocks for line in block.lines),
+            code_blocks=list(blocks),
+            syntax=C.PYTHON_SYNTAX,
+        )
+
+    # `ty-map` is pipeline-owned: `_stage_1` consumes it and `_stage_3` skips 
it.
+    assert "ty-map" in C.PIPELINE_DIRECTIVE_KINDS
+    ty_map: dict[str, str] = {}
+    _stage_1(_file_info(), ty_map)
+    assert ty_map == {"demo.Foo": "mapped.Foo"}
+
+    # The Python generator does not declare `field`, so the name is unknown to 
it.
+    python = get_generator("python")
+    assert python.directive_kinds == frozenset({"import-object"})
+    with pytest.raises(ValueError, match="Unknown directive `field` at line 
2"):
+        _stage_3(_file_info(), Options(dry_run=True), _default_ty_map(), {}, 
python)
+
+    seen: list[tuple[object, str, str, int]] = []
+
+    class FieldAware(PythonGenerator):
+        directive_kinds = frozenset({"field"})
+
+        def add_directive(self, imports: object, name: str, payload: str, 
lineno: int) -> None:
+            seen.append((imports, name, payload, lineno))
+
+    _stage_3(_file_info(), Options(dry_run=True), _default_ty_map(), {}, 
FieldAware())
+    assert [entry[1:] for entry in seen] == [("field", "demo.Foo.x -> Bar", 2)]
+    assert isinstance(seen[0][0], type(python.new_imports()))

Reply via email to