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 17ea4d7b [STUBGEN] Carry native layout facts through the 
language-neutral data. (#720)
17ea4d7b is described below

commit 17ea4d7ba2d4153aa991f1c581cecd2a06f17e26
Author: Linzhang Li <[email protected]>
AuthorDate: Wed Sep 2 10:32:11 2026 -0400

    [STUBGEN] Carry native layout facts through the language-neutral data. 
(#720)
    
    `NamedTypeSchema` now records the byte facts the C++ registry already
    publishes for every reflected field (size/alignment/offset, static
    default or factory value, structural-eq flag, frozen-ness), and
    `ObjectInfo` records the ancestor chain plus the type's own `sizeof`
    (`None` for a type without its own metadata entry, whose inherited size
    says nothing about the type). This gives non-Python backends the
    memory-layout inputs they need without a second reflection pass.
    
    Language-neutral infrastructure for such backends:
    
    - `//` marker syntax for `.rs` files (the block parser was already
    syntax-driven);
    - directory scans visit only the active generator's `source_exts`, so
    one tree can hold stub files for several languages without one target
    rewriting another's files;
    - `--target` choices follow the generator registry instead of a
    hard-coded list;
    - drop the unused `generate_helpers_block` protocol method.
    
    No generator other than `python` is registered yet.
---
 python/tvm_ffi/stub/cli.py                        |  11 +-
 python/tvm_ffi/stub/consts.py                     |  13 ++-
 python/tvm_ffi/stub/file_utils.py                 |  14 ++-
 python/tvm_ffi/stub/generator.py                  |  10 ++
 python/tvm_ffi/stub/python_generator/generator.py |   1 +
 python/tvm_ffi/stub/utils.py                      | 124 +++++++++++++++-----
 tests/python/test_stubgen.py                      | 133 +++++++++++++++++++++-
 7 files changed, 259 insertions(+), 47 deletions(-)

diff --git a/python/tvm_ffi/stub/cli.py b/python/tvm_ffi/stub/cli.py
index 66688de9..2b054c72 100644
--- a/python/tvm_ffi/stub/cli.py
+++ b/python/tvm_ffi/stub/cli.py
@@ -28,7 +28,7 @@ from typing import TYPE_CHECKING
 
 from . import consts as C
 from .file_utils import FileInfo, collect_files, syntax_for
-from .generator import get_generator
+from .generator import generator_names, get_generator
 from .lib_state import (
     collect_global_funcs,
     collect_type_keys,
@@ -53,7 +53,7 @@ def __main__() -> int:
     for imp in opt.imports or []:
         importlib.import_module(imp)
     dlls = [ctypes.CDLL(lib) for lib in opt.dlls]
-    files: list[FileInfo] = collect_files([Path(f) for f in opt.files])
+    files: list[FileInfo] = collect_files([Path(f) for f in opt.files], 
generator.source_exts)
     global_funcs: dict[str, list[FuncInfo]] = collect_global_funcs()
     init_path: Path | None = None
     if opt.files:
@@ -347,15 +347,16 @@ def _parse_args() -> Options:
         metavar="PATH",
         help=(
             "Files or directories to process. Directories are scanned 
recursively; "
-            "only .py and .pyi files are modified. Use tvm-ffi-stubgen 
directives to "
-            "select where stubs are generated."
+            "only files with the target's source extensions (.py and .pyi for 
python) "
+            "are modified. Use tvm-ffi-stubgen directives to select where 
stubs are "
+            "generated."
         ),
     )
     parser.add_argument(
         "--target",
         type=str,
         default="python",
-        choices=["python"],
+        choices=generator_names(),
         help="Code generator target.",
     )
     parser.add_argument(
diff --git a/python/tvm_ffi/stub/consts.py b/python/tvm_ffi/stub/consts.py
index 46ba9709..53115386 100644
--- a/python/tvm_ffi/stub/consts.py
+++ b/python/tvm_ffi/stub/consts.py
@@ -29,9 +29,9 @@ class MarkerSyntax:
     """Comment-syntax-specific stub directive markers.
 
     All stub directives are embedded inside single-line comments. The comment
-    token (currently ``#`` for Python sources) parameterizes the marker set,
-    while the directive grammar (``tvm-ffi-stubgen(begin): ...`` etc.) stays
-    uniform.
+    token (``#`` for Python sources, ``//`` for Rust) parameterizes the marker
+    set, while the directive grammar (``tvm-ffi-stubgen(begin): ...`` etc.)
+    stays uniform.
     """
 
     comment: str
@@ -69,12 +69,15 @@ class MarkerSyntax:
 
 
 PYTHON_SYNTAX = MarkerSyntax(comment="#")
+RUST_SYNTAX = MarkerSyntax(comment="//")
 
 #: Map a source-file extension to the marker syntax used inside it. The block
-#: parser selects the syntax per file.
+#: parser selects the syntax per file. Which extensions a run actually visits 
is
+#: decided by the active generator (``Generator.source_exts``), not by this 
map.
 SYNTAX_BY_EXT: dict[str, MarkerSyntax] = {
     ".py": PYTHON_SYNTAX,
     ".pyi": PYTHON_SYNTAX,
+    ".rs": RUST_SYNTAX,
 }
 
 STUB_BLOCK_KINDS: TypeAlias = Literal[
@@ -100,8 +103,6 @@ TERM_CYAN = "\033[36m"
 TERM_WHITE = "\033[37m"
 DOC_URL = "https://tvm.apache.org/ffi/packaging/stubgen.html";
 
-DEFAULT_SOURCE_EXTS = set(SYNTAX_BY_EXT)
-
 # Language-neutral metadata transform applied while building `ObjectInfo` from
 # the FFI reflection registry (see `utils.ObjectInfo.from_type_info`).
 FN_NAME_MAP: dict[str, str] = {}
diff --git a/python/tvm_ffi/stub/file_utils.py 
b/python/tvm_ffi/stub/file_utils.py
index d6c75f56..94d0769c 100644
--- a/python/tvm_ffi/stub/file_utils.py
+++ b/python/tvm_ffi/stub/file_utils.py
@@ -22,7 +22,7 @@ import dataclasses
 import difflib
 import os
 import traceback
-from collections.abc import Generator, Iterable
+from collections.abc import Collection, Generator, Iterable
 from pathlib import Path
 from typing import Callable
 
@@ -239,8 +239,14 @@ class FileInfo:
         self.code_blocks = source.code_blocks
 
 
-def collect_files(paths: list[Path]) -> list[FileInfo]:
-    """Collect all files from the given paths and parse them into FileInfo 
objects."""
+def collect_files(paths: list[Path], source_exts: Collection[str]) -> 
list[FileInfo]:
+    """Collect all files from the given paths and parse them into FileInfo 
objects.
+
+    A path given explicitly is always visited; a directory is walked 
recursively
+    and only files whose (lower-cased) extension is in ``source_exts`` -- the
+    extensions the active generator owns -- are considered.
+    """
+    exts = {ext.lower() for ext in source_exts}
 
     def _on_error(e: Exception) -> None:
         print(
@@ -257,7 +263,7 @@ def collect_files(paths: list[Path]) -> list[FileInfo]:
             for root, _dirs, files in path_walk(p, follow_symlinks=False, 
on_error=_on_error):
                 for file in files:
                     f = Path(root) / file
-                    if f.suffix.lower() not in C.DEFAULT_SOURCE_EXTS:
+                    if f.suffix.lower() not in exts:
                         continue
                     yield f
 
diff --git a/python/tvm_ffi/stub/generator.py b/python/tvm_ffi/stub/generator.py
index 1651dd97..99c3aa0d 100644
--- a/python/tvm_ffi/stub/generator.py
+++ b/python/tvm_ffi/stub/generator.py
@@ -66,6 +66,11 @@ class Generator(Protocol):
     #: Short identifier, e.g. ``"python"``.
     name: str
 
+    #: Source-file extensions this generator owns (lower-cased, with the dot).
+    #: Directory scans only visit these, so one tree can hold stub files for
+    #: several languages without one target rewriting another's files.
+    source_exts: frozenset[str]
+
     #: Comment-marker syntax for the files this generator emits.
     syntax: C.MarkerSyntax
 
@@ -182,6 +187,11 @@ _GENERATORS: dict[str, Generator] = {
 }
 
 
+def generator_names() -> list[str]:
+    """Return the registered target names, sorted (the ``--target`` 
choices)."""
+    return sorted(_GENERATORS)
+
+
 def get_generator(target: str) -> Generator:
     """Resolve a generator by target name."""
     if target not in _GENERATORS:
diff --git a/python/tvm_ffi/stub/python_generator/generator.py 
b/python/tvm_ffi/stub/python_generator/generator.py
index da1fc16f..d05b2f2a 100644
--- a/python/tvm_ffi/stub/python_generator/generator.py
+++ b/python/tvm_ffi/stub/python_generator/generator.py
@@ -43,6 +43,7 @@ class PythonGenerator:
 
     name = "python"
     syntax = C.PYTHON_SYNTAX
+    source_exts = frozenset({".py", ".pyi"})
 
     def default_ty_map(self) -> dict[str, str]:
         """Return the default FFI-origin -> Python-type name map."""
diff --git a/python/tvm_ffi/stub/utils.py b/python/tvm_ffi/stub/utils.py
index 07f40f8d..c16eb657 100644
--- a/python/tvm_ffi/stub/utils.py
+++ b/python/tvm_ffi/stub/utils.py
@@ -25,12 +25,15 @@ generator (e.g. 
:mod:`tvm_ffi.stub.python_generator.codegen`).
 from __future__ import annotations
 
 import dataclasses
-from typing import Any
+from typing import TYPE_CHECKING, Any
 
-from tvm_ffi.core import TypeInfo, TypeSchema, _lookup_type_attr
+from tvm_ffi.core import MISSING, TypeInfo, TypeSchema, _lookup_type_attr
 
 from . import consts as C
 
+if TYPE_CHECKING:
+    from tvm_ffi.core import TypeField
+
 
 def _parse_type_schema(raw: str | dict[str, Any]) -> TypeSchema:
     """Parse a type schema from either a JSON string or an already-parsed 
dict."""
@@ -86,14 +89,72 @@ class Options:
 
 @dataclasses.dataclass(init=False)
 class NamedTypeSchema(TypeSchema):
-    """A type schema with an associated name."""
+    """A type schema with an associated name.
+
+    For a reflected object field, the schema also carries the facts the C++
+    registry recorded about the native field, so a generator can reason about
+    memory layout and defaults without a second reflection pass:
+
+    - ``size`` / ``alignment`` / ``offset``: the byte facts of the native 
field.
+      ``offset`` is measured from the start of the ``TVMFFIObject`` header, the
+      same base the C ABI field getters use. All three are ``None`` for 
function
+      parameters and synthetic schemas.
+    - ``default``: the registered static default value (:data:`MISSING` when
+      none). ``default_is_factory`` marks a ``default_factory`` registration,
+      whose value only exists by calling the factory through the FFI.
+    - ``structural_eq``: the decoded structural-equality flag
+      (``"ignore"``, ``"def-recursive"``, ``"def-non-recursive"`` or ``None``).
+    - ``frozen``: ``True`` for read-only (``def_ro``) fields.
+    """
 
     name: str
-
-    def __init__(self, name: str, schema: TypeSchema) -> None:
-        """Initialize a `NamedTypeSchema` with the given name and schema."""
+    size: int | None = None
+    alignment: int | None = None
+    offset: int | None = None
+    default: Any = MISSING
+    default_is_factory: bool = False
+    structural_eq: str | None = None
+    frozen: bool = False
+
+    def __init__(
+        self,
+        name: str,
+        schema: TypeSchema,
+        *,
+        size: int | None = None,
+        alignment: int | None = None,
+        offset: int | None = None,
+        default: Any = MISSING,
+        default_is_factory: bool = False,
+        structural_eq: str | None = None,
+        frozen: bool = False,
+    ) -> None:
+        """Initialize a `NamedTypeSchema` with the given name, schema and 
field facts."""
         super().__init__(origin=schema.origin, args=schema.args)
         self.name = name
+        self.size = size
+        self.alignment = alignment
+        self.offset = offset
+        self.default = default
+        self.default_is_factory = default_is_factory
+        self.structural_eq = structural_eq
+        self.frozen = frozen
+
+    @staticmethod
+    def from_type_field(field: TypeField) -> NamedTypeSchema:
+        """Construct a `NamedTypeSchema` from a reflected 
:class:`~tvm_ffi.core.TypeField`."""
+        is_factory = field.c_default_factory is not MISSING
+        return NamedTypeSchema(
+            name=field.name,
+            schema=_parse_type_schema(field.metadata["type_schema"]),
+            size=field.size,
+            alignment=field.alignment,
+            offset=field.offset,
+            default=field.c_default_factory if is_factory else field.c_default,
+            default_is_factory=is_factory,
+            structural_eq=field.c_structural_eq,
+            frozen=field.frozen,
+        )
 
 
 @dataclasses.dataclass
@@ -121,7 +182,15 @@ class InitFieldInfo:
 
 @dataclasses.dataclass
 class ObjectInfo:
-    """Information of an object type, including its fields and methods."""
+    """Information of an object type, including its fields and methods.
+
+    ``fields`` lists only the fields declared by this type; inherited fields 
are
+    reached through ``parent_type_key`` / ``ancestors``. ``total_size`` is the
+    native ``sizeof`` of the object (header included) when the type registered
+    its own metadata, and ``None`` otherwise: a type without its own
+    ``ObjectDef`` inherits its parent's metadata entry, whose size says nothing
+    about the type itself.
+    """
 
     fields: list[NamedTypeSchema]
     methods: list[FuncInfo]
@@ -129,6 +198,10 @@ class ObjectInfo:
     parent_type_key: str | None = None
     init_fields: list[InitFieldInfo] = dataclasses.field(default_factory=list)
     has_init: bool = False
+    ancestors: list[str] = dataclasses.field(default_factory=list)
+    """Type keys of every ancestor, root first (``["ffi.Object", "ir.Expr"]`` 
for ``tirx.Add``)."""
+    total_size: int | None = None
+    """Native ``sizeof`` in bytes, or ``None`` when the type has no metadata 
of its own."""
 
     def has_overloaded_methods(self) -> bool:
         """Return whether reflection exposed multiple signatures for a 
method."""
@@ -143,47 +216,38 @@ class ObjectInfo:
     @staticmethod
     def from_type_info(type_info: TypeInfo) -> ObjectInfo:
         """Construct an `ObjectInfo` from a `TypeInfo` instance."""
-        parent_type_key: str | None = None
-        if type_info.parent_type_info is not None:
-            parent_type_key = type_info.parent_type_info.type_key
+        # Ancestor chain, root first (`ancestor_infos[-1]` is the direct 
parent).
+        ancestor_infos: list[TypeInfo] = []
+        ancestor_info: TypeInfo | None = type_info.parent_type_info
+        while ancestor_info is not None:
+            ancestor_infos.append(ancestor_info)
+            ancestor_info = ancestor_info.parent_type_info
+        ancestor_infos.reverse()
+        parent_type_key = ancestor_infos[-1].type_key if ancestor_infos else 
None
 
         # Detect __ffi_init__ from TypeMethod or TypeAttrColumn.
         has_init = any(m.name == "__ffi_init__" for m in type_info.methods)
         if not has_init:
             has_init = _lookup_type_attr(type_info.type_index, "__ffi_init__") 
is not None
 
-        # Walk parent chain (parent-first) to collect all init-eligible fields.
+        # Collect init-eligible fields from the whole chain, inherited fields 
first.
         init_fields: list[InitFieldInfo] = []
         if has_init:
-            ti: TypeInfo | None = type_info
-            chain: list[TypeInfo] = []
-            while ti is not None:
-                chain.append(ti)
-                ti = ti.parent_type_info
-            for ancestor_info in reversed(chain):
-                for field in ancestor_info.fields:
+            for declaring_info in [*ancestor_infos, type_info]:
+                for field in declaring_info.fields:
                     if not field.c_init:
                         continue
                     init_fields.append(
                         InitFieldInfo(
                             name=field.name,
-                            schema=NamedTypeSchema(
-                                name=field.name,
-                                
schema=_parse_type_schema(field.metadata["type_schema"]),
-                            ),
+                            schema=NamedTypeSchema.from_type_field(field),
                             kw_only=field.c_kw_only,
                             has_default=field.c_has_default,
                         )
                     )
 
         return ObjectInfo(
-            fields=[
-                NamedTypeSchema(
-                    name=field.name,
-                    schema=_parse_type_schema(field.metadata["type_schema"]),
-                )
-                for field in type_info.fields
-            ],
+            fields=[NamedTypeSchema.from_type_field(field) for field in 
type_info.fields],
             methods=[
                 FuncInfo(
                     schema=NamedTypeSchema(
@@ -198,4 +262,6 @@ class ObjectInfo:
             parent_type_key=parent_type_key,
             init_fields=init_fields,
             has_init=has_init,
+            ancestors=[info.type_key for info in ancestor_infos],
+            total_size=type_info.total_size if type_info._has_type_metadata 
else None,
         )
diff --git a/tests/python/test_stubgen.py b/tests/python/test_stubgen.py
index f94d2418..4fefdc24 100644
--- a/tests/python/test_stubgen.py
+++ b/tests/python/test_stubgen.py
@@ -23,12 +23,12 @@ from pathlib import Path
 import pytest
 import tvm_ffi.stub.cli as stub_cli
 from tvm_ffi import Object, method
-from tvm_ffi.core import TypeSchema
+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.file_utils import CodeBlock, FileInfo
-from tvm_ffi.stub.generator import get_generator
+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 consts as PC
 from tvm_ffi.stub.python_generator.codegen import (
     generate_python_all,
@@ -53,6 +53,12 @@ from tvm_ffi.stub.utils import (
     ObjectInfo,
     Options,
 )
+from tvm_ffi.testing import (
+    TestObjectBase,
+    _TestCxxClassBase,
+    _TestCxxClassDerived,
+    _TestCxxClassDerivedDerived,
+)
 
 _counter = itertools.count()
 
@@ -964,3 +970,124 @@ def test_stage_2_filters_prefix_and_marks_root(
     sub_text = sub_api.read_text(encoding="utf-8")
     assert 'LIB = _FFI_LOAD_LIB("demo-pkg", "demo_shared")' in root_text
     assert "LIB =" not in sub_text
+
+
+def test_objectinfo_from_type_info_layout_facts() -> None:
+    """Reflected size/alignment/offset/default facts reach ``ObjectInfo`` 
unchanged."""
+    base = ObjectInfo.from_type_info(_TestCxxClassBase.__tvm_ffi_type_info__)  
# ty: ignore[unresolved-attribute]
+    derived = 
ObjectInfo.from_type_info(_TestCxxClassDerived.__tvm_ffi_type_info__)  # ty: 
ignore[unresolved-attribute]
+    dd = 
ObjectInfo.from_type_info(_TestCxxClassDerivedDerived.__tvm_ffi_type_info__)  # 
ty: ignore[unresolved-attribute]
+
+    assert base.ancestors == ["ffi.Object"]
+    assert derived.ancestors == ["ffi.Object", "testing.TestCxxClassBase"]
+    assert dd.ancestors == [
+        "ffi.Object",
+        "testing.TestCxxClassBase",
+        "testing.TestCxxClassDerived",
+    ]
+    assert dd.parent_type_key == dd.ancestors[-1]
+
+    header = 24  # sizeof(TVMFFIObject): the fields of a root type start right 
after it
+    v_i64, v_i32 = base.fields
+    assert (v_i64.size, v_i64.alignment, v_i64.offset) == (8, 8, header)
+    assert (v_i32.size, v_i32.alignment, v_i32.offset) == (4, 4, header + 8)
+    assert base.total_size == 40  # 36 bytes of data, padded to the 8-byte 
alignment
+    v_f64, v_f32 = derived.fields
+    assert v_f64.offset == base.total_size  # a derived type's fields follow 
the parent's size
+    assert (v_f32.size, v_f32.alignment, v_f32.offset) == (4, 4, 48)
+    assert derived.total_size == 56
+    v_str, v_bool = dd.fields
+    assert (v_str.size, v_str.alignment, v_str.offset) == (16, 8, 
derived.total_size)
+    assert (v_bool.size, v_bool.alignment, v_bool.offset) == (1, 1, 72)
+    assert dd.total_size == 80
+
+    assert v_f64.default is MISSING
+    assert not v_f64.default_is_factory
+    assert v_f32.default == 8.0
+    assert v_str.default == "default"
+    assert all(f.structural_eq is None for f in (*base.fields, 
*derived.fields, *dd.fields))
+    assert not v_i64.frozen
+
+    # `def_ro` fields are frozen; `def_rw` fields with `default_value` keep 
their default.
+    tob_type_info = TestObjectBase.__tvm_ffi_type_info__  # ty: 
ignore[unresolved-attribute]
+    tob = {f.name: f for f in ObjectInfo.from_type_info(tob_type_info).fields}
+    assert tob["v_f64"].frozen
+    assert not tob["v_i64"].frozen
+    assert tob["v_i64"].default == 10
+
+    # The auto-init parameter list carries the same facts, parent fields first.
+    assert [f.schema.offset for f in dd.init_fields] == [24, 32, 40, 48, 56, 
72]
+
+
+def test_objectinfo_total_size_requires_own_metadata() -> None:
+    """A type without its own metadata inherits a meaningless size, so it 
reports ``None``."""
+    root = 
ObjectInfo.from_type_info(_lookup_or_register_type_info_from_type_key("ffi.Object"))
+    assert root.total_size == 24
+    assert root.ancestors == []
+    assert root.parent_type_key is None
+    func = 
ObjectInfo.from_type_info(_lookup_or_register_type_info_from_type_key("ffi.Function"))
+    assert func.total_size is None
+    assert func.ancestors == ["ffi.Object"]
+
+
+def test_named_type_schema_keeps_positional_signature() -> None:
+    """Synthetic schemas (function params, tests) carry no layout facts."""
+    schema = NamedTypeSchema("x", TypeSchema("int"))
+    assert (schema.size, schema.alignment, schema.offset) == (None, None, None)
+    assert schema.default is MISSING
+    assert not schema.default_is_factory
+    assert schema.structural_eq is None
+    assert not schema.frozen
+
+
+def test_rust_marker_syntax_parses_rs_file(tmp_path: Path) -> None:
+    """``//`` markers in a ``.rs`` file parse into the same block kinds as 
``#`` markers."""
+    rs = tmp_path / "mod.rs"
+    rs.write_text(
+        "\n".join(
+            [
+                f"{C.RUST_SYNTAX.begin} object/demo.Foo",
+                "pub struct Foo;",
+                C.RUST_SYNTAX.end,
+                f"{C.RUST_SYNTAX.ty_map} a -> b",
+                "",
+            ]
+        ),
+        encoding="utf-8",
+    )
+    assert syntax_for(rs) is C.RUST_SYNTAX
+    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 info.code_blocks[0].param == "demo.Foo"
+    assert info.code_blocks[0].lines[1] == "pub struct Foo;"
+
+    # Python markers are plain text inside a Rust file.
+    py_markers = tmp_path / "other.rs"
+    py_markers.write_text(
+        f"{C.PYTHON_SYNTAX.begin} object/demo.Foo\n{C.PYTHON_SYNTAX.end}\n", 
encoding="utf-8"
+    )
+    assert FileInfo.from_file(py_markers) is None
+
+
+def test_collect_files_filters_by_generator_exts(tmp_path: Path) -> None:
+    """Directory scans visit only the active generator's extensions; explicit 
files always."""
+    py = tmp_path / "a.py"
+    py.write_text(f"{C.PYTHON_SYNTAX.begin} 
import-section\n{C.PYTHON_SYNTAX.end}\n")
+    rs = tmp_path / "b.rs"
+    rs.write_text(f"{C.RUST_SYNTAX.begin} 
import-section\n{C.RUST_SYNTAX.end}\n")
+
+    python = get_generator("python")
+    assert python.source_exts == frozenset({".py", ".pyi"})
+    assert [f.path for f in collect_files([tmp_path], python.source_exts)] == 
[py.resolve()]
+    assert [f.path for f in collect_files([rs], python.source_exts)] == 
[rs.resolve()]
+    both = collect_files([tmp_path], {".py", ".RS"})
+    assert {f.path for f in both} == {py.resolve(), rs.resolve()}
+
+
+def test_generator_registry_names() -> None:
+    """``--target`` choices follow the registered generators."""
+    assert generator_names() == ["python"]
+    with pytest.raises(ValueError, match="Known generators: python"):
+        get_generator("rust")

Reply via email to