Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We鈥檒l occasionally send you account related emails.

Already on GitHub? Sign in to your account

Remove unnecessary parentheses from with statements #2926

Merged
merged 24 commits into from Apr 3, 2022
Merged
Show file tree
Hide file tree
Changes from 16 commits
Commits
Show all changes
24 commits
Select commit Hold shift + click to select a range
1a499b2
Remove unnecessary parentheses from WITH statements
jpy-git Mar 15, 2022
1a2c13b
Add unit tests
jpy-git Mar 15, 2022
27a4bfd
Move functionality to preview mode
jpy-git Mar 15, 2022
ddc1a3f
Merge branch 'main' into with_parens
jpy-git Mar 16, 2022
240a6f8
Make logic more DRY
jpy-git Mar 16, 2022
cd0695d
Use or instead of and
jpy-git Mar 16, 2022
0e0d9f8
Make unit test py>=3.9
jpy-git Mar 17, 2022
3c14ab2
re-add preview style comment in CHANGES.md
jpy-git Mar 18, 2022
254fc91
Merge branch 'main' into with_parens
jpy-git Mar 21, 2022
d5db697
Update for latest master
jpy-git Mar 21, 2022
87aa74d
make redundant parens enum entry consistent
jpy-git Mar 21, 2022
aaa902a
add brackets for clarity
jpy-git Mar 23, 2022
9b62d0e
Merge branch 'main' into with_parens
JelleZijlstra Mar 24, 2022
68bb6d8
Merge branch 'main' into with_parens
JelleZijlstra Mar 24, 2022
3f8db3d
Fix condition after merge
jpy-git Mar 24, 2022
ac698f8
One-pass fix for removing brackets in for/with
jpy-git Mar 25, 2022
6532485
Merge branch 'main' into with_parens
JelleZijlstra Mar 26, 2022
e61783a
fix parent in as_expr logic
jpy-git Mar 26, 2022
639a2e4
Merge branch 'main' into with_parens
JelleZijlstra Mar 26, 2022
afcb8be
Merge branch 'main' into with_parens
JelleZijlstra Mar 30, 2022
1540bfa
Add deeply nested with bracket examples
jpy-git Apr 2, 2022
0f0a01a
Add deeply nested for bracket examples
jpy-git Apr 2, 2022
bd7373c
Merge branch 'main' into with_parens
jpy-git Apr 2, 2022
99167c2
Moar code comments 'cause ASTs are no joke
ichard26 Apr 2, 2022
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Jump to
Jump to file
Failed to load files.
Diff view
Diff view
1 change: 1 addition & 0 deletions CHANGES.md
Expand Up @@ -15,6 +15,7 @@
<!-- Changes that affect Black's preview style -->

- Code cell separators `#%%` are now standardised to `# %%` (#2919)
- Remove unnecessary parentheses from `with` statements (#2926)
- Remove unnecessary parentheses from tuple unpacking in `for` loops (#2945)
- Avoid magic-trailing-comma in single-element subscripts (#2942)

Expand Down
84 changes: 65 additions & 19 deletions src/black/linegen.py
Expand Up @@ -319,7 +319,10 @@ def __post_init__(self) -> None:
v, keywords={"try", "except", "else", "finally"}, parens=脴
)
self.visit_except_clause = partial(v, keywords={"except"}, parens=脴)
self.visit_with_stmt = partial(v, keywords={"with"}, parens=脴)
if self.mode.preview:
self.visit_with_stmt = partial(v, keywords={"with"}, parens={"with"})
else:
self.visit_with_stmt = partial(v, keywords={"with"}, parens=脴)
self.visit_funcdef = partial(v, keywords={"def"}, parens=脴)
self.visit_classdef = partial(v, keywords={"class"}, parens=脴)
self.visit_expr_stmt = partial(v, keywords=脴, parens=ASSIGNMENTS)
Expand Down Expand Up @@ -840,11 +843,26 @@ def normalize_invisible_parens(
check_lpar = True

if check_lpar:
if child.type == syms.atom:
if (
preview
and child.type == syms.atom
and node.type == syms.for_stmt
and isinstance(child.prev_sibling, Leaf)
and child.prev_sibling.type == token.NAME
and child.prev_sibling.value == "for"
):
if maybe_make_parens_invisible_in_atom(
child,
parent=node,
remove_brackets_around_comma=True,
):
wrap_in_parentheses(node, child, visible=False)
Comment on lines +849 to +862
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

for fix is now very simple. Detect the parent is a for loop and then remove the brackets using the option to remove around commas as well.

elif preview and isinstance(child, Node) and node.type == syms.with_stmt:
remove_with_parens(child, node)
elif child.type == syms.atom:
if maybe_make_parens_invisible_in_atom(
child,
parent=node,
preview=preview,
):
wrap_in_parentheses(node, child, visible=False)
elif is_one_tuple(child):
Expand All @@ -866,38 +884,62 @@ def normalize_invisible_parens(
elif not (isinstance(child, Leaf) and is_multiline_string(child)):
wrap_in_parentheses(node, child, visible=False)

check_lpar = isinstance(child, Leaf) and child.value in parens_after
comma_check = child.type == token.COMMA if preview else False

check_lpar = isinstance(child, Leaf) and (
child.value in parens_after or comma_check
)


def remove_with_parens(node: Node, parent: Node) -> None:
"""Recursively hide optional parens in `with` statements."""
if node.type == syms.atom:
if maybe_make_parens_invisible_in_atom(
node,
parent=parent,
remove_brackets_around_comma=True,
):
wrap_in_parentheses(parent, node, visible=False)
if isinstance(node.children[1], Node):
remove_with_parens(node.children[1], node)
elif node.type == syms.testlist_gexp:
for child in node.children:
if isinstance(child, Node):
remove_with_parens(child, node)
elif node.type == syms.asexpr_test and not any(
leaf.type == token.COLONEQUAL for leaf in node.leaves()
):
if maybe_make_parens_invisible_in_atom(
node.children[0],
parent=parent,
remove_brackets_around_comma=True,
):
wrap_in_parentheses(parent, node.children[0], visible=False)
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

with is slightly more complicated to achieve in one pass so I've split this into it's own function.

Basically the different variations of bracketed with statements give pretty different parse trees so we need to recursively solve this.

with (open("test.txt")) as f: ...: this is an asexpr_test
with (open("test.txt") as f): ...: this is an atom containing an asexpr_test
with (open("test.txt")) as f, (open("test.txt")) as f: ...: this is asexpr_test, COMMA, asexpr_test
with (open("test.txt") as f, open("test.txt")) as f: ...: this is an atom containing a testlist_gexp which then contains multiple asexpr_tests



def maybe_make_parens_invisible_in_atom(
node: LN,
parent: LN,
preview: bool = False,
remove_brackets_around_comma: bool = False,
) -> bool:
"""If it's safe, make the parens in the atom `node` invisible, recursively.
Additionally, remove repeated, adjacent invisible parens from the atom `node`
as they are redundant.

Returns whether the node should itself be wrapped in invisible parentheses.

"""
if (
preview
and parent.type == syms.for_stmt
and isinstance(node.prev_sibling, Leaf)
and node.prev_sibling.type == token.NAME
and node.prev_sibling.value == "for"
):
for_stmt_check = False
else:
for_stmt_check = True

if (
node.type != syms.atom
or is_empty_tuple(node)
or is_one_tuple(node)
or (is_yield(node) and parent.type != syms.expr_stmt)
or (max_delimiter_priority_in_atom(node) >= COMMA_PRIORITY and for_stmt_check)
or (
# This condition tries to prevent removing non-optional brackets
# around a tuple, however, can be a bit overzealous so we provide
# and option to skip this check for `for` and `with` statements.
not remove_brackets_around_comma
and max_delimiter_priority_in_atom(node) >= COMMA_PRIORITY
)
Comment on lines +955 to +961
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

So the reason I had the for_stmt_check/with_stmt_check initially was to disable this check for the reason in the above comment (basically it's hard to cleanly detect a tuple).

The issue was that nested brackets are nested atoms so when we recursed into the second level of this function the parent would no longer be for_stmt or with_stmt so my check would fail and require a second run. I've added an argument to disable this check and then we can just use this when we're removing brackets from for/with in the main loop.

):
return False

Expand All @@ -920,7 +962,11 @@ def maybe_make_parens_invisible_in_atom(
# make parentheses invisible
first.value = ""
last.value = ""
maybe_make_parens_invisible_in_atom(middle, parent=parent, preview=preview)
maybe_make_parens_invisible_in_atom(
middle,
parent=parent,
remove_brackets_around_comma=remove_brackets_around_comma,
)

if is_atom_with_invisible_parens(middle):
# Strip the invisible parens from `middle` by replacing
Expand Down
79 changes: 79 additions & 0 deletions tests/data/remove_with_brackets.py
@@ -0,0 +1,79 @@
with (open("bla.txt")):
JelleZijlstra marked this conversation as resolved.
Show resolved Hide resolved
pass

with (open("bla.txt")), (open("bla.txt")):
pass

with (open("bla.txt") as f):
pass

# Remove brackets within alias expression
with (open("bla.txt")) as f:
pass

# Remove brackets around one-line context managers
with (open("bla.txt") as f, (open("x"))):
pass

with ((open("bla.txt")) as f, open("x")):
pass

with (CtxManager1() as example1, CtxManager2() as example2):
...

# Brackets remain when using magic comma
with (CtxManager1() as example1, CtxManager2() as example2,):
...

# Brackets remain for multi-line context managers
with (CtxManager1() as example1, CtxManager2() as example2, CtxManager2() as example2, CtxManager2() as example2, CtxManager2() as example2):
...

# Don't touch assignment expressions
with (y := open("./test.py")) as f:
pass

# output
with open("bla.txt"):
pass

with open("bla.txt"), open("bla.txt"):
pass

with open("bla.txt") as f:
pass

# Remove brackets within alias expression
with open("bla.txt") as f:
pass

# Remove brackets around one-line context managers
with open("bla.txt") as f, open("x"):
pass

with open("bla.txt") as f, open("x"):
pass

with CtxManager1() as example1, CtxManager2() as example2:
...

# Brackets remain when using magic comma
with (
CtxManager1() as example1,
CtxManager2() as example2,
):
...

# Brackets remain for multi-line context managers
with (
CtxManager1() as example1,
CtxManager2() as example2,
CtxManager2() as example2,
CtxManager2() as example2,
CtxManager2() as example2,
):
...

# Don't touch assignment expressions
with (y := open("./test.py")) as f:
pass
10 changes: 10 additions & 0 deletions tests/test_format.py
Expand Up @@ -191,6 +191,16 @@ def test_pep_570() -> None:
assert_format(source, expected, minimum_version=(3, 8))


def test_remove_with_brackets() -> None:
source, expected = read_data("remove_with_brackets")
assert_format(
source,
expected,
black.Mode(preview=True),
minimum_version=(3, 9),
)


@pytest.mark.parametrize("filename", PY310_CASES)
def test_python_310(filename: str) -> None:
source, expected = read_data(filename)
Expand Down