Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
42 changes: 14 additions & 28 deletions packages/griffelib/src/griffe/_internal/expressions.py
Original file line number Diff line number Diff line change
Expand Up @@ -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),
Expand Down Expand Up @@ -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),
Expand All @@ -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)
Expand All @@ -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:
Expand Down
71 changes: 71 additions & 0 deletions packages/griffelib/tests/test_expressions.py
Original file line number Diff line number Diff line change
Expand Up @@ -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',)"
Expand Down
Loading