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

tqchen 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 4e754f9f [STUBGEN] Let a file declare a namespace with a `prefix` 
directive (#752)
4e754f9f is described below

commit 4e754f9f647e16ffe0aee860531f55d62e33138b
Author: Linzhang Li <[email protected]>
AuthorDate: Sun Sep 6 08:06:42 2026 -0400

    [STUBGEN] Let a file declare a namespace with a `prefix` directive (#752)
    
    ## Summary
    
    A file that starts with
    
    ```rust
    // tvm-ffi-stubgen(prefix): tirx
    // tvm-ffi-stubgen(skip): tirx.DispatchContext
    ```
    
    owns every object registered directly under `tirx` (`tirx.transform.*`
    is another file). On each run the tool appends an empty `object/<key>`
    block for every such object that no file of the run defines yet, parents
    first, after the file's last block, and fills it as usual; an
    `import-section` is added after the `prefix` line when the file has
    none. `skip` leaves one object out, and a skipped object counts as not
    asked for, so the dependency error from #748 names it if something else
    needs it. Code outside the blocks is untouched, so a one-line skeleton
    is a valid starting point. Rust target only: a Python `object/` block
    needs a class around it.
    
    The roll-out happens in memory before stage 3, so the new blocks join
    `declared` and `--check` (#746) reports a file with pending blocks as
    stale. It runs after `--init`, which rewrites files on disk; `--init`
    honours `skip` as well. Builtin `ffi.*` keys are never rolled out. Two
    `prefix` lines in one file, or one prefix declared by two files, fail
    the run (exit 2); an unknown prefix is only a warning.
    
    ## Changes
    
    - `stub/cli.py`: `_roll_out_prefixes` and `_new_blocks`, called once
    after `--init`, returning a failure count; `_stage_2` subtracts the
    run's `skip` keys.
    - `stub/consts.py`: `prefix` and `skip` join the pipeline directive
    kinds.
    - `docs/packaging/stubgen.rst`: the directive in the reference.
    - `tests/python/test_stubgen_rust.py`: the `testing` namespace rolled
    out around an existing block, a skip, and a hand-written tail (`--check`
    1, in place 0, `--check` 0); the same skeleton under `--init` (blocks
    written, skip honoured, `--check` 0); a prefix declared twice; exact
    matching, insertion order, and the builtin exclusion with a stubbed
    registry.
    
    ## Testing
    
    `test_stubgen_rust.py` + `test_stubgen.py`: 109 passed; pre-commit lint
    clean. Against `libtvm_compiler.so`, a `tirx.rs` with the two lines
    above, one more `skip`, and thirteen `ty-map` lines for the `ir` types
    rolls out 63 blocks in one run, leaves no `super::` reference, keeps the
    trailing `mod tail {}`, and passes `--check` on the next run.
    
    ---------
    
    Signed-off-by: yuchuan <[email protected]>
---
 docs/packaging/stubgen.rst        |  10 +++
 python/tvm_ffi/stub/cli.py        |  72 ++++++++++++++++++++-
 python/tvm_ffi/stub/consts.py     |   2 +-
 tests/python/test_stubgen_rust.py | 128 ++++++++++++++++++++++++++++++++++++++
 4 files changed, 209 insertions(+), 3 deletions(-)

diff --git a/docs/packaging/stubgen.rst b/docs/packaging/stubgen.rst
index 3b446a9d..98087e2e 100644
--- a/docs/packaging/stubgen.rst
+++ b/docs/packaging/stubgen.rst
@@ -387,6 +387,16 @@ When you run the tool, it:
 
       # tvm-ffi-stubgen(ty-map): ffi.reflection.AccessStep -> 
ffi.access_path.AccessStep
 
+``prefix`` - Demand a Namespace
+   Rust target only. Adds an ``object/<type_key>`` block to the file for every 
object
+   registered directly under the prefix that no processed file defines yet; 
``skip``
+   leaves one out. Code outside the blocks is preserved.
+
+   .. code-block:: rust
+
+      // tvm-ffi-stubgen(prefix): my_ffi_extension
+      // tvm-ffi-stubgen(skip): my_ffi_extension.Internal
+
 ``import-object`` - Import Object
    Injects a custom import into generated code. The format is
    ``<full_name>;<type_checking_only>;<alias>``.
diff --git a/python/tvm_ffi/stub/cli.py b/python/tvm_ffi/stub/cli.py
index 3fdffa4b..4f96e9ae 100644
--- a/python/tvm_ffi/stub/cli.py
+++ b/python/tvm_ffi/stub/cli.py
@@ -27,7 +27,7 @@ from pathlib import Path
 from typing import TYPE_CHECKING
 
 from . import consts as C
-from .file_utils import FileInfo, collect_files, syntax_for
+from .file_utils import CodeBlock, FileInfo, collect_files, syntax_for
 from .generator import generator_names, get_generator
 from .layout import classify, write_coverage_report
 from .lib_state import (
@@ -96,6 +96,10 @@ def __main__() -> int:
             generator=generator,
         )
 
+    # Stage 2b. Add the object blocks a `tvm-ffi-stubgen(prefix)` file asks 
for. This runs
+    # after `--init`, which rewrites files on disk and reloads them.
+    failed += _roll_out_prefixes(files)
+
     # Stage 3: Process
     # - `tvm-ffi-stubgen(begin): global/...`
     # - `tvm-ffi-stubgen(begin): object/...`
@@ -162,6 +166,64 @@ def _stage_1(
         ty_map[lhs.strip()] = rhs.strip()
 
 
+def _roll_out_prefixes(files: list[FileInfo]) -> int:
+    """Append an ``object/<key>`` block for each registered object under a 
file's ``prefix``.
+
+    Keys with a block in any file of the run, or named by ``skip``, are left 
alone; an
+    ``import-section`` is added when the file has none. Returns the number of 
bad files.
+    """
+    defined = {
+        code.param for file in files for code in file.code_blocks if code.kind 
== "object"
+    } | C.BUILTIN_TYPE_KEYS
+    registry = collect_type_keys()
+    owners: dict[str, Path] = {}
+    failed = 0
+    for file in files:
+        directives = [c for c in file.code_blocks if c.kind == "directive"]
+        heads = [c for c in directives if c.param[0] == "prefix"]
+        if not heads:
+            continue
+        head = heads[0]
+        prefix = head.param[1].rstrip(".")
+        error = ""
+        if len(heads) > 1:
+            error = f"more than one `prefix` directive (line 
{heads[1].lineno_start})"
+        elif owners.setdefault(prefix, file.path) != file.path:
+            error = f"prefix `{prefix}` is already declared by 
{owners[prefix]}"
+        if error:
+            failed += 1
+            print(f'{C.TERM_RED}[Failed] File "{file.path}": 
{error}{C.TERM_RESET}')
+            continue
+        if prefix not in registry:
+            print(
+                f"{C.TERM_YELLOW}[Skipped] No registered object under prefix 
`{prefix}`{C.TERM_RESET}"
+            )
+            continue
+        skipped = {c.param[1].strip() for c in directives if c.param[0] == 
"skip"}
+        keys = [key for key in registry[prefix] if key not in defined and key 
not in skipped]
+        blocks = file.code_blocks
+        if not any(c.kind == "import-section" for c in blocks):
+            at = blocks.index(head) + 1
+            blocks[at:at] = _new_blocks(file.syntax, head.lineno_start, 
"import-section")
+        at = max(i for i, c in enumerate(blocks) if c.kind in ("object", 
"import-section")) + 1
+        blocks[at:at] = [
+            block
+            for info in toposort_objects(keys)
+            for block in _new_blocks(file.syntax, head.lineno_start, 
f"object/{info.type_key}")
+        ]
+    return failed
+
+
+def _new_blocks(syntax: C.MarkerSyntax, lineno: int, stub: str) -> 
list[CodeBlock]:
+    """Return a blank line and an empty ``begin``/``end`` block for ``stub``, 
to insert into a file."""
+    begin = f"{syntax.begin} {stub}"
+    block = CodeBlock.from_begin_line(lineno, begin, syntax)
+    block.lineno_end = lineno
+    block.lines = [begin, syntax.end]
+    blank = CodeBlock(kind=None, param="", lineno_start=lineno, 
lineno_end=lineno, lines=[""])
+    return [blank, block]
+
+
 def _stage_2(
     files: list[FileInfo],
     ty_map: dict[str, str],
@@ -190,6 +252,12 @@ def _stage_2(
     defined_objs: set[str] = {  # ty: ignore[invalid-assignment]
         code.param for file in files for code in file.code_blocks if code.kind 
== "object"
     } | C.BUILTIN_TYPE_KEYS
+    skipped: set[str] = {
+        code.param[1].strip()
+        for file in files
+        for code in file.code_blocks
+        if code.kind == "directive" and code.param[0] == "skip"
+    }
 
     # Step 0. Generate missing `_ffi_api.py` and `__init__.py` under each 
prefix.
     prefix_filter = init_cfg.prefix.strip()
@@ -207,7 +275,7 @@ def _stage_2(
             [] if prefix in defined_func_prefixes else 
global_funcs.get(prefix, []),
             key=lambda f: f.schema.name,
         )
-        objs = sorted(set(obj_names) - defined_objs)
+        objs = sorted(set(obj_names) - defined_objs - skipped)
         object_infos = toposort_objects(objs)
         if not funcs and not object_infos:
             continue
diff --git a/python/tvm_ffi/stub/consts.py b/python/tvm_ffi/stub/consts.py
index ba790681..5df28d5f 100644
--- a/python/tvm_ffi/stub/consts.py
+++ b/python/tvm_ffi/stub/consts.py
@@ -87,7 +87,7 @@ SYNTAX_BY_EXT: dict[str, MarkerSyntax] = {
 #: 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"})
+PIPELINE_DIRECTIVE_KINDS: frozenset[str] = frozenset({"ty-map", "prefix", 
"skip"})
 
 STUB_BLOCK_KINDS: TypeAlias = Literal[
     "global",
diff --git a/tests/python/test_stubgen_rust.py 
b/tests/python/test_stubgen_rust.py
index 39493400..97a446d2 100644
--- a/tests/python/test_stubgen_rust.py
+++ b/tests/python/test_stubgen_rust.py
@@ -1302,3 +1302,131 @@ def 
test_stage_3_checks_dependencies_across_the_run(tmp_path: Path) -> None:
     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
+
+
+# ---------------------------------------------------------------------------
+# `prefix` / `skip`: a file declares a namespace and the blocks are rolled out
+# ---------------------------------------------------------------------------
+
+
+def test_prefix_directive_rolls_out_a_namespace(
+    tmp_path: Path, monkeypatch: pytest.MonkeyPatch
+) -> None:
+    src = tmp_path / "testing.rs"
+    src.write_text(
+        "\n".join(
+            [
+                "//! Hand-written skeleton.",
+                f"{C.RUST_SYNTAX.directive('prefix')} testing",
+                f"{C.RUST_SYNTAX.directive('skip')} 
testing.TestCxxClassDerivedDerived",
+                "",
+                f"{C.RUST_SYNTAX.begin} object/testing.TestCxxClassBase",
+                C.RUST_SYNTAX.end,
+                "",
+                "pub fn hand_written() {}",
+                "",
+            ]
+        ),
+        encoding="utf-8",
+    )
+    check = ["tvm-ffi-stubgen", "--target", "rust", "--check", str(tmp_path)]
+    monkeypatch.setattr("sys.argv", check)
+    assert stub_cli.__main__() == 1  # the blocks still to be added make the 
file stale
+    monkeypatch.setattr("sys.argv", ["tvm-ffi-stubgen", "--target", "rust", 
str(tmp_path)])
+    assert stub_cli.__main__() == 0
+    text = src.read_text(encoding="utf-8")
+    begins = [line for line in text.splitlines() if 
line.startswith(C.RUST_SYNTAX.begin)]
+    # An import section is added after the `prefix` line; the existing block 
is kept once.
+    assert begins[0] == f"{C.RUST_SYNTAX.begin} import-section"
+    assert begins[1] == f"{C.RUST_SYNTAX.begin} 
object/testing.TestCxxClassBase"
+    assert text.count("object/testing.TestCxxClassBase\n") == 1
+    # The rest of the namespace follows, parents first; the skipped leaf is 
absent.
+    derived = begins.index(f"{C.RUST_SYNTAX.begin} 
object/testing.TestCxxClassDerived")
+    assert derived > 1
+    assert "object/testing.TestCxxClassDerivedDerived" not in text
+    assert "pub struct TestCxxClassDerivedObj {" in text
+    assert text.endswith("pub fn hand_written() {}\n")
+    monkeypatch.setattr("sys.argv", check)
+    assert stub_cli.__main__() == 0
+
+
+def test_prefix_declared_twice_is_a_failure(
+    tmp_path: Path, monkeypatch: pytest.MonkeyPatch
+) -> None:
+    for name in ("a.rs", "b.rs"):
+        (tmp_path / name).write_text(
+            f"{C.RUST_SYNTAX.directive('prefix')} testing\n", encoding="utf-8"
+        )
+    monkeypatch.setattr(
+        "sys.argv", ["tvm-ffi-stubgen", "--target", "rust", "--check", 
str(tmp_path)]
+    )
+    assert stub_cli.__main__() == 2
+
+
+def test_roll_out_matches_the_prefix_exactly(
+    tmp_path: Path, monkeypatch: pytest.MonkeyPatch
+) -> None:
+    monkeypatch.setattr(
+        stub_cli,
+        "collect_type_keys",
+        lambda: {"a": ["a.Y", "a.X"], "a.b": ["a.b.Z"], "ffi": ["ffi.Object"]},
+    )
+    monkeypatch.setattr(
+        stub_cli,
+        "toposort_objects",
+        lambda keys: [ObjectInfo(fields=[], methods=[], type_key=key) for key 
in sorted(keys)],
+    )
+    begin, end = C.RUST_SYNTAX.begin, C.RUST_SYNTAX.end
+    src = tmp_path / "a.rs"
+    src.write_text(
+        "\n".join(
+            [
+                f"{C.RUST_SYNTAX.directive('prefix')} a.",
+                f"{begin} import-section",
+                end,
+                "mod tail {}",
+                "",
+            ]
+        ),
+        encoding="utf-8",
+    )
+    info = FileInfo.from_file(src)
+    assert info is not None
+    assert stub_cli._roll_out_prefixes([info]) == 0
+    assert [line for block in info.code_blocks for line in block.lines] == [
+        f"{C.RUST_SYNTAX.directive('prefix')} a.",
+        f"{begin} import-section",
+        end,
+        "",
+        f"{begin} object/a.X",
+        end,
+        "",
+        f"{begin} object/a.Y",
+        end,
+        "mod tail {}",
+    ]
+    # Builtin type keys live in the crate and are never rolled out.
+    src.write_text(f"{C.RUST_SYNTAX.directive('prefix')} ffi\n", 
encoding="utf-8")
+    info = FileInfo.from_file(src)
+    assert info is not None
+    assert stub_cli._roll_out_prefixes([info]) == 0
+    assert not any(block.kind == "object" for block in info.code_blocks)
+
+
+def test_prefix_survives_init(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) 
-> None:
+    """`--init` rewrites the file on disk and reloads it; the roll-out must 
come after that."""
+    (tmp_path / "testing").mkdir()
+    mod_rs = tmp_path / "testing" / "mod.rs"
+    skip = f"{C.RUST_SYNTAX.directive('skip')} 
testing.TestCxxClassDerivedDerived"
+    mod_rs.write_text(f"{C.RUST_SYNTAX.directive('prefix')} 
testing\n{skip}\n", encoding="utf-8")
+    init = ["--init-pypkg", "demo", "--init-lib", "demo_shared", 
"--init-prefix", "testing."]
+    monkeypatch.setattr("sys.argv", ["tvm-ffi-stubgen", "--target", "rust", 
*init, str(tmp_path)])
+    assert stub_cli.__main__() == 0
+    text = mod_rs.read_text(encoding="utf-8")
+    assert text.startswith(f"{C.RUST_SYNTAX.directive('prefix')} testing\n")
+    assert "pub struct TestCxxClassDerivedObj {" in text
+    assert "object/testing.TestCxxClassDerivedDerived" not in text  # `--init` 
honours `skip`
+    monkeypatch.setattr(
+        "sys.argv", ["tvm-ffi-stubgen", "--target", "rust", "--check", 
str(tmp_path)]
+    )
+    assert stub_cli.__main__() == 0

Reply via email to