From d55cade6bd6302da22c0747371157839a43c4231 Mon Sep 17 00:00:00 2001 From: Vincent Gao Date: Thu, 25 Jun 2026 10:58:29 +0200 Subject: [PATCH 1/2] fix: render empty-tuple subscript annotation as tuple[()] not tuple[] MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit An empty `ExprTuple` with `implicit=True` (produced when parsing a subscript like `tuple[()]`) previously yielded nothing in `iterate()`, resulting in the invalid string `tuple[]`. An empty tuple has no implicit form—it must always be written as `()`—so treat an empty implicit tuple the same as an explicit one. Before: `str(returns)` of `def f() -> tuple[()]` gave `"tuple[]"`, which is a `SyntaxError` when re-parsed. After: correctly yields `"tuple[()]"`. --- .../src/griffe/_internal/expressions.py | 8 ++++++++ packages/griffelib/tests/test_expressions.py | 19 +++++++++++++++++++ 2 files changed, 27 insertions(+) diff --git a/packages/griffelib/src/griffe/_internal/expressions.py b/packages/griffelib/src/griffe/_internal/expressions.py index 2916ba70..c5d33c66 100644 --- a/packages/griffelib/src/griffe/_internal/expressions.py +++ b/packages/griffelib/src/griffe/_internal/expressions.py @@ -1003,6 +1003,14 @@ 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]: + if self.implicit and not self.elements: + # 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). + yield "(" + yield ")" + return if not self.implicit: yield "(" yield from _join(self.elements, ", ", flat=flat) diff --git a/packages/griffelib/tests/test_expressions.py b/packages/griffelib/tests/test_expressions.py index dab89b81..4db0b3fa 100644 --- a/packages/griffelib/tests/test_expressions.py +++ b/packages/griffelib/tests/test_expressions.py @@ -311,3 +311,22 @@ def test_render_dict_with_unpacking() -> None: assert str(module["a"].value) == "{**base, 'x': 1}" assert str(module["b"].value) == "{**d1, **d2}" 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 str(module["f1"].returns) == "tuple[()]" + assert str(module["f2"].returns) == "Tuple[()]" From 51ecb80b6972390da95b313b60e5dd01d0839223 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Timoth=C3=A9e=20Mazzucotelli?= Date: Sun, 12 Jul 2026 11:34:05 +0200 Subject: [PATCH 2/2] fixup! fix: render empty-tuple subscript annotation as tuple[()] not tuple[] --- .../src/griffe/_internal/expressions.py | 19 +++++++++++-------- packages/griffelib/tests/test_expressions.py | 8 +++++--- 2 files changed, 16 insertions(+), 11 deletions(-) diff --git a/packages/griffelib/src/griffe/_internal/expressions.py b/packages/griffelib/src/griffe/_internal/expressions.py index c5d33c66..1c4ea1e1 100644 --- a/packages/griffelib/src/griffe/_internal/expressions.py +++ b/packages/griffelib/src/griffe/_internal/expressions.py @@ -1003,13 +1003,11 @@ 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]: - if self.implicit and not self.elements: - # 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). - yield "(" - yield ")" + # 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 "(" @@ -1405,7 +1403,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 4db0b3fa..04f4549b 100644 --- a/packages/griffelib/tests/test_expressions.py +++ b/packages/griffelib/tests/test_expressions.py @@ -316,9 +316,9 @@ def test_render_dict_with_unpacking() -> None: 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[]``. + `tuple[()]` is a valid annotation for a zero-element tuple. + Its string representation must remain `tuple[()]`, not the + invalid `tuple[]`. """ with temporary_visited_module( """ @@ -328,5 +328,7 @@ 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[()]"