diff --git a/packages/griffelib/src/griffe/_internal/expressions.py b/packages/griffelib/src/griffe/_internal/expressions.py index f7696126..73082b2f 100644 --- a/packages/griffelib/src/griffe/_internal/expressions.py +++ b/packages/griffelib/src/griffe/_internal/expressions.py @@ -1007,6 +1007,12 @@ class ExprTuple(Expr): """Whether the tuple is implicit (e.g. without parentheses in a subscript's slice).""" def iterate(self, *, flat: bool = True) -> Iterator[str | Expr]: + # We fixed `_build_tuple` to make sure we never build + # implicit tuples with no elements, but since users might load + # data built with previous Griffe versions, we must be defensive here. + if not self.elements: + yield "()" + return if not self.implicit: yield "(" yield from _join(self.elements, ", ", flat=flat) @@ -1401,7 +1407,12 @@ def _build_tuple( compr_target: bool = False, **kwargs: Any, ) -> Expr: - return ExprTuple([_build(el, parent, **kwargs) for el in node.elts], implicit=subscript_slice or compr_target) + # An empty tuple is always written as `()` and cannot be implicit. + # This arises in annotations like `tuple[()]`, where the AST represents + # the subscript slice as an empty Tuple node, but the parentheses must + # be preserved to produce valid Python (`tuple[]` is a SyntaxError). + implicit = (subscript_slice or compr_target) if node.elts else False + return ExprTuple([_build(el, parent, **kwargs) for el in node.elts], implicit=implicit) def _build_unaryop(node: ast.UnaryOp, parent: Module | Class, **kwargs: Any) -> Expr: diff --git a/packages/griffelib/tests/test_expressions.py b/packages/griffelib/tests/test_expressions.py index 5edad128..c18cf52b 100644 --- a/packages/griffelib/tests/test_expressions.py +++ b/packages/griffelib/tests/test_expressions.py @@ -313,6 +313,27 @@ def test_render_dict_with_unpacking() -> None: assert str(module["c"].value) == "{None: 1, 'y': 2}" +def test_empty_tuple_annotation_str() -> None: + """Check that empty-tuple annotations round-trip correctly. + + `tuple[()]` is a valid annotation for a zero-element tuple. + Its string representation must remain `tuple[()]`, not the + invalid `tuple[]`. + """ + with temporary_visited_module( + """ + from typing import Tuple + + def f1() -> tuple[()]: ... + def f2() -> Tuple[()]: ... + """, + ) as module: + assert not module["f1"].returns.slice.implicit + assert not module["f2"].returns.slice.implicit + assert str(module["f1"].returns) == "tuple[()]" + assert str(module["f2"].returns) == "Tuple[()]" + + @pytest.mark.skipif(sys.version_info < (3, 15), reason="Unpackings in Comprehensions require Python 3.15+") def test_render_dict_comprehension_with_unpacking() -> None: """Docstring stub."""