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")