diff --git a/packages/griffelib/src/griffe/_internal/expressions.py b/packages/griffelib/src/griffe/_internal/expressions.py index 3cf4fc06..dc7dd664 100644 --- a/packages/griffelib/src/griffe/_internal/expressions.py +++ b/packages/griffelib/src/griffe/_internal/expressions.py @@ -1315,9 +1315,17 @@ def _build_compare(node: ast.Compare, parent: Module | Class, **kwargs: Any) -> ) +def _build_implicit_tuple(node: ast.AST, parent: Module | Class, **kwargs: Any) -> str | Expr: + expression = _build(node, parent, **kwargs) + # Empty tuples cannot be implicit: omitting their parentheses produces invalid Python. + if isinstance(expression, ExprTuple) and expression.elements: + expression.implicit = True + return expression + + def _build_comprehension(node: ast.comprehension, parent: Module | Class, **kwargs: Any) -> Expr: return ExprComprehension( - _build(node.target, parent, compr_target=True, **kwargs), + _build_implicit_tuple(node.target, parent, **kwargs), _build(node.iter, parent, **kwargs), [_build(condition, parent, **kwargs) for condition in node.ifs], is_async=bool(node.is_async), @@ -1483,15 +1491,7 @@ def _build_setcomp(node: ast.SetComp, parent: Module | Class, **kwargs: Any) -> return ExprSetComp(_build(node.elt, parent, **kwargs), [_build(gen, parent, **kwargs) for gen in node.generators]) -def _build_slice( - node: ast.Slice, - parent: Module | Class, - *, - subscript_slice: bool = False, # noqa: ARG001 - **kwargs: Any, -) -> Expr: - # Note: `subscript_slice` is intentionally consumed here so that it doesn't propagate - # to the slice bounds, where tuples require their parentheses, e.g. `o[(1, 2):]`. +def _build_slice(node: ast.Slice, parent: Module | Class, **kwargs: Any) -> Expr: return ExprSlice( None if node.lower is None else _build(node.lower, parent, **kwargs), None if node.upper is None else _build(node.upper, parent, **kwargs), @@ -1509,7 +1509,6 @@ def _build_subscript( *, parse_strings: bool = False, literal_strings: bool = False, - subscript_slice: bool = False, # noqa: ARG001 **kwargs: Any, ) -> Expr: left = _build(node.value, parent, **kwargs) @@ -1519,33 +1518,20 @@ def _build_subscript( "typing_extensions.Literal", }: literal_strings = True - slice_expr = _build( + slice_expr = _build_implicit_tuple( node.slice, parent, parse_strings=True, literal_strings=literal_strings, - subscript_slice=True, **kwargs, ) else: - slice_expr = _build(node.slice, parent, subscript_slice=True, **kwargs) + slice_expr = _build_implicit_tuple(node.slice, parent, **kwargs) return ExprSubscript(left, slice_expr) -def _build_tuple( - node: ast.Tuple, - parent: Module | Class, - *, - subscript_slice: bool = False, - compr_target: bool = False, - **kwargs: Any, -) -> Expr: - # 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_tuple(node: ast.Tuple, parent: Module | Class, **kwargs: Any) -> Expr: + return ExprTuple([_build(el, parent, **kwargs) for el in node.elts]) 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 426b4d7f..bed3941c 100644 --- a/packages/griffelib/tests/test_expressions.py +++ b/packages/griffelib/tests/test_expressions.py @@ -218,6 +218,77 @@ def test_nested_container_format_spec_is_roundtrip_safe() -> None: assert ast.dump(reparsed) == ast.dump(original), f"{code!r} rendered as {rendered!r}" +@pytest.mark.parametrize( + "code", + [ + "o[[(x, y)]]", + "o[f((x, y))]", + "o[[(x, y) for x in xs for y in ys]]", + "[(x, y) for [x, (y, z)] in values]", + ], +) +def test_nested_tuples_keep_parentheses_in_implicit_contexts(code: str) -> None: + """Implicit-tuple contexts must only affect the immediate tuple node.""" + original = ast.parse(code, mode="eval") + expression = get_expression(original.body, parent=Module("module"), parse_strings=False) + rendered = str(expression) + reparsed = ast.parse(rendered, mode="eval") + assert ast.dump(reparsed) == ast.dump(original), f"{code!r} rendered as {rendered!r}" + + +@pytest.mark.parametrize( + ("code", "expected"), + [ + ("o[{(x, y)}]", "o[{(x, y)}]"), + ("o[{(x, y): value}]", "o[{(x, y): value}]"), + ("o[{(x, y): value for value in values}]", "o[{(x, y): value for value in values}]"), + ("o[{(x, y) for value in values}]", "o[{(x, y) for value in values}]"), + ("o[p[(x, y)]]", "o[p[x, y]]"), + ("o[p[(x, y):]]", "o[p[(x, y):]]"), + ("o[f(arg=(x, y))]", "o[f(arg=(x, y))]"), + ("o[((x, y)).attribute]", "o[(x, y).attribute]"), + ("o[(x, y) + z]", "o[(x, y) + z]"), + ("o[not (x, y)]", "o[not (x, y)]"), + ("o[(x, y) and z]", "o[(x, y) and z]"), + ("o[(x, y) == z]", "o[(x, y) == z]"), + ("o[(x, y) if condition else z]", "o[(x, y) if condition else z]"), + ("o[lambda: (x, y)]", "o[lambda: (x, y)]"), + ("o[(item := (x, y))]", "o[item := (x, y)]"), + ("o[f'{(x, y)}']", "o[f'{(x, y)}']"), + ("o[((x, y) for value in values)]", "o[((x, y) for value in values)]"), + ("o[await (x, y)]", "o[await (x, y)]"), + ("[(x, y) for (x, (y, z)) in values]", "[(x, y) for x, (y, z) in values]"), + ], +) +def test_only_root_tuples_are_implicit_in_tuple_contexts(code: str, expected: str) -> None: + """Only a tuple at the root of a subscript slice or comprehension target is implicit.""" + original = ast.parse(code, mode="eval") + expression = get_expression(original.body, parent=Module("module"), parse_strings=False) + rendered = str(expression) + assert rendered == expected + assert ast.dump(ast.parse(rendered, mode="eval")) == ast.dump(original) + + +@pytest.mark.parametrize( + ("code", "expected"), + [ + ('o["x, y"]', "o[x, y]"), + ('o["x,"]', "o[x,]"), + ('o["()"]', "o[()]"), + ('o[["x, y"]]', "o[[(x, y)]]"), + ('o[f("x, y")]', "o[f((x, y))]"), + ('o[p["x, y"]]', "o[p[x, y]]"), + ('o[p[["x, y"]]]', "o[p[[(x, y)]]]"), + ('typing.Literal["x, y"]', "typing.Literal['x, y']"), + ], +) +def test_parsed_string_tuples_respect_tuple_contexts(code: str, expected: str) -> None: + """Parsed strings inherit only their immediate tuple context.""" + node = ast.parse(code, mode="eval") + expression = get_expression(node.body, parent=Module("module"), parse_strings=True) + assert str(expression) == expected + + def test_length_one_tuple_as_string() -> None: """Length-1 tuples must have a trailing comma.""" code = "x = ('a',)"