- Fix two crashes in preview style involving edge cases with docstrings (#3451)
- Exclude string type annotations from improved string processing; fix crash when the
return type annotation is stringified and spans across multiple lines (#3462)
+- Wrap multiple context managers in parentheses when targeting Python 3.9+ (#3489)
- Fix several crashes in preview style with walrus operators used in `with` statements
or tuples (#3473)
future_imports = get_future_imports(src_node)
versions = detect_target_versions(src_node, future_imports=future_imports)
+ context_manager_features = {
+ feature
+ for feature in {Feature.PARENTHESIZED_CONTEXT_MANAGERS}
+ if supports_feature(versions, feature)
+ }
normalize_fmt_off(src_node, preview=mode.preview)
- lines = LineGenerator(mode=mode)
+ lines = LineGenerator(mode=mode, features=context_manager_features)
elt = EmptyLineTracker(mode=mode)
split_line_features = {
feature
- relaxed decorator syntax;
- usage of __future__ flags (annotations);
- print / exec statements;
+ - parenthesized context managers;
+ - match statements;
+ - except* clause;
+ - variadic generics;
"""
features: Set[Feature] = set()
if future_imports:
):
features.add(Feature.ANN_ASSIGN_EXTENDED_RHS)
+ elif (
+ n.type == syms.with_stmt
+ and len(n.children) > 2
+ and n.children[1].type == syms.atom
+ ):
+ atom_children = n.children[1].children
+ if (
+ len(atom_children) == 3
+ and atom_children[0].type == token.LPAR
+ and atom_children[1].type == syms.testlist_gexp
+ and atom_children[2].type == token.RPAR
+ ):
+ features.add(Feature.PARENTHESIZED_CONTEXT_MANAGERS)
+
+ elif n.type == syms.match_stmt:
+ features.add(Feature.PATTERN_MATCHING)
+
elif (
n.type == syms.except_clause
and len(n.children) >= 2
in ways that will no longer stringify to valid Python code on the tree.
"""
- def __init__(self, mode: Mode) -> None:
+ def __init__(self, mode: Mode, features: Collection[Feature]) -> None:
self.mode = mode
+ self.features = features
self.current_line: Line
self.__post_init__()
`parens` holds a set of string leaf values immediately after which
invisible parens should be put.
"""
- normalize_invisible_parens(node, parens_after=parens, preview=self.mode.preview)
+ normalize_invisible_parens(
+ node, parens_after=parens, mode=self.mode, features=self.features
+ )
for child in node.children:
if is_name_token(child) and child.value in keywords:
yield from self.line()
def visit_match_case(self, node: Node) -> Iterator[Line]:
"""Visit either a match or case statement."""
- normalize_invisible_parens(node, parens_after=set(), preview=self.mode.preview)
+ normalize_invisible_parens(
+ node, parens_after=set(), mode=self.mode, features=self.features
+ )
yield from self.line()
for child in node.children:
def normalize_invisible_parens(
- node: Node, parens_after: Set[str], *, preview: bool
+ node: Node, parens_after: Set[str], *, mode: Mode, features: Collection[Feature]
) -> None:
"""Make existing optional parentheses invisible or create new ones.
Standardizes on visible parentheses for single-element tuples, and keeps
existing visible parentheses for other tuples and generator expressions.
"""
- for pc in list_comments(node.prefix, is_endmarker=False, preview=preview):
+ for pc in list_comments(node.prefix, is_endmarker=False, preview=mode.preview):
if pc.value in FMT_OFF:
# This `node` has a prefix with `# fmt: off`, don't mess with parens.
return
+
+ # The multiple context managers grammar has a different pattern, thus this is
+ # separate from the for-loop below. This possibly wraps them in invisible parens,
+ # and later will be removed in remove_with_parens when needed.
+ if node.type == syms.with_stmt:
+ _maybe_wrap_cms_in_parens(node, mode, features)
+
check_lpar = False
for index, child in enumerate(list(node.children)):
# Fixes a bug where invisible parens are not properly stripped from
# assignment statements that contain type annotations.
if isinstance(child, Node) and child.type == syms.annassign:
normalize_invisible_parens(
- child, parens_after=parens_after, preview=preview
+ child, parens_after=parens_after, mode=mode, features=features
)
# Add parentheses around long tuple unpacking in assignments.
if check_lpar:
if (
- preview
+ mode.preview
and child.type == syms.atom
and node.type == syms.for_stmt
and isinstance(child.prev_sibling, Leaf)
remove_brackets_around_comma=True,
):
wrap_in_parentheses(node, child, visible=False)
- elif preview and isinstance(child, Node) and node.type == syms.with_stmt:
+ elif (
+ mode.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(
elif is_one_tuple(child):
wrap_in_parentheses(node, child, visible=True)
elif node.type == syms.import_from:
- # "import from" nodes store parentheses directly as part of
- # the statement
- if is_lpar_token(child):
- assert is_rpar_token(node.children[-1])
- # make parentheses invisible
- child.value = ""
- node.children[-1].value = ""
- elif child.type != token.STAR:
- # insert invisible parentheses
- node.insert_child(index, Leaf(token.LPAR, ""))
- node.append_child(Leaf(token.RPAR, ""))
+ _normalize_import_from(node, child, index)
break
elif (
index == 1
elif not (isinstance(child, Leaf) and is_multiline_string(child)):
wrap_in_parentheses(node, child, visible=False)
- comma_check = child.type == token.COMMA if preview else False
+ comma_check = child.type == token.COMMA if mode.preview else False
check_lpar = isinstance(child, Leaf) and (
child.value in parens_after or comma_check
)
+def _normalize_import_from(parent: Node, child: LN, index: int) -> None:
+ # "import from" nodes store parentheses directly as part of
+ # the statement
+ if is_lpar_token(child):
+ assert is_rpar_token(parent.children[-1])
+ # make parentheses invisible
+ child.value = ""
+ parent.children[-1].value = ""
+ elif child.type != token.STAR:
+ # insert invisible parentheses
+ parent.insert_child(index, Leaf(token.LPAR, ""))
+ parent.append_child(Leaf(token.RPAR, ""))
+
+
def remove_await_parens(node: Node) -> None:
if node.children[0].type == token.AWAIT and len(node.children) > 1:
if (
remove_await_parens(bracket_contents)
+def _maybe_wrap_cms_in_parens(
+ node: Node, mode: Mode, features: Collection[Feature]
+) -> None:
+ """When enabled and safe, wrap the multiple context managers in invisible parens.
+
+ It is only safe when `features` contain Feature.PARENTHESIZED_CONTEXT_MANAGERS.
+ """
+ if (
+ Feature.PARENTHESIZED_CONTEXT_MANAGERS not in features
+ or Preview.wrap_multiple_context_managers_in_parens not in mode
+ or len(node.children) <= 2
+ # If it's an atom, it's already wrapped in parens.
+ or node.children[1].type == syms.atom
+ ):
+ return
+ colon_index: Optional[int] = None
+ for i in range(2, len(node.children)):
+ if node.children[i].type == token.COLON:
+ colon_index = i
+ break
+ if colon_index is not None:
+ lpar = Leaf(token.LPAR, "")
+ rpar = Leaf(token.RPAR, "")
+ context_managers = node.children[1:colon_index]
+ for child in context_managers:
+ child.remove()
+ # After wrapping, the with_stmt will look like this:
+ # with_stmt
+ # NAME 'with'
+ # atom
+ # LPAR ''
+ # testlist_gexp
+ # ... <-- context_managers
+ # /testlist_gexp
+ # RPAR ''
+ # /atom
+ # COLON ':'
+ new_child = Node(
+ syms.atom, [lpar, Node(syms.testlist_gexp, context_managers), rpar]
+ )
+ node.insert_child(1, new_child)
+
+
def remove_with_parens(node: Node, parent: Node) -> None:
"""Recursively hide optional parens in `with` statements."""
# Removing all unnecessary parentheses in with statements in one pass is a tad
EXCEPT_STAR = 14
VARIADIC_GENERICS = 15
DEBUG_F_STRINGS = 16
+ PARENTHESIZED_CONTEXT_MANAGERS = 17
FORCE_OPTIONAL_PARENTHESES = 50
# __future__ flags
Feature.POS_ONLY_ARGUMENTS,
Feature.UNPACKING_ON_FLOW,
Feature.ANN_ASSIGN_EXTENDED_RHS,
+ Feature.PARENTHESIZED_CONTEXT_MANAGERS,
},
TargetVersion.PY310: {
Feature.F_STRINGS,
Feature.POS_ONLY_ARGUMENTS,
Feature.UNPACKING_ON_FLOW,
Feature.ANN_ASSIGN_EXTENDED_RHS,
+ Feature.PARENTHESIZED_CONTEXT_MANAGERS,
Feature.PATTERN_MATCHING,
},
TargetVersion.PY311: {
Feature.POS_ONLY_ARGUMENTS,
Feature.UNPACKING_ON_FLOW,
Feature.ANN_ASSIGN_EXTENDED_RHS,
+ Feature.PARENTHESIZED_CONTEXT_MANAGERS,
Feature.PATTERN_MATCHING,
Feature.EXCEPT_STAR,
Feature.VARIADIC_GENERICS,
parenthesize_conditional_expressions = auto()
skip_magic_trailing_comma_in_subscript = auto()
wrap_long_dict_values_in_parens = auto()
+ wrap_multiple_context_managers_in_parens = auto()
class Deprecated(UserWarning):
--- /dev/null
+# This file uses pattern matching introduced in Python 3.10.
+
+
+match http_code:
+ case 404:
+ print("Not found")
+
+
+with \
+ make_context_manager1() as cm1, \
+ make_context_manager2() as cm2, \
+ make_context_manager3() as cm3, \
+ make_context_manager4() as cm4 \
+:
+ pass
+
+
+# output
+
+
+# This file uses pattern matching introduced in Python 3.10.
+
+
+match http_code:
+ case 404:
+ print("Not found")
+
+
+with (
+ make_context_manager1() as cm1,
+ make_context_manager2() as cm2,
+ make_context_manager3() as cm3,
+ make_context_manager4() as cm4,
+):
+ pass
--- /dev/null
+# This file uses except* clause in Python 3.11.
+
+
+try:
+ some_call()
+except* Error as e:
+ pass
+
+
+with \
+ make_context_manager1() as cm1, \
+ make_context_manager2() as cm2, \
+ make_context_manager3() as cm3, \
+ make_context_manager4() as cm4 \
+:
+ pass
+
+
+# output
+
+
+# This file uses except* clause in Python 3.11.
+
+
+try:
+ some_call()
+except* Error as e:
+ pass
+
+
+with (
+ make_context_manager1() as cm1,
+ make_context_manager2() as cm2,
+ make_context_manager3() as cm3,
+ make_context_manager4() as cm4,
+):
+ pass
--- /dev/null
+# This file doesn't use any Python 3.9+ only grammars.
+
+
+# Make sure parens around a single context manager don't get autodetected as
+# Python 3.9+.
+with (a):
+ pass
+
+
+with \
+ make_context_manager1() as cm1, \
+ make_context_manager2() as cm2, \
+ make_context_manager3() as cm3, \
+ make_context_manager4() as cm4 \
+:
+ pass
+
+
+# output
+# This file doesn't use any Python 3.9+ only grammars.
+
+
+# Make sure parens around a single context manager don't get autodetected as
+# Python 3.9+.
+with a:
+ pass
+
+
+with make_context_manager1() as cm1, make_context_manager2() as cm2, make_context_manager3() as cm3, make_context_manager4() as cm4:
+ pass
--- /dev/null
+# This file uses parenthesized context managers introduced in Python 3.9.
+
+
+with \
+ make_context_manager1() as cm1, \
+ make_context_manager2() as cm2, \
+ make_context_manager3() as cm3, \
+ make_context_manager4() as cm4 \
+:
+ pass
+
+
+with (
+ new_new_new1() as cm1,
+ new_new_new2()
+):
+ pass
+
+
+# output
+# This file uses parenthesized context managers introduced in Python 3.9.
+
+
+with (
+ make_context_manager1() as cm1,
+ make_context_manager2() as cm2,
+ make_context_manager3() as cm3,
+ make_context_manager4() as cm4,
+):
+ pass
+
+
+with new_new_new1() as cm1, new_new_new2():
+ pass
--- /dev/null
+with \
+ make_context_manager1() as cm1, \
+ make_context_manager2() as cm2, \
+ make_context_manager3() as cm3, \
+ make_context_manager4() as cm4 \
+:
+ pass
+
+
+with \
+ make_context_manager1() as cm1, \
+ make_context_manager2(), \
+ make_context_manager3() as cm3, \
+ make_context_manager4() \
+:
+ pass
+
+
+with \
+ new_new_new1() as cm1, \
+ new_new_new2() \
+:
+ pass
+
+
+# output
+
+
+with make_context_manager1() as cm1, make_context_manager2() as cm2, make_context_manager3() as cm3, make_context_manager4() as cm4:
+ pass
+
+
+with make_context_manager1() as cm1, make_context_manager2(), make_context_manager3() as cm3, make_context_manager4():
+ pass
+
+
+with new_new_new1() as cm1, new_new_new2():
+ pass
--- /dev/null
+with \
+ make_context_manager1() as cm1, \
+ make_context_manager2() as cm2, \
+ make_context_manager3() as cm3, \
+ make_context_manager4() as cm4 \
+:
+ pass
+
+
+# Leading comment
+with \
+ make_context_manager1() as cm1, \
+ make_context_manager2(), \
+ make_context_manager3() as cm3, \
+ make_context_manager4() \
+:
+ pass
+
+
+with \
+ new_new_new1() as cm1, \
+ new_new_new2() \
+:
+ pass
+
+
+with (
+ new_new_new1() as cm1,
+ new_new_new2()
+):
+ pass
+
+
+# Leading comment.
+with (
+ # First comment.
+ new_new_new1() as cm1,
+ # Second comment.
+ new_new_new2()
+ # Last comment.
+):
+ pass
+
+
+with \
+ this_is_a_very_long_call(looong_arg1=looong_value1, looong_arg2=looong_value2) as cm1, \
+ this_is_a_very_long_call(looong_arg1=looong_value1, looong_arg2=looong_value2, looong_arg3=looong_value3, looong_arg4=looong_value4) as cm2 \
+:
+ pass
+
+
+# output
+
+
+with (
+ make_context_manager1() as cm1,
+ make_context_manager2() as cm2,
+ make_context_manager3() as cm3,
+ make_context_manager4() as cm4,
+):
+ pass
+
+
+# Leading comment
+with (
+ make_context_manager1() as cm1,
+ make_context_manager2(),
+ make_context_manager3() as cm3,
+ make_context_manager4(),
+):
+ pass
+
+
+with new_new_new1() as cm1, new_new_new2():
+ pass
+
+
+with new_new_new1() as cm1, new_new_new2():
+ pass
+
+
+# Leading comment.
+with (
+ # First comment.
+ new_new_new1() as cm1,
+ # Second comment.
+ new_new_new2()
+ # Last comment.
+):
+ pass
+
+
+with (
+ this_is_a_very_long_call(
+ looong_arg1=looong_value1, looong_arg2=looong_value2
+ ) as cm1,
+ this_is_a_very_long_call(
+ looong_arg1=looong_value1,
+ looong_arg2=looong_value2,
+ looong_arg3=looong_value3,
+ looong_arg4=looong_value4,
+ ) as cm2,
+):
+ pass
+import re
from dataclasses import replace
from typing import Any, Iterator
from unittest.mock import patch
assert_format(source, expected, mode, minimum_version=(3, 10))
+def test_preview_context_managers_targeting_py38() -> None:
+ source, expected = read_data("preview_context_managers", "targeting_py38.py")
+ mode = black.Mode(preview=True, target_versions={black.TargetVersion.PY38})
+ assert_format(source, expected, mode, minimum_version=(3, 8))
+
+
+def test_preview_context_managers_targeting_py39() -> None:
+ source, expected = read_data("preview_context_managers", "targeting_py39.py")
+ mode = black.Mode(preview=True, target_versions={black.TargetVersion.PY39})
+ assert_format(source, expected, mode, minimum_version=(3, 9))
+
+
+@pytest.mark.parametrize(
+ "filename", all_data_cases("preview_context_managers/auto_detect")
+)
+def test_preview_context_managers_auto_detect(filename: str) -> None:
+ match = re.match(r"features_3_(\d+)", filename)
+ assert match is not None, "Unexpected filename format: %s" % filename
+ source, expected = read_data("preview_context_managers/auto_detect", filename)
+ mode = black.Mode(preview=True)
+ assert_format(source, expected, mode, minimum_version=(3, int(match.group(1))))
+
+
# =============== #
# Complex cases
# ============= #