Skip to content

Commit 2ea329b

Browse files
committed
1 parent 7cce069 commit 2ea329b

File tree

8 files changed

+220
-3
lines changed

8 files changed

+220
-3
lines changed

CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,7 @@ Semantic versioning in our case means:
2424
- Adds `WPS478`: forbids using non strict slice operations, #1011
2525
- Adds `WPS479`: forbids using multiline fstrings, #3405
2626
- Adds `WPS480`: forbids using comments inside formatted string, #3404
27+
- Adds `WPS481`: forbids using complex slice indexes, #3427
2728

2829
### Bugfixes
2930

tests/fixtures/noqa/noqa.py

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -756,3 +756,6 @@ async def test_await_in_loop():
756756

757757
some_sequence = some_sequence[::-1] # noqa: WPS478
758758

759+
array = array[my_dict["index"] :] # noqa: WPS481
760+
array = array[: end_index()] # noqa: WPS481
761+
array = array[:: (var_x * (var_y + 1))] # noqa: WPS481

tests/test_checker/test_noqa.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -245,6 +245,7 @@
245245
'WPS478': 1,
246246
'WPS479': 0,
247247
'WPS480': 0, # only triggers on 3.12+
248+
'WPS481': 3,
248249
'WPS500': 1,
249250
'WPS501': 1,
250251
'WPS502': 0, # disabled since 1.0.0
Lines changed: 78 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,78 @@
1+
import pytest
2+
3+
from wemake_python_styleguide.violations.best_practices import (
4+
ComplexSliceIndexViolation,
5+
)
6+
from wemake_python_styleguide.visitors.ast.subscripts import SliceIndexVisitor
7+
8+
usage_template = 'constant[{0}]'
9+
10+
11+
@pytest.mark.parametrize(
12+
'expression',
13+
[
14+
'my_dict["start_index"]:',
15+
'::my_dict["step"]',
16+
'my_list[0]:',
17+
'::my_list[-1]',
18+
'start_index():',
19+
'::step()',
20+
'Slice().start:',
21+
'Slice(arg_1).start:',
22+
'index.start():',
23+
':-(-x + 1)',
24+
':-(-x + -y)',
25+
':-(-x + -(y + 1))',
26+
':-(-(x + 1) + -(y + 1))',
27+
'(1 - (x + 2)):',
28+
'(1 - (x + 2) * 2):',
29+
'(index.start + 1):',
30+
'(x + y + z):',
31+
':x + y + z',
32+
],
33+
)
34+
def test_invalid_slice_indexes(
35+
assert_errors,
36+
parse_ast_tree,
37+
expression,
38+
default_options,
39+
):
40+
"""Testing that invalid indexes are forbidden."""
41+
tree = parse_ast_tree(usage_template.format(expression))
42+
43+
visitor = SliceIndexVisitor(default_options, tree=tree)
44+
visitor.run()
45+
46+
assert_errors(visitor, [ComplexSliceIndexViolation])
47+
48+
49+
@pytest.mark.parametrize(
50+
'expression',
51+
[
52+
'1:3',
53+
'1:3:2',
54+
'start:end',
55+
'::step',
56+
'index.start:',
57+
'index.start::index.step',
58+
'Slice.start:',
59+
':-start',
60+
'(x + 1):',
61+
'(-x + 1):',
62+
':-(x + 1)',
63+
'::-index.step',
64+
],
65+
)
66+
def test_valid_slice_indexes(
67+
assert_errors,
68+
parse_ast_tree,
69+
expression,
70+
default_options,
71+
):
72+
"""Testing that valid indexes are allowed."""
73+
tree = parse_ast_tree(usage_template.format(expression))
74+
75+
visitor = SliceIndexVisitor(default_options, tree=tree)
76+
visitor.run()
77+
78+
assert_errors(visitor, [])

wemake_python_styleguide/presets/types/tree.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -75,6 +75,7 @@
7575
subscripts.ImplicitDictGetVisitor,
7676
subscripts.CorrectKeyVisitor,
7777
subscripts.StrictSliceOperations,
78+
subscripts.SliceIndexVisitor,
7879
decorators.WrongDecoratorVisitor,
7980
redundancy.RedundantEnumerateVisitor,
8081
pm.MatchSubjectVisitor,

wemake_python_styleguide/violations/base.py

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -251,5 +251,6 @@ def _location(self) -> tuple[int, int]:
251251

252252
def _prepend_skipping_whitespaces(prefix: str, text: str) -> str:
253253
lstripped_text = text.lstrip()
254-
leading_whitespaces = text[: len(text) - len(lstripped_text)]
254+
end_index = len(text) - len(lstripped_text)
255+
leading_whitespaces = text[:end_index]
255256
return leading_whitespaces + prefix + lstripped_text

wemake_python_styleguide/violations/best_practices.py

Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -97,6 +97,7 @@
9797
NonStrictSliceOperationsViolation
9898
MultilineFormattedStringViolation
9999
CommentInFormattedStringViolation
100+
ComplexSliceIndexViolation
100101
101102
Best practices
102103
--------------
@@ -182,6 +183,7 @@
182183
.. autoclass:: NonStrictSliceOperationsViolation
183184
.. autoclass:: MultilineFormattedStringViolation
184185
.. autoclass:: CommentInFormattedStringViolation
186+
.. autoclass:: ComplexSliceIndexViolation
185187
186188
"""
187189

@@ -3150,3 +3152,42 @@ class CommentInFormattedStringViolation(TokenizeViolation):
31503152

31513153
error_template = 'Found comment inside formatted string'
31523154
code = 480
3155+
3156+
3157+
@final
3158+
class ComplexSliceIndexViolation(ASTViolation):
3159+
"""
3160+
Forbid using complex grammar for slice index.
3161+
3162+
Reasoning:
3163+
It is probably not a good idea to use complex grammar in slice indexes.
3164+
Because indexes should be simple and easy to read.
3165+
3166+
Solution:
3167+
Use names, constants and attributes as indexes only.
3168+
Without complex expressions in parentheses.
3169+
3170+
Example::
3171+
3172+
# Correct:
3173+
array[3:7]
3174+
array[:-(x + 3)]
3175+
array[::-index.step]
3176+
array[start_index:end_index]
3177+
array[index.start:index.end]
3178+
array[Slice.start:]
3179+
3180+
# Wrong:
3181+
array[my_dict["start_index"]:my_list[-1]]
3182+
array[start_index():index.end()]
3183+
array[Slice().start:]
3184+
array[-(-x + 1):]
3185+
array[(x * (y + 2)):]
3186+
array[(x + y + z):]
3187+
3188+
.. versionadded:: 1.2.0
3189+
3190+
"""
3191+
3192+
error_template = 'Found complex slice index'
3193+
code = 481

wemake_python_styleguide/visitors/ast/subscripts.py

Lines changed: 93 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,15 +1,42 @@
11
import ast
2-
from typing import ClassVar, final
2+
from typing import ClassVar, Final, final
33

44
from wemake_python_styleguide.logic import source
5-
from wemake_python_styleguide.logic.tree import functions, operators, slices
5+
from wemake_python_styleguide.logic.tree import (
6+
attributes,
7+
functions,
8+
operators,
9+
slices,
10+
)
11+
from wemake_python_styleguide.types import AnyNodes
612
from wemake_python_styleguide.violations import (
713
best_practices,
814
consistency,
915
refactoring,
1016
)
1117
from wemake_python_styleguide.visitors import base
1218

19+
_ALLOWED_BINOP_TYPES: Final = (
20+
ast.Name,
21+
ast.Constant,
22+
ast.UnaryOp,
23+
)
24+
25+
_ALLOWED_UNARYOP_TYPES: Final = (
26+
ast.Name,
27+
ast.Constant,
28+
ast.BinOp,
29+
ast.Attribute,
30+
)
31+
32+
_ALLOWED_INDEX_TYPES: Final = (
33+
ast.Name,
34+
ast.Constant,
35+
ast.BinOp,
36+
ast.UnaryOp,
37+
ast.Attribute,
38+
)
39+
1340

1441
@final
1542
class SubscriptVisitor(base.BaseNodeVisitor):
@@ -252,3 +279,67 @@ def _is_node_have_value(
252279
)
253280

254281
return isinstance(node, ast.Constant) and node.value == value_to_check
282+
283+
284+
@final
285+
class SliceIndexVisitor(base.BaseNodeVisitor):
286+
"""Checks slices used in the code."""
287+
288+
def visit_Subscript(self, node: ast.Subscript) -> None:
289+
"""Visits slice."""
290+
if not isinstance(node.slice, ast.Slice):
291+
return
292+
293+
self._check_slice_complexity(node.slice)
294+
self.generic_visit(node.slice)
295+
296+
def _check_slice_complexity(self, node: ast.Slice) -> None:
297+
for index in (node.lower, node.upper, node.step):
298+
if index is None:
299+
continue
300+
301+
if not self._is_allowed_index(index, _ALLOWED_INDEX_TYPES):
302+
self.add_violation(
303+
best_practices.ComplexSliceIndexViolation(index)
304+
)
305+
306+
def _is_allowed_index(
307+
self,
308+
node: ast.expr,
309+
allowed_types: AnyNodes,
310+
) -> bool:
311+
for part in attributes.parts(node):
312+
if isinstance(part, ast.UnaryOp) and not self._is_allowed_unaryop(
313+
part, allowed_types
314+
):
315+
return False
316+
317+
if isinstance(part, ast.BinOp) and not self._is_allowed_binop(
318+
part, allowed_types
319+
):
320+
return False
321+
322+
if not isinstance(part, allowed_types):
323+
return False
324+
325+
return True
326+
327+
def _is_allowed_unaryop(
328+
self,
329+
node: ast.UnaryOp,
330+
allowed_types: AnyNodes,
331+
) -> bool:
332+
if allowed_types is _ALLOWED_INDEX_TYPES:
333+
return self._is_allowed_index(node.operand, _ALLOWED_UNARYOP_TYPES)
334+
return self._is_allowed_index(node.operand, allowed_types)
335+
336+
def _is_allowed_binop(
337+
self, node: ast.BinOp, allowed_types: AnyNodes
338+
) -> bool:
339+
if allowed_types is _ALLOWED_INDEX_TYPES:
340+
return self._is_allowed_index(
341+
node.left, _ALLOWED_BINOP_TYPES
342+
) and self._is_allowed_index(node.right, _ALLOWED_BINOP_TYPES)
343+
return self._is_allowed_index(
344+
node.left, allowed_types
345+
) and self._is_allowed_index(node.right, allowed_types)

0 commit comments

Comments
 (0)