]> git.madduck.net Git - etc/vim.git/commitdiff

madduck's git repository

Every one of the projects in this repository is available at the canonical URL git://git.madduck.net/madduck/pub/<projectpath> — see each project's metadata for the exact URL.

All patches and comments are welcome. Please squash your changes to logical commits before using git-format-patch and git-send-email to patches@git.madduck.net. If you'd read over the Git project's submission guidelines and adhered to them, I'd be especially grateful.

SSH access, as well as push access can be individually arranged.

If you use my repositories frequently, consider adding the following snippet to ~/.gitconfig and using the third clone URL listed for each project:

[url "git://git.madduck.net/madduck/"]
  insteadOf = madduck:

Wrap multiple context managers in parentheses when targeting Python 3.9+ (#3489)
authorYilei "Dolee" Yang <yileiyang@google.com>
Fri, 20 Jan 2023 12:14:05 +0000 (04:14 -0800)
committerGitHub <noreply@github.com>
Fri, 20 Jan 2023 12:14:05 +0000 (04:14 -0800)
CHANGES.md
src/black/__init__.py
src/black/linegen.py
src/black/mode.py
tests/data/preview_context_managers/auto_detect/features_3_10.py [new file with mode: 0644]
tests/data/preview_context_managers/auto_detect/features_3_11.py [new file with mode: 0644]
tests/data/preview_context_managers/auto_detect/features_3_8.py [new file with mode: 0644]
tests/data/preview_context_managers/auto_detect/features_3_9.py [new file with mode: 0644]
tests/data/preview_context_managers/targeting_py38.py [new file with mode: 0644]
tests/data/preview_context_managers/targeting_py39.py [new file with mode: 0644]
tests/test_format.py

index 313536e848036ce70a0120f44752377a48f08bb8..1450278341b8eac4d6380ac59f1f74d32ee0f413 100644 (file)
@@ -35,6 +35,7 @@
 - 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)
 
index 5d35c805bacf7d637f50a0de03a7d581375c02aa..daf6f88f58edac008203b5d28b8eb3034bae7100 100644 (file)
@@ -1096,8 +1096,13 @@ def _format_str_once(src_contents: str, *, mode: Mode) -> str:
         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
@@ -1159,6 +1164,10 @@ def get_features_used(  # noqa: C901
     - 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:
@@ -1234,6 +1243,23 @@ def get_features_used(  # noqa: C901
         ):
             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
index 14f851161fd60cbee9d7cf0cf058d03f469a5461..2f50257a9305b0203ee662e4d5d3187bbe20741d 100644 (file)
@@ -90,8 +90,9 @@ class LineGenerator(Visitor[Line]):
     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__()
 
@@ -191,7 +192,9 @@ class LineGenerator(Visitor[Line]):
         `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()
@@ -244,7 +247,9 @@ class LineGenerator(Visitor[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:
@@ -1090,7 +1095,7 @@ def normalize_prefix(leaf: Leaf, *, inside_brackets: bool) -> None:
 
 
 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.
 
@@ -1100,17 +1105,24 @@ def normalize_invisible_parens(
     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.
@@ -1123,7 +1135,7 @@ def normalize_invisible_parens(
 
         if check_lpar:
             if (
-                preview
+                mode.preview
                 and child.type == syms.atom
                 and node.type == syms.for_stmt
                 and isinstance(child.prev_sibling, Leaf)
@@ -1136,7 +1148,9 @@ def normalize_invisible_parens(
                     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(
@@ -1147,17 +1161,7 @@ def normalize_invisible_parens(
             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
@@ -1172,13 +1176,27 @@ def normalize_invisible_parens(
             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 (
@@ -1215,6 +1233,49 @@ def remove_await_parens(node: Node) -> None:
                 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
index 775805ae9600a30c0001ce1f67dff27a4b2ff122..af0706e6a0b2a425b1c312625b2657867eae4e46 100644 (file)
@@ -50,6 +50,7 @@ class Feature(Enum):
     EXCEPT_STAR = 14
     VARIADIC_GENERICS = 15
     DEBUG_F_STRINGS = 16
+    PARENTHESIZED_CONTEXT_MANAGERS = 17
     FORCE_OPTIONAL_PARENTHESES = 50
 
     # __future__ flags
@@ -106,6 +107,7 @@ VERSION_TO_FEATURES: Dict[TargetVersion, Set[Feature]] = {
         Feature.POS_ONLY_ARGUMENTS,
         Feature.UNPACKING_ON_FLOW,
         Feature.ANN_ASSIGN_EXTENDED_RHS,
+        Feature.PARENTHESIZED_CONTEXT_MANAGERS,
     },
     TargetVersion.PY310: {
         Feature.F_STRINGS,
@@ -120,6 +122,7 @@ VERSION_TO_FEATURES: Dict[TargetVersion, Set[Feature]] = {
         Feature.POS_ONLY_ARGUMENTS,
         Feature.UNPACKING_ON_FLOW,
         Feature.ANN_ASSIGN_EXTENDED_RHS,
+        Feature.PARENTHESIZED_CONTEXT_MANAGERS,
         Feature.PATTERN_MATCHING,
     },
     TargetVersion.PY311: {
@@ -135,6 +138,7 @@ VERSION_TO_FEATURES: Dict[TargetVersion, Set[Feature]] = {
         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,
@@ -164,6 +168,7 @@ class Preview(Enum):
     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):
diff --git a/tests/data/preview_context_managers/auto_detect/features_3_10.py b/tests/data/preview_context_managers/auto_detect/features_3_10.py
new file mode 100644 (file)
index 0000000..1458df1
--- /dev/null
@@ -0,0 +1,35 @@
+# 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
diff --git a/tests/data/preview_context_managers/auto_detect/features_3_11.py b/tests/data/preview_context_managers/auto_detect/features_3_11.py
new file mode 100644 (file)
index 0000000..f83c533
--- /dev/null
@@ -0,0 +1,37 @@
+# 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
diff --git a/tests/data/preview_context_managers/auto_detect/features_3_8.py b/tests/data/preview_context_managers/auto_detect/features_3_8.py
new file mode 100644 (file)
index 0000000..e05094e
--- /dev/null
@@ -0,0 +1,30 @@
+# 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
diff --git a/tests/data/preview_context_managers/auto_detect/features_3_9.py b/tests/data/preview_context_managers/auto_detect/features_3_9.py
new file mode 100644 (file)
index 0000000..0d28f99
--- /dev/null
@@ -0,0 +1,34 @@
+# 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
diff --git a/tests/data/preview_context_managers/targeting_py38.py b/tests/data/preview_context_managers/targeting_py38.py
new file mode 100644 (file)
index 0000000..6ec4684
--- /dev/null
@@ -0,0 +1,38 @@
+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
diff --git a/tests/data/preview_context_managers/targeting_py39.py b/tests/data/preview_context_managers/targeting_py39.py
new file mode 100644 (file)
index 0000000..5cb8763
--- /dev/null
@@ -0,0 +1,104 @@
+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
index 0816bbd36925d313f0ff37970f57b15fc09d47b8..adcbc02468d166f5803a694d78897f2f9ff1cc5f 100644 (file)
@@ -1,3 +1,4 @@
+import re
 from dataclasses import replace
 from typing import Any, Iterator
 from unittest.mock import patch
@@ -58,6 +59,29 @@ def test_preview_minimum_python_310_format(filename: str) -> None:
     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
 # ============= #