Skip to content

Commit 60834f3

Browse files
committed
feat: forbid redundant trailing colon in slice (fixes #1071)
1 parent 91e062f commit 60834f3

6 files changed

Lines changed: 207 additions & 0 deletions

File tree

CHANGELOG.md

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,12 @@ Semantic versioning in our case means:
1616
But, in the future we might change the configuration names/logic,
1717
change the client facing API, change code conventions significantly, etc.
1818

19+
## Unreleased
20+
21+
### Features
22+
23+
- Adds `WPS367`: forbid redundant trailing colon in slice, #1071
24+
1925
## 1.6.2
2026

2127
### Bugfixes

tests/test_visitors/test_ast/test_subscripts/test_redundant_subscripts.py

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,8 @@
1717
'3:None:2',
1818
'3:7:None',
1919
'3:7:1',
20+
'::1',
21+
'1::1',
2022
],
2123
)
2224
def test_one_redundant_subscript(
@@ -44,6 +46,7 @@ def test_one_redundant_subscript(
4446
'None:None',
4547
'3:None:1',
4648
':None:None',
49+
'0::1',
4750
],
4851
)
4952
def test_two_redundant_subscript(
Lines changed: 69 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,69 @@
1+
import pytest
2+
3+
from wemake_python_styleguide.violations.consistency import (
4+
RedundantTrailingSliceViolation,
5+
)
6+
from wemake_python_styleguide.visitors.tokenize.subscripts import (
7+
RedundantTrailingSliceVisitor,
8+
)
9+
10+
# Wrong:
11+
trailing_colon_cases = [
12+
'a[1:4:]',
13+
'a[1::]',
14+
'a[:4:]',
15+
'a[None::]',
16+
'a[1:None:]',
17+
'a[1 + 2::]',
18+
]
19+
20+
# Correct:
21+
correct_cases = [
22+
'a[1:4]',
23+
'a[1:]',
24+
'a[:4]',
25+
'a[::]', # caught by NonStrictSliceOperationsViolation
26+
'a[1:4:5]',
27+
'a[1:4:None]',
28+
'a[1]',
29+
'a[1:4:1]', # caught by RedundantSubscriptViolation, not ours
30+
'a[b[1:4:5]]', # nested valid slice
31+
'a[b[1:4:]]', # nested invalid — should flag inner
32+
'a[{1: 2}]', # dict literal inside subscript
33+
'a[(1, 2)]', # tuple inside subscript
34+
'a[lambda x: x]', # lambda inside subscript
35+
]
36+
37+
38+
@pytest.mark.parametrize('code', trailing_colon_cases)
39+
def test_redundant_trailing_colon(
40+
parse_tokens,
41+
assert_errors,
42+
default_options,
43+
code,
44+
):
45+
"""Ensure trailing colon in slice is forbidden."""
46+
file_tokens = parse_tokens(code)
47+
visitor = RedundantTrailingSliceVisitor(
48+
default_options,
49+
file_tokens=file_tokens,
50+
)
51+
visitor.run()
52+
assert_errors(visitor, [RedundantTrailingSliceViolation])
53+
54+
55+
@pytest.mark.parametrize('code', correct_cases)
56+
def test_correct_slice_not_flagged(
57+
parse_tokens,
58+
assert_errors,
59+
default_options,
60+
code,
61+
):
62+
"""Ensure valid slices do not raise violation."""
63+
file_tokens = parse_tokens(code)
64+
visitor = RedundantTrailingSliceVisitor(
65+
default_options,
66+
file_tokens=file_tokens,
67+
)
68+
visitor.run()
69+
assert_errors(visitor, [])

wemake_python_styleguide/presets/types/file_tokens.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@
55
conditions,
66
primitives,
77
statements,
8+
subscripts,
89
syntax,
910
)
1011

@@ -21,4 +22,5 @@
2122
statements.MultilineStringVisitor,
2223
conditions.IfElseVisitor,
2324
primitives.MultilineFormattedStringTokenVisitor,
25+
subscripts.RedundantTrailingSliceVisitor,
2426
)

wemake_python_styleguide/violations/consistency.py

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2511,3 +2511,36 @@ class MeaninglessBooleanOperationViolation(ASTViolation):
25112511

25122512
error_template = 'Found meaningless boolean operation'
25132513
code = 366
2514+
2515+
2516+
@final
2517+
class RedundantTrailingSliceViolation(TokenizeViolation):
2518+
"""
2519+
Forbid redundant trailing colon in subscript slice.
2520+
2521+
Reasoning:
2522+
Trailing colon inside a subscript slice does not change behavior.
2523+
For example, ``a[1:4:]`` is parsed identically to ``a[1:4]``.
2524+
It adds visual noise for no reason.
2525+
2526+
Solution:
2527+
Remove the trailing colon.
2528+
2529+
Example::
2530+
2531+
# Correct:
2532+
a[1:4]
2533+
a[1:]
2534+
a[:4]
2535+
2536+
# Wrong:
2537+
a[1:4:]
2538+
a[1::]
2539+
a[:4:]
2540+
2541+
.. versionadded:: 1.7.0
2542+
2543+
"""
2544+
2545+
error_template = 'Found redundant trailing slice colon'
2546+
code = 367
Lines changed: 94 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,94 @@
1+
import tokenize
2+
from typing import ClassVar, Final, TypedDict, final
3+
4+
from wemake_python_styleguide.violations import consistency
5+
from wemake_python_styleguide.visitors.base import BaseTokenVisitor
6+
7+
_INSIGNIFICANT_TYPES: Final = frozenset((
8+
tokenize.NL,
9+
tokenize.NEWLINE,
10+
tokenize.COMMENT,
11+
tokenize.INDENT,
12+
tokenize.DEDENT,
13+
tokenize.ENCODING,
14+
tokenize.ENDMARKER,
15+
))
16+
17+
18+
_COLON_COUNT: Final = 'colon_count'
19+
_LAST_WAS_COLON: Final = 'last_was_colon'
20+
_LAST_COLON: Final = 'last_colon'
21+
_HAS_NON_COLON: Final = 'has_non_colon'
22+
23+
24+
class _SliceBracketState(TypedDict):
25+
"""Mutable state tracker for a single ``[...]`` bracket level."""
26+
27+
colon_count: int
28+
last_was_colon: bool
29+
last_colon: tokenize.TokenInfo | None
30+
has_non_colon: bool
31+
32+
33+
@final
34+
class RedundantTrailingSliceVisitor(BaseTokenVisitor):
35+
"""Check for redundant trailing colon in subscript slices."""
36+
37+
def __init__(self, *args, **kwargs) -> None:
38+
"""Initialize state for bracket tracking."""
39+
super().__init__(*args, **kwargs)
40+
self._bracket_stack: list[_SliceBracketState] = []
41+
42+
def visit(self, token: tokenize.TokenInfo) -> None:
43+
"""Track brackets and colons to detect trailing colon."""
44+
self._maybe_push_bracket(token)
45+
self._maybe_pop_bracket(token)
46+
self._maybe_track_colon(token)
47+
self._maybe_track_other(token)
48+
super().visit(token)
49+
50+
def _maybe_push_bracket(self, token: tokenize.TokenInfo) -> None:
51+
if token.exact_type == tokenize.OP and token.string == '[':
52+
self._bracket_stack.append({
53+
_COLON_COUNT: 0,
54+
_LAST_WAS_COLON: False,
55+
_LAST_COLON: None,
56+
_HAS_NON_COLON: False,
57+
})
58+
59+
def _maybe_pop_bracket(self, token: tokenize.TokenInfo) -> None:
60+
if not (token.exact_type == tokenize.OP and token.string == ']'):
61+
return
62+
if not self._bracket_stack:
63+
return
64+
entry = self._bracket_stack.pop()
65+
if (
66+
entry[_LAST_WAS_COLON]
67+
and entry[_COLON_COUNT] >= 2
68+
and entry[_HAS_NON_COLON]
69+
):
70+
self.add_violation(
71+
consistency.RedundantTrailingSliceViolation(
72+
entry[_LAST_COLON],
73+
),
74+
)
75+
76+
def _maybe_track_colon(self, token: tokenize.TokenInfo) -> None:
77+
if not (
78+
token.exact_type == tokenize.OP
79+
and token.string == ':'
80+
and self._bracket_stack
81+
):
82+
return
83+
entry = self._bracket_stack[-1]
84+
entry[_COLON_COUNT] += 1
85+
entry[_LAST_WAS_COLON] = True
86+
entry[_LAST_COLON] = token
87+
88+
def _maybe_track_other(self, token: tokenize.TokenInfo) -> None:
89+
if token.type in _INSIGNIFICANT_TYPES or not self._bracket_stack:
90+
return
91+
entry = self._bracket_stack[-1]
92+
entry[_LAST_WAS_COLON] = False
93+
if token.string != ':':
94+
entry[_HAS_NON_COLON] = True

0 commit comments

Comments
 (0)