diff --git a/CHANGELOG.md b/CHANGELOG.md
index 814c3ca..86c3839 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -3,6 +3,8 @@ Changelog
## NEXT
***
+**⭐ New**
+* Exempt parentheses around multi-line strings in tuples and lists ([#34](https://github.com/robsdedude/flake8-picky-parentheses/pull/34)).
## 0.5.0
@@ -14,7 +16,7 @@ Changelog
## 0.4.0
***
**⭐ New**
-* Add support for Python 3.11 ([#28](https://github.com/robsdedude/flake8-picky-parentheses/pull/28))
+* Add support for Python 3.11 ([#28](https://github.com/robsdedude/flake8-picky-parentheses/pull/28)).
## 0.3.2
diff --git a/README.md b/README.md
index ffc9f40..5d30993 100644
--- a/README.md
+++ b/README.md
@@ -184,14 +184,15 @@ try to remove each pair of parentheses and see if the code still compiles and
yields the same AST (i.e., is semantically equivalent).
If it does, a flake (lint error) is reported. However, there are two notable
exceptions to this rule:
- 1. Parentheses for tuple literals
+ 1. Parentheses for tuple literals.
2. A single pair or parentheses in expressions to highlight operator
precedence.
Even if these parentheses are redundant, they help to divide parts of
expressions and show sequence of actions.
- 3. Parts of slices
+ 3. Parts of slices.
4. Multi-line[1)](#footnotes) `if` and `for` parts in comprehensions.
5. Multi-line[1)](#footnotes) keyword arguments or argument defaults.
+ 6. String concatenation over several lines in lists and tuples .
Exception type 1:
@@ -289,6 +290,37 @@ def foo(bar=(a is b)):
...
```
+Exception type 6:
+
+```python
+# GOOD
+[
+ "a",
+ (
+ "b"
+ "c"
+ ),
+ "d",
+]
+
+# This helps to avoid forgetting a comma at the end of a string spanning
+# multiple lines. Compare with:
+[
+ "a",
+ "b"
+ "c"
+ "d",
+]
+# Was the comma after "b" forgotten or was the string supposed to be "bc"?
+
+# BAD
+[
+ (
+ "a" "b"
+ ),
+]
+```
+
### Footnotes:
1. Multi-line means that either
* the expression spans multiple lines, e.g.,
diff --git a/src/flake8_picky_parentheses/_redundant_parentheses.py b/src/flake8_picky_parentheses/_redundant_parentheses.py
index a662549..9860da5 100644
--- a/src/flake8_picky_parentheses/_redundant_parentheses.py
+++ b/src/flake8_picky_parentheses/_redundant_parentheses.py
@@ -285,7 +285,9 @@ def _get_exceptions_from_ast(cls, sorted_parens_coords, tree, tokens):
rewrite_buffer = None
for _, parens_coord in enumerate(sorted_parens_coords):
node, pos, end, parents = nodes[nodes_idx]
- while not cls._node_in_parens(parens_coord, pos, end):
+ while not cls._node_in_parens(
+ parens_coord, node, pos, end, tokens
+ ):
nodes_idx += 1
if nodes_idx >= len(nodes):
return
@@ -302,21 +304,24 @@ def _get_exceptions_from_ast(cls, sorted_parens_coords, tree, tokens):
):
rewrite_buffer = ProblemRewrite(parens_coord.open_, None)
last_exception_node = node
- elif (
+ continue
+ if (
parents
and isinstance(parents[0], special_ops_pair_exceptions)
and isinstance(node, special_ops_pair_exceptions)
):
rewrite_buffer = ProblemRewrite(parens_coord.open_, None)
last_exception_node = node
- elif (
+ continue
+ if (
parents
and isinstance(parents[0], ast.Starred)
and isinstance(node, special_ops_pair_exceptions)
):
rewrite_buffer = ProblemRewrite(parens_coord.open_, None)
last_exception_node = node
- elif (
+ continue
+ if (
parents
and isinstance(parents[0], ast.keyword)
and parents[0].arg is None
@@ -324,18 +329,20 @@ def _get_exceptions_from_ast(cls, sorted_parens_coords, tree, tokens):
):
rewrite_buffer = ProblemRewrite(parens_coord.open_, None)
last_exception_node = node
- elif isinstance(node, ast.Tuple):
- if (
- parents
- and isinstance(parents[0], ast.Assign)
- and node in parents[0].targets
- ):
- rewrite_buffer = ProblemRewrite(
- parens_coord.open_,
- "PAR002: Dont use parentheses for unpacking"
- )
- last_exception_node = node
- elif (
+ continue
+ if (
+ isinstance(node, ast.Tuple)
+ and parents
+ and isinstance(parents[0], ast.Assign)
+ and node in parents[0].targets
+ ):
+ rewrite_buffer = ProblemRewrite(
+ parens_coord.open_,
+ "PAR002: Dont use parentheses for unpacking"
+ )
+ last_exception_node = node
+ continue
+ if (
parents
and isinstance(parents[0], ast.comprehension)
and (node in parents[0].ifs or node == parents[0].iter)
@@ -343,14 +350,16 @@ def _get_exceptions_from_ast(cls, sorted_parens_coords, tree, tokens):
):
rewrite_buffer = ProblemRewrite(parens_coord.open_, None)
last_exception_node = node
- elif (
+ continue
+ if (
parents
and isinstance(parents[0], ast.keyword)
and (parens_coord.open_[0] != pos[0] or pos[0] != end[0])
):
rewrite_buffer = ProblemRewrite(parens_coord.open_, None)
last_exception_node = node
- elif (
+ continue
+ if (
parents
and isinstance(parents[0], ast.arguments)
and node in parents[0].defaults
@@ -358,12 +367,29 @@ def _get_exceptions_from_ast(cls, sorted_parens_coords, tree, tokens):
):
rewrite_buffer = ProblemRewrite(parens_coord.open_, None)
last_exception_node = node
- elif (
+ continue
+ if (
sys.version_info >= (3, 10)
and isinstance(node, ast.MatchSequence)
):
rewrite_buffer = ProblemRewrite(parens_coord.open_, None)
last_exception_node = node
+ continue
+ if (
+ parents
+ and isinstance(parents[0], (ast.Tuple, ast.List))
+ and isinstance(node, ast.Str)
+ ):
+ tokens_slice = slice(parens_coord.token_indexes[0] + 1,
+ parens_coord.token_indexes[1])
+ string_tokens = [
+ token for token in tokens[tokens_slice]
+ if token.type == tokenize.STRING
+ ]
+ if string_tokens[0].start[0] != string_tokens[-1].start[0]:
+ rewrite_buffer = ProblemRewrite(parens_coord.open_, None)
+ last_exception_node = node
+ continue
if rewrite_buffer is not None:
yield rewrite_buffer
@@ -469,8 +495,13 @@ def _get_exceptions_for_neighboring_parens(sorted_optional_parens_coords,
yield ProblemRewrite(coords2.open_, None)
@staticmethod
- def _node_in_parens(parens_coord, pos, end):
+ def _node_in_parens(parens_coord, node, pos, end, tokens):
open_, _, _, close, _ = parens_coord
close = close[0], close[1] + 1
- # close[1] + 1 to allow the closing parenthesis to be part of the node
+ if (
+ isinstance(node, ast.Tuple)
+ and sys.version_info < (3, 8)
+ ):
+ # Python 3.7 does not include the redundant parentheses of tuples
+ return open_ < pos <= end < close
return open_ <= pos <= end <= close
diff --git a/tests/test_redundant_parentheses.py b/tests/test_redundant_parentheses.py
index b51495c..a7a36c6 100644
--- a/tests/test_redundant_parentheses.py
+++ b/tests/test_redundant_parentheses.py
@@ -349,6 +349,7 @@ def test_unpacking(plugin, ws1, ws2, ws3, ws4):
assert lint_codes(plugin(s), ["PAR002"])
+# BAD (don't use parentheses for unpacking)
def test_simple_unpacking(plugin):
s = """(a,) = ["a"]"""
assert lint_codes(plugin(s), ["PAR002"])
@@ -412,6 +413,94 @@ def test_one_line_expression_2(plugin):
assert lint_codes(plugin(s), ["PAR001"])
+# BAD (single line strings in list/tuple)
+# https://github.com/robsdedude/flake8-picky-parentheses/issues/32
+@pytest.mark.parametrize("value", (
+ '[("a")]',
+ '[("a"),]',
+ '[("a" "b")]',
+ '[("a" "b"),]',
+ '[("a"), "b"]',
+ '["a", ("b")]',
+ '[("a"), "b",]',
+ '["a", ("b"),]',
+ '[("a"), "b"\n"c"]',
+ '["a"\n"c", ("b")]',
+ '[("a"), "b"\n"c",]',
+ '["a"\n"c", ("b"),]',
+ '[("a"\n), "b"]',
+ '["a", ("b"\n)]',
+ '[(\n"a"), "b",]',
+ '["a", (\n"b"),]',
+ '[("a"\n# comment\n), "b"]',
+ '["a", ("b"\n# comment\n)]',
+ '[(\n# comment\n"a"), "b",]',
+ '["a", (\n# comment\n"b"),]',
+ '(("a"),)',
+ '(("a" "b"),)',
+ '(("a"), "b")',
+ '("a", ("b"))',
+ '(("a"), "b",)',
+ '("a", ("b"),)',
+ '(("a"), "b"\n"c")',
+ '("a"\n"c", ("b"))',
+ '(("a"), "b"\n"c",)',
+ '("a"\n"c", ("b"),)',
+ '(("a"\n), "b")',
+ '("a", ("b"\n))',
+ '((\n"a"), "b",)',
+ '("a", (\n"b"),)',
+ '(("a"\n# comment\n), "b")',
+ '("a", ("b"\n# comment\n))',
+ '((\n# comment\n"a"), "b",)',
+ '("a", (\n# comment\n"b"),)',
+))
+@pytest.mark.parametrize("quote", ("'", '"'))
+def test_single_line_strings(plugin, value, quote):
+ value = value.replace('"', quote)
+ s = f"a = {value}\n"
+ assert lint_codes(plugin(s), ["PAR001"])
+
+
+# GOOD (multi-line strings in list/tuple)
+# https://github.com/robsdedude/flake8-picky-parentheses/issues/32
+@pytest.mark.parametrize("value", (
+ '[("a"\n"b")]',
+ '[("a"\n"b"),]',
+ '[("a"\n"c"), "b"]',
+ '["a", ("b" \n"c")]',
+ '[("a"\n"c"), "b",]',
+ '["a", ("b"\n"c"),]',
+ '[(\n"a"\n"c"), "b"]',
+ '["a", (\n"b" \n"c")]',
+ '[(\n"a"\n"c"), "b",]',
+ '["a", (\n"b"\n"c"),]',
+ '[("a"\n"c"\n), "b"]',
+ '["a", ("b" \n"c"\n)]',
+ '[("a"\n"c"\n), "b",]',
+ '["a", ("b"\n"c"\n),]',
+ '(("a"\n"b"),)',
+ '(("a"\n"c"), "b")',
+ '("a", ("b" \n"c"))',
+ '(("a"\n"c"), "b",)',
+ '("a", ("b"\n"c"),)',
+ '((\n"a"\n"c"), "b")',
+ '("a", (\n"b" \n"c"))',
+ '((\n"a"\n"c"), "b",)',
+ '("a", (\n"b"\n"c"),)',
+ '(("a"\n"c"\n), "b")',
+ '("a", ("b" \n"c"\n))',
+ '(("a"\n"c"\n), "b",)',
+ '("a", ("b"\n"c"\n),)',
+
+))
+@pytest.mark.parametrize("quote", ("'", '"')[1:])
+def test_grouped_single_line_strings(plugin, value, quote):
+ value = value.replace('"', quote)
+ s = f"a = {value}\n"
+ assert no_lint(plugin(s))
+
+
# GOOD (function call)
def test_function_call(plugin):
s = """foo("a")