Repository: psf/black

Issue:
Avoid magic-trailing-comma newline in `tuple[int,]`, like tuple literal `(1,)`
Black exempts single-element tuple literals from the usual handling of magic trailing commas: `(1,)` will *not* have a newline added (as would happen for lists, sets, etc.).  

I'd like to be able to write type annotations for a tuple-of-one-element in the same way, so that I can write either `tuple[int,]` or `tuple[int, ...]` without looking quite so much like `list[int]`.  However, Black's current add-a-newline code style makes this pattern unattractive, so I'm unlikely to write a linter for this pattern unless the code style changes.

**Examples in the current _Black_ style**

```python
shape: tuple[
    int,
] 
shape = (1,)
```

**Desired style**

```python
shape: tuple[int,]
shape = (1,)
```

Discussion:
github-actions[bot]: **diff-shades** results comparing this PR (a6b21b95eeb792f929c3bb63aca4bc5ebb9bc68f) to main (fa7f01592b02229ff47f3bcab39a9b7d6c59f07c). The full diff is [available in the logs](https://github.com/psf/black/runs/5604141256?check_suite_focus=true#step:11:1) under the "Generate HTML diff report" step.
```text
╭─────────────────────── Summary ────────────────────────╮
│ 3 projects & 3 files changed / 60 changes [+29/-31]    │
│                                                        │
│ ... out of 2 165 268 lines, 10 468 files & 23 projects │
╰────────────────────────────────────────────────────────╯

Differences found.
```
[**What is this?**](https://black.readthedocs.io/en/latest/contributing/gauging_changes.html#diff-shades) | [Workflow run](https://github.com/psf/black/actions/runs/2005461078) | [diff-shades documentation](https://github.com/ichard26/diff-shades#readme)
Zac-HD: Will it be possible to enable this without also turning on the experimental string processing?
JelleZijlstra: No, preview mode is currently planned to be a single flag for everything.

Patch:
diff --git a/CHANGES.md b/CHANGES.md
index d0faf7c..d753a24 100644
--- a/CHANGES.md
+++ b/CHANGES.md
@@ -15,6 +15,7 @@
 <!-- Changes that affect Black's preview style -->
 
 - Code cell separators `#%%` are now standardised to `# %%` (#2919)
+- Avoid magic-trailing-comma in single-element subscripts (#2942)
 
 ### _Blackd_
 
diff --git a/docs/guides/introducing_black_to_your_project.md b/docs/guides/introducing_black_to_your_project.md
index 71ccf7c..9ae40a1 100644
--- a/docs/guides/introducing_black_to_your_project.md
+++ b/docs/guides/introducing_black_to_your_project.md
@@ -43,8 +43,10 @@ call to `git blame`.
 $ git config blame.ignoreRevsFile .git-blame-ignore-revs
 ```
 
-**The one caveat is that GitHub and GitLab do not yet support ignoring revisions using
-their native UI of blame.** So blame information will be cluttered with a reformatting
-commit on those platforms. (If you'd like this feature, there's an open issue for
-[GitLab](https://gitlab.com/gitlab-org/gitlab/-/issues/31423) and please let GitHub
-know!)
+**The one caveat is that some online Git-repositories like GitLab do not yet support
+ignoring revisions using their native blame UI.** So blame information will be cluttered
+with a reformatting commit on those platforms. (If you'd like this feature, there's an
+open issue for [GitLab](https://gitlab.com/gitlab-org/gitlab/-/issues/31423)). This is
+however supported by
+[GitHub](https://docs.github.com/en/repositories/working-with-files/using-files/viewing-a-file#ignore-commits-in-the-blame-view),
+currently in beta.
diff --git a/src/black/linegen.py b/src/black/linegen.py
index 79475a8..5d92011 100644
--- a/src/black/linegen.py
+++ b/src/black/linegen.py
@@ -8,7 +8,12 @@
 from black.nodes import WHITESPACE, RARROW, STATEMENT, STANDALONE_COMMENT
 from black.nodes import ASSIGNMENTS, OPENING_BRACKETS, CLOSING_BRACKETS
 from black.nodes import Visitor, syms, is_arith_like, ensure_visible
-from black.nodes import is_docstring, is_empty_tuple, is_one_tuple, is_one_tuple_between
+from black.nodes import (
+    is_docstring,
+    is_empty_tuple,
+    is_one_tuple,
+    is_one_sequence_between,
+)
 from black.nodes import is_name_token, is_lpar_token, is_rpar_token
 from black.nodes import is_walrus_assignment, is_yield, is_vararg, is_multiline_string
 from black.nodes import is_stub_suite, is_stub_body, is_atom_with_invisible_parens
@@ -973,7 +978,7 @@ def generate_trailers_to_omit(line: Line, line_length: int) -> Iterator[Set[Leaf
                     prev
                     and prev.type == token.COMMA
                     and leaf.opening_bracket is not None
-                    and not is_one_tuple_between(
+                    and not is_one_sequence_between(
                         leaf.opening_bracket, leaf, line.leaves
                     )
                 ):
@@ -1001,7 +1006,7 @@ def generate_trailers_to_omit(line: Line, line_length: int) -> Iterator[Set[Leaf
                 prev
                 and prev.type == token.COMMA
                 and leaf.opening_bracket is not None
-                and not is_one_tuple_between(leaf.opening_bracket, leaf, line.leaves)
+                and not is_one_sequence_between(leaf.opening_bracket, leaf, line.leaves)
             ):
                 # Never omit bracket pairs with trailing commas.
                 # We need to explode on those.
diff --git a/src/black/lines.py b/src/black/lines.py
index f35665c..e455a50 100644
--- a/src/black/lines.py
+++ b/src/black/lines.py
@@ -17,12 +17,12 @@
 from blib2to3.pgen2 import token
 
 from black.brackets import BracketTracker, DOT_PRIORITY
-from black.mode import Mode
+from black.mode import Mode, Preview
 from black.nodes import STANDALONE_COMMENT, TEST_DESCENDANTS
 from black.nodes import BRACKETS, OPENING_BRACKETS, CLOSING_BRACKETS
 from black.nodes import syms, whitespace, replace_child, child_towards
 from black.nodes import is_multiline_string, is_import, is_type_comment
-from black.nodes import is_one_tuple_between
+from black.nodes import is_one_sequence_between
 
 # types
 T = TypeVar("T")
@@ -254,6 +254,7 @@ def has_magic_trailing_comma(
         """Return True if we have a magic trailing comma, that is when:
         - there's a trailing comma here
         - it's not a one-tuple
+        - it's not a single-element subscript
         Additionally, if ensure_removable:
         - it's not from square bracket indexing
         """
@@ -268,6 +269,20 @@ def has_magic_trailing_comma(
             return True
 
         if closing.type == token.RSQB:
+            if (
+                Preview.one_element_subscript in self.mode
+                and closing.parent
+                and closing.parent.type == syms.trailer
+                and closing.opening_bracket
+                and is_one_sequence_between(
+                    closing.opening_bracket,
+                    closing,
+                    self.leaves,
+                    brackets=(token.LSQB, token.RSQB),
+                )
+            ):
+                return False
+
             if not ensure_removable:
                 return True
             comma = self.leaves[-1]
@@ -276,7 +291,7 @@ def has_magic_trailing_comma(
         if self.is_import:
             return True
 
-        if closing.opening_bracket is not None and not is_one_tuple_between(
+        if closing.opening_bracket is not None and not is_one_sequence_between(
             closing.opening_bracket, closing, self.leaves
         ):
             return True
diff --git a/src/black/mode.py b/src/black/mode.py
index 35a072c..77b1cab 100644
--- a/src/black/mode.py
+++ b/src/black/mode.py
@@ -127,6 +127,7 @@ class Preview(Enum):
     """Individual preview style features."""
 
     string_processing = auto()
+    one_element_subscript = auto()
 
 
 class Deprecated(UserWarning):
@@ -162,9 +163,7 @@ def __contains__(self, feature: Preview) -> bool:
         """
         if feature is Preview.string_processing:
             return self.preview or self.experimental_string_processing
-        # TODO: Remove type ignore comment once preview contains more features
-        #  than just ESP
-        return self.preview  # type: ignore
+        return self.preview
 
     def get_cache_key(self) -> str:
         if self.target_versions:
diff --git a/src/black/nodes.py b/src/black/nodes.py
index f130bff..d18d4bd 100644
--- a/src/black/nodes.py
+++ b/src/black/nodes.py
@@ -9,6 +9,7 @@
     List,
     Optional,
     Set,
+    Tuple,
     TypeVar,
     Union,
 )
@@ -559,9 +560,14 @@ def is_one_tuple(node: LN) -> bool:
     )
 
 
-def is_one_tuple_between(opening: Leaf, closing: Leaf, leaves: List[Leaf]) -> bool:
-    """Return True if content between `opening` and `closing` looks like a one-tuple."""
-    if opening.type != token.LPAR and closing.type != token.RPAR:
+def is_one_sequence_between(
+    opening: Leaf,
+    closing: Leaf,
+    leaves: List[Leaf],
+    brackets: Tuple[int, int] = (token.LPAR, token.RPAR),
+) -> bool:
+    """Return True if content between `opening` and `closing` is a one-sequence."""
+    if (opening.type, closing.type) != brackets:
         return False
 
     depth = closing.bracket_depth + 1


Test patch:
diff --git a/tests/data/one_element_subscript.py b/tests/data/one_element_subscript.py
new file mode 100644
index 0000000..39205ba
--- /dev/null
+++ b/tests/data/one_element_subscript.py
@@ -0,0 +1,36 @@
+# We should not treat the trailing comma
+# in a single-element subscript.
+a: tuple[int,]
+b = tuple[int,]
+
+# The magic comma still applies to multi-element subscripts.
+c: tuple[int, int,]
+d = tuple[int, int,]
+
+# Magic commas still work as expected for non-subscripts.
+small_list = [1,]
+list_of_types = [tuple[int,],]
+
+# output
+# We should not treat the trailing comma
+# in a single-element subscript.
+a: tuple[int,]
+b = tuple[int,]
+
+# The magic comma still applies to multi-element subscripts.
+c: tuple[
+    int,
+    int,
+]
+d = tuple[
+    int,
+    int,
+]
+
+# Magic commas still work as expected for non-subscripts.
+small_list = [
+    1,
+]
+list_of_types = [
+    tuple[int,],
+]
diff --git a/tests/test_format.py b/tests/test_format.py
index 667d5c1..4de3170 100644
--- a/tests/test_format.py
+++ b/tests/test_format.py
@@ -80,6 +80,7 @@
     "long_strings__edge_case",
     "long_strings__regression",
     "percent_precedence",
+    "one_element_subscript",
 ]
 
 SOURCES: List[str] = [