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 62df2f57 [STUBGEN] Add a `--check` mode that reports stale stub files 
(#746)
62df2f57 is described below

commit 62df2f579567802cef3cba0126ac2aea299169ef
Author: Linzhang Li <[email protected]>
AuthorDate: Sat Sep 5 12:33:30 2026 -0400

    [STUBGEN] Add a `--check` mode that reports stale stub files (#746)
    
    ## Summary
    
    `tvm-ffi-stubgen` updates generated blocks in place, but always exited
    0, so CI could not tell whether a tree was up to date. `--check` writes
    nothing, prints `[Stale] <file>` for every file whose blocks would
    change, and exits 1 if there is any; a file that fails to process exits
    2 (in every mode; it used to exit 0). This is the CI half of the
    in-place / `--check` pair, like `ruff format --check`.
    
    `--check` is `--dry-run` plus the report: it sets `dry_run` at parse
    time, and `_stage_3` returns the changed flag `FileInfo.update` already
    computes. It refuses `--init-*`, since `--init` scaffolds files on disk
    regardless of `dry_run`.
    
    ## Changes
    
    - `stub/cli.py`: `--check` flag, `_stage_3` returns changed, `__main__`
    returns `2 if failed else 1 if stale else 0`.
    - `stub/utils.py`: `Options.check`.
    - `docs/packaging/stubgen.rst`: the option and its exit codes.
    - `tests/python/test_stubgen_rust.py`: stale round trip, failure exits
    2, `--check` + `--init-*` rejected.
    
    ## Testing
    
    `test_stubgen_rust.py` + `test_stubgen.py`: 102 passed; pre-commit lint
    clean.
    
    Signed-off-by: yuchuan <[email protected]>
---
 docs/packaging/stubgen.rst        |  5 +++
 python/tvm_ffi/stub/cli.py        | 33 +++++++++++++---
 python/tvm_ffi/stub/utils.py      |  3 +-
 tests/python/test_stubgen_rust.py | 82 +++++++++++++++++++++++++++++++++++++++
 4 files changed, 116 insertions(+), 7 deletions(-)

diff --git a/docs/packaging/stubgen.rst b/docs/packaging/stubgen.rst
index 1e4ff1ed..3b446a9d 100644
--- a/docs/packaging/stubgen.rst
+++ b/docs/packaging/stubgen.rst
@@ -245,6 +245,11 @@ All three are required together. When omitted, the tool 
operates in directive-on
 ``--dry-run``
    Preview changes without writing to files.
 
+``--check``
+   Write nothing; print ``[Stale] <file>`` for every file whose stub blocks 
are out of
+   date and exit with status 1 if there is any (2 if a file failed to 
process), so CI
+   can reject a stale tree. Cannot be combined with ``--init-*``.
+
 ``--imports``
    Additional Python modules to import before generation (semicolon-separated).
 
diff --git a/python/tvm_ffi/stub/cli.py b/python/tvm_ffi/stub/cli.py
index a5e0ba39..a1afe73f 100644
--- a/python/tvm_ffi/stub/cli.py
+++ b/python/tvm_ffi/stub/cli.py
@@ -48,6 +48,9 @@ def __main__() -> int:
     This generates in-place type stubs inside special ``tvm-ffi-stubgen`` 
blocks
     in the given files or directories. See the module docstring for an
     overview and examples of the block syntax.
+
+    Returns 0, or 2 when a file failed to process, or 1 when ``--check`` found
+    a file whose stub blocks are out of date.
     """
     opt = _parse_args()
     generator = get_generator(opt.target)
@@ -67,10 +70,13 @@ def __main__() -> int:
     # - defined global functions: `tvm-ffi-stubgen(begin): global/...`
     # - defined object types: `tvm-ffi-stubgen(begin): object/...`
     ty_map: dict[str, str] = generator.default_ty_map()
+    failed = 0
+    stale = 0
     for file in files:
         try:
             _stage_1(file, ty_map)
         except Exception:
+            failed += 1
             print(
                 f'{C.TERM_RED}[Failed] File "{file.path}": 
{traceback.format_exc()}{C.TERM_RESET}'
             )
@@ -95,7 +101,7 @@ def __main__() -> int:
         if opt.verbose:
             print(f"{C.TERM_CYAN}[File] {file.path}{C.TERM_RESET}")
         try:
-            _stage_3(
+            changed = _stage_3(
                 file,
                 opt,
                 ty_map,
@@ -103,9 +109,14 @@ def __main__() -> int:
                 generator=generator,
             )
         except Exception:
+            failed += 1
             print(
                 f'{C.TERM_RED}[Failed] File "{file.path}": 
{traceback.format_exc()}{C.TERM_RESET}'
             )
+            continue
+        if changed and opt.check:
+            stale += 1
+            print(f"{C.TERM_YELLOW}[Stale] {file.path}{C.TERM_RESET}")
 
     # Stage 4. Let the generator stitch the generated tree together (runs 
after the
     # files are fully written, so language-specific wiring isn't clobbered).
@@ -122,7 +133,7 @@ def __main__() -> int:
         }
         write_coverage_report(Path(opt.coverage_out), classify(infos))
     del dlls
-    return 0
+    return 2 if failed else 1 if stale else 0
 
 
 def _stage_1(
@@ -227,7 +238,8 @@ def _stage_3(  # noqa: PLR0912
     ty_map: dict[str, str],
     global_funcs: dict[str, list[FuncInfo]],
     generator: Generator,
-) -> None:
+) -> bool:
+    """Process one file's blocks; return whether its content is (or would be) 
changed."""
     defined_funcs: set[str] = set()
     defined_types: set[str] = set()
     imports = generator.new_imports()
@@ -273,7 +285,7 @@ def _stage_3(  # noqa: PLR0912
         if code.kind == "export":
             generator.generate_export_block(code)
     # Finalize: write back to file
-    file.update(verbose=opt.verbose, dry_run=opt.dry_run)
+    return file.update(verbose=opt.verbose, dry_run=opt.dry_run)
 
 
 def _parse_args() -> Options:
@@ -390,6 +402,14 @@ def _parse_args() -> Options:
             "without modifying any files."
         ),
     )
+    parser.add_argument(
+        "--check",
+        action="store_true",
+        help=(
+            "Don't write changes; report every file whose stub blocks are out 
of date "
+            "and exit with status 1 if there is any. Cannot be combined with 
--init-*."
+        ),
+    )
     parser.add_argument(
         "--coverage-out",
         type=str,
@@ -413,6 +433,8 @@ def _parse_args() -> Options:
             shared_target=args.init_lib,
             prefix=args.init_prefix,
         )
+    if args.check and init_cfg is not None:
+        parser.error("--check cannot be combined with --init-* flags")
 
     if not args.files and args.coverage_out is None:
         parser.print_help()
@@ -425,7 +447,8 @@ def _parse_args() -> Options:
         indent=args.indent,
         files=args.files,
         verbose=args.verbose,
-        dry_run=args.dry_run,
+        dry_run=args.dry_run or args.check,
+        check=args.check,
         target=args.target,
         coverage_out=args.coverage_out,
     )
diff --git a/python/tvm_ffi/stub/utils.py b/python/tvm_ffi/stub/utils.py
index 7f5ea625..ae22c275 100644
--- a/python/tvm_ffi/stub/utils.py
+++ b/python/tvm_ffi/stub/utils.py
@@ -83,10 +83,9 @@ class Options:
     files: list[str] = dataclasses.field(default_factory=list)
     verbose: bool = False
     dry_run: bool = False
+    check: bool = False
     target: str = "python"
-    """Code generator target to use."""
     coverage_out: str | None = None
-    """Path of the native-layout coverage report (JSON) to write, if 
requested."""
 
 
 @dataclasses.dataclass(init=False)
diff --git a/tests/python/test_stubgen_rust.py 
b/tests/python/test_stubgen_rust.py
index f2532cb5..438a1131 100644
--- a/tests/python/test_stubgen_rust.py
+++ b/tests/python/test_stubgen_rust.py
@@ -1145,3 +1145,85 @@ def test_cli_init_generates_a_module_tree(tmp_path: 
Path, monkeypatch: pytest.Mo
     # Running again over the generated tree is a no-op.
     assert stub_cli.__main__() == 0
     assert (tmp_path / "testing" / "mod.rs").read_text(encoding="utf-8") == 
text
+
+
+def test_cli_check_reports_stale_files(
+    tmp_path: Path, monkeypatch: pytest.MonkeyPatch, capsys: 
pytest.CaptureFixture[str]
+) -> None:
+    monkeypatch.setattr(
+        "sys.argv",
+        [
+            "tvm-ffi-stubgen",
+            "--target",
+            "rust",
+            "--init-pypkg",
+            "demo",
+            "--init-lib",
+            "demo_shared",
+            "--init-prefix",
+            "testing.",
+            str(tmp_path),
+        ],
+    )
+    assert stub_cli.__main__() == 0
+    mod_rs = tmp_path / "testing" / "mod.rs"
+    fresh = mod_rs.read_text(encoding="utf-8")
+    check = ["tvm-ffi-stubgen", "--target", "rust", "--check", str(tmp_path)]
+    # An up-to-date tree passes the check.
+    monkeypatch.setattr("sys.argv", check)
+    assert stub_cli.__main__() == 0
+    assert "[Stale]" not in capsys.readouterr().out
+    # A stale block fails it, is named, and is left untouched.
+    stale = fresh.replace("    pub v_str: String,\n", "    pub v_str: String, 
// stale\n", 1)
+    assert stale != fresh
+    mod_rs.write_text(stale, encoding="utf-8")
+    assert stub_cli.__main__() == 1
+    assert f"[Stale] {mod_rs}" in capsys.readouterr().out
+    assert mod_rs.read_text(encoding="utf-8") == stale
+    # Running in place repairs it; the check passes again.
+    monkeypatch.setattr("sys.argv", ["tvm-ffi-stubgen", "--target", "rust", 
str(tmp_path)])
+    assert stub_cli.__main__() == 0
+    assert mod_rs.read_text(encoding="utf-8") == fresh
+    monkeypatch.setattr("sys.argv", check)
+    assert stub_cli.__main__() == 0
+
+
+def test_cli_failed_file_exits_2(tmp_path: Path, monkeypatch: 
pytest.MonkeyPatch) -> None:
+    (tmp_path / "mod.rs").write_text(
+        "\n".join(
+            [
+                f"{C.RUST_SYNTAX.directive('bogus')} testing.TestCxxClassBase",
+                f"{C.RUST_SYNTAX.begin} object/testing.TestCxxClassBase",
+                C.RUST_SYNTAX.end,
+                "",
+            ]
+        ),
+        encoding="utf-8",
+    )
+    for extra in ([], ["--check"]):
+        monkeypatch.setattr(
+            "sys.argv", ["tvm-ffi-stubgen", "--target", "rust", *extra, 
str(tmp_path)]
+        )
+        assert stub_cli.__main__() == 2
+
+
+def test_cli_check_rejects_init_flags(tmp_path: Path, monkeypatch: 
pytest.MonkeyPatch) -> None:
+    monkeypatch.setattr(
+        "sys.argv",
+        [
+            "tvm-ffi-stubgen",
+            "--target",
+            "rust",
+            "--check",
+            "--init-pypkg",
+            "demo",
+            "--init-lib",
+            "demo_shared",
+            "--init-prefix",
+            "testing.",
+            str(tmp_path),
+        ],
+    )
+    with pytest.raises(SystemExit) as excinfo:
+        stub_cli.__main__()
+    assert excinfo.value.code == 2

Reply via email to