thailint 0.15.0__py3-none-any.whl → 0.15.1__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- src/analyzers/rust_base.py +155 -0
- src/analyzers/rust_context.py +141 -0
- src/cli/config.py +6 -4
- src/cli/linters/code_patterns.py +64 -16
- src/cli/linters/code_smells.py +23 -14
- src/cli/linters/documentation.py +5 -3
- src/cli/linters/performance.py +23 -10
- src/cli/linters/shared.py +22 -6
- src/cli/linters/structure.py +13 -4
- src/cli/linters/structure_quality.py +9 -4
- src/cli/utils.py +4 -4
- src/config.py +34 -21
- src/core/python_lint_rule.py +101 -0
- src/linter_config/ignore.py +2 -1
- src/linters/cqs/__init__.py +54 -0
- src/linters/cqs/config.py +55 -0
- src/linters/cqs/function_analyzer.py +201 -0
- src/linters/cqs/input_detector.py +139 -0
- src/linters/cqs/linter.py +159 -0
- src/linters/cqs/output_detector.py +84 -0
- src/linters/cqs/python_analyzer.py +54 -0
- src/linters/cqs/types.py +82 -0
- src/linters/cqs/typescript_cqs_analyzer.py +61 -0
- src/linters/cqs/typescript_function_analyzer.py +192 -0
- src/linters/cqs/typescript_input_detector.py +203 -0
- src/linters/cqs/typescript_output_detector.py +117 -0
- src/linters/cqs/violation_builder.py +94 -0
- src/linters/dry/typescript_value_extractor.py +2 -1
- src/linters/file_header/linter.py +2 -1
- src/linters/file_placement/linter.py +6 -6
- src/linters/file_placement/pattern_validator.py +6 -5
- src/linters/file_placement/rule_checker.py +10 -5
- src/linters/lazy_ignores/config.py +5 -3
- src/linters/lazy_ignores/python_analyzer.py +5 -1
- src/linters/lazy_ignores/types.py +2 -1
- src/linters/lbyl/__init__.py +3 -1
- src/linters/lbyl/linter.py +67 -0
- src/linters/lbyl/pattern_detectors/__init__.py +30 -2
- src/linters/lbyl/pattern_detectors/base.py +24 -7
- src/linters/lbyl/pattern_detectors/dict_key_detector.py +107 -0
- src/linters/lbyl/pattern_detectors/division_check_detector.py +232 -0
- src/linters/lbyl/pattern_detectors/file_exists_detector.py +220 -0
- src/linters/lbyl/pattern_detectors/hasattr_detector.py +119 -0
- src/linters/lbyl/pattern_detectors/isinstance_detector.py +119 -0
- src/linters/lbyl/pattern_detectors/len_check_detector.py +173 -0
- src/linters/lbyl/pattern_detectors/none_check_detector.py +146 -0
- src/linters/lbyl/pattern_detectors/string_validator_detector.py +145 -0
- src/linters/lbyl/python_analyzer.py +215 -0
- src/linters/lbyl/violation_builder.py +354 -0
- src/linters/stringly_typed/ignore_checker.py +4 -6
- src/orchestrator/language_detector.py +5 -3
- {thailint-0.15.0.dist-info → thailint-0.15.1.dist-info}/METADATA +4 -2
- {thailint-0.15.0.dist-info → thailint-0.15.1.dist-info}/RECORD +56 -29
- {thailint-0.15.0.dist-info → thailint-0.15.1.dist-info}/WHEEL +0 -0
- {thailint-0.15.0.dist-info → thailint-0.15.1.dist-info}/entry_points.txt +0 -0
- {thailint-0.15.0.dist-info → thailint-0.15.1.dist-info}/licenses/LICENSE +0 -0
|
@@ -0,0 +1,232 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Purpose: AST-based detector for division zero-check LBYL patterns
|
|
3
|
+
|
|
4
|
+
Scope: Detects 'if x != 0: a / x' patterns in Python code
|
|
5
|
+
|
|
6
|
+
Overview: Provides DivisionCheckDetector class that uses AST traversal to find LBYL
|
|
7
|
+
anti-patterns involving zero-checks before division. Identifies patterns where code
|
|
8
|
+
checks if a divisor is non-zero before dividing (e.g., 'if x != 0: a / x'). Also
|
|
9
|
+
detects inverse patterns with else branches and truthy checks. Covers division (/),
|
|
10
|
+
integer division (//), modulo (%), and augmented operators (/=, //=, %=).
|
|
11
|
+
|
|
12
|
+
Dependencies: ast module, base detector classes from pattern_detectors.base
|
|
13
|
+
|
|
14
|
+
Exports: DivisionCheckPattern, DivisionCheckDetector
|
|
15
|
+
|
|
16
|
+
Interfaces: DivisionCheckDetector.find_patterns(tree: ast.AST) -> list[DivisionCheckPattern]
|
|
17
|
+
|
|
18
|
+
Implementation: AST NodeVisitor pattern with visit_If to detect zero comparison followed
|
|
19
|
+
by division using the checked variable
|
|
20
|
+
|
|
21
|
+
Suppressions:
|
|
22
|
+
- N802: visit_If follows Python AST visitor naming convention (camelCase required)
|
|
23
|
+
- invalid-name: visit_If follows Python AST visitor naming convention (camelCase required)
|
|
24
|
+
"""
|
|
25
|
+
|
|
26
|
+
import ast
|
|
27
|
+
from collections.abc import Iterator
|
|
28
|
+
from dataclasses import dataclass
|
|
29
|
+
|
|
30
|
+
from .base import BaseLBYLDetector, LBYLPattern
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
@dataclass
|
|
34
|
+
class DivisionCheckPattern(LBYLPattern):
|
|
35
|
+
"""Pattern data for division check LBYL anti-pattern."""
|
|
36
|
+
|
|
37
|
+
divisor_name: str
|
|
38
|
+
operation: str
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def _is_zero_constant(node: ast.expr) -> bool:
|
|
42
|
+
"""Check if node is a zero constant."""
|
|
43
|
+
return isinstance(node, ast.Constant) and node.value == 0
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def _try_extract_zero_check(node: ast.expr) -> tuple[ast.expr | None, bool]:
|
|
47
|
+
"""Try to extract variable from zero comparison.
|
|
48
|
+
|
|
49
|
+
Returns:
|
|
50
|
+
Tuple of (variable_expr, is_non_zero_check) or (None, False) if not valid.
|
|
51
|
+
is_non_zero_check is True for '!= 0', False for '== 0'.
|
|
52
|
+
"""
|
|
53
|
+
if isinstance(node, ast.Compare):
|
|
54
|
+
return _extract_zero_comparison(node)
|
|
55
|
+
# Handle truthy check: if x: (implicit != 0)
|
|
56
|
+
if isinstance(node, ast.Name):
|
|
57
|
+
return node, True
|
|
58
|
+
if isinstance(node, ast.Attribute):
|
|
59
|
+
return node, True
|
|
60
|
+
if isinstance(node, ast.Subscript):
|
|
61
|
+
return node, True
|
|
62
|
+
return None, False
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def _is_equality_op(node: ast.Compare) -> ast.cmpop | None:
|
|
66
|
+
"""Return equality operator if it's a single eq/noteq comparison, else None."""
|
|
67
|
+
if len(node.ops) != 1 or len(node.comparators) != 1:
|
|
68
|
+
return None
|
|
69
|
+
op = node.ops[0]
|
|
70
|
+
return op if isinstance(op, (ast.Eq, ast.NotEq)) else None
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def _extract_zero_comparison(node: ast.Compare) -> tuple[ast.expr | None, bool]:
|
|
74
|
+
"""Extract variable from 'x != 0' or 'x == 0' comparison.
|
|
75
|
+
|
|
76
|
+
Returns:
|
|
77
|
+
Tuple of (variable_expr, is_non_zero) or (None, False) if not valid.
|
|
78
|
+
"""
|
|
79
|
+
op = _is_equality_op(node)
|
|
80
|
+
if op is None:
|
|
81
|
+
return None, False
|
|
82
|
+
|
|
83
|
+
left, right = node.left, node.comparators[0]
|
|
84
|
+
is_not_eq = isinstance(op, ast.NotEq)
|
|
85
|
+
|
|
86
|
+
# Check for 'x != 0', 'x == 0', '0 != x', '0 == x'
|
|
87
|
+
if _is_zero_constant(right):
|
|
88
|
+
return left, is_not_eq
|
|
89
|
+
if _is_zero_constant(left):
|
|
90
|
+
return right, is_not_eq
|
|
91
|
+
|
|
92
|
+
return None, False
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
def _get_expression_key(expr: ast.expr) -> str:
|
|
96
|
+
"""Get a normalized key for comparing expressions."""
|
|
97
|
+
return ast.dump(expr)
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
# Maps BinOp operator types to their string representation
|
|
101
|
+
DIVISION_BINOP_MAP = {
|
|
102
|
+
ast.Div: "/",
|
|
103
|
+
ast.FloorDiv: "//",
|
|
104
|
+
ast.Mod: "%",
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
# Maps AugAssign operator types to their string representation
|
|
108
|
+
DIVISION_AUGOP_MAP = {
|
|
109
|
+
ast.Div: "/=",
|
|
110
|
+
ast.FloorDiv: "//=",
|
|
111
|
+
ast.Mod: "%=",
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
# Keywords suggesting path-related variables (likely pathlib, not numeric division)
|
|
115
|
+
PATH_KEYWORDS = frozenset({"path", "file", "dir", "folder", "root", "name", "directory"})
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
def _get_variable_name(expr: ast.expr) -> str:
|
|
119
|
+
"""Extract variable name from expression for heuristic checks."""
|
|
120
|
+
if isinstance(expr, ast.Name):
|
|
121
|
+
return expr.id.lower()
|
|
122
|
+
if isinstance(expr, ast.Attribute):
|
|
123
|
+
return expr.attr.lower()
|
|
124
|
+
return ""
|
|
125
|
+
|
|
126
|
+
|
|
127
|
+
def _looks_like_path_variable(expr: ast.expr) -> bool:
|
|
128
|
+
"""Check if expression name suggests it's a path-related variable."""
|
|
129
|
+
name = _get_variable_name(expr)
|
|
130
|
+
return any(keyword in name for keyword in PATH_KEYWORDS)
|
|
131
|
+
|
|
132
|
+
|
|
133
|
+
def _is_likely_pathlib_division(node: ast.BinOp) -> bool:
|
|
134
|
+
"""Check if BinOp is likely pathlib path joining, not numeric division.
|
|
135
|
+
|
|
136
|
+
Heuristics:
|
|
137
|
+
- If left operand has path-related name (e.g., project_root, base_path)
|
|
138
|
+
- If right operand has path-related name (e.g., file_name, sub_dir)
|
|
139
|
+
- Only applies to single `/`, not `//` or `%`
|
|
140
|
+
"""
|
|
141
|
+
if not isinstance(node.op, ast.Div):
|
|
142
|
+
return False
|
|
143
|
+
return _looks_like_path_variable(node.left) or _looks_like_path_variable(node.right)
|
|
144
|
+
|
|
145
|
+
|
|
146
|
+
def _check_binop(node: ast.BinOp, expected_key: str) -> str | None:
|
|
147
|
+
"""Check if BinOp is division with expected divisor."""
|
|
148
|
+
op_str = DIVISION_BINOP_MAP.get(type(node.op))
|
|
149
|
+
if not op_str:
|
|
150
|
+
return None
|
|
151
|
+
if _get_expression_key(node.right) != expected_key:
|
|
152
|
+
return None
|
|
153
|
+
# Skip likely pathlib operations (false positive avoidance)
|
|
154
|
+
if _is_likely_pathlib_division(node):
|
|
155
|
+
return None
|
|
156
|
+
return op_str
|
|
157
|
+
|
|
158
|
+
|
|
159
|
+
def _check_augassign(node: ast.AugAssign, expected_key: str) -> str | None:
|
|
160
|
+
"""Check if AugAssign is division with expected divisor."""
|
|
161
|
+
op_str = DIVISION_AUGOP_MAP.get(type(node.op))
|
|
162
|
+
if op_str and _get_expression_key(node.value) == expected_key:
|
|
163
|
+
return op_str
|
|
164
|
+
return None
|
|
165
|
+
|
|
166
|
+
|
|
167
|
+
def _iter_ast_nodes(body: list[ast.stmt]) -> Iterator[ast.AST]:
|
|
168
|
+
"""Iterate over all AST nodes in a list of statements."""
|
|
169
|
+
for stmt in body:
|
|
170
|
+
yield from ast.walk(stmt)
|
|
171
|
+
|
|
172
|
+
|
|
173
|
+
def _check_division_node(node: ast.AST, expected_key: str) -> str | None:
|
|
174
|
+
"""Check if AST node is a division using expected divisor."""
|
|
175
|
+
if isinstance(node, ast.BinOp):
|
|
176
|
+
return _check_binop(node, expected_key)
|
|
177
|
+
if isinstance(node, ast.AugAssign):
|
|
178
|
+
return _check_augassign(node, expected_key)
|
|
179
|
+
return None
|
|
180
|
+
|
|
181
|
+
|
|
182
|
+
def _find_division(body: list[ast.stmt], expected_key: str) -> str | None:
|
|
183
|
+
"""Find first division operation using expected divisor in body."""
|
|
184
|
+
for node in _iter_ast_nodes(body):
|
|
185
|
+
result = _check_division_node(node, expected_key)
|
|
186
|
+
if result:
|
|
187
|
+
return result
|
|
188
|
+
return None
|
|
189
|
+
|
|
190
|
+
|
|
191
|
+
class DivisionCheckDetector(BaseLBYLDetector[DivisionCheckPattern]):
|
|
192
|
+
"""Detects 'if x != 0: a / x' LBYL patterns."""
|
|
193
|
+
|
|
194
|
+
def __init__(self) -> None:
|
|
195
|
+
"""Initialize the detector."""
|
|
196
|
+
self._patterns: list[DivisionCheckPattern] = []
|
|
197
|
+
|
|
198
|
+
def visit_If(self, node: ast.If) -> None: # noqa: N802 # pylint: disable=invalid-name
|
|
199
|
+
"""Visit if statement to check for division zero-check LBYL pattern.
|
|
200
|
+
|
|
201
|
+
Args:
|
|
202
|
+
node: AST If node to analyze
|
|
203
|
+
"""
|
|
204
|
+
self._check_division_pattern(node)
|
|
205
|
+
self.generic_visit(node)
|
|
206
|
+
|
|
207
|
+
def _check_division_pattern(self, node: ast.If) -> None:
|
|
208
|
+
"""Check if node is a division zero-check LBYL pattern and record it."""
|
|
209
|
+
var_expr, is_non_zero = _try_extract_zero_check(node.test)
|
|
210
|
+
if var_expr is None:
|
|
211
|
+
return
|
|
212
|
+
|
|
213
|
+
# For '!= 0' or truthy, check body; for '== 0', check else branch
|
|
214
|
+
body_to_check = node.body if is_non_zero else node.orelse
|
|
215
|
+
if not body_to_check:
|
|
216
|
+
return
|
|
217
|
+
|
|
218
|
+
expected_key = _get_expression_key(var_expr)
|
|
219
|
+
operation = _find_division(body_to_check, expected_key)
|
|
220
|
+
if operation:
|
|
221
|
+
self._patterns.append(self._create_pattern(node, var_expr, operation))
|
|
222
|
+
|
|
223
|
+
def _create_pattern(
|
|
224
|
+
self, node: ast.If, var_expr: ast.expr, operation: str
|
|
225
|
+
) -> DivisionCheckPattern:
|
|
226
|
+
"""Create DivisionCheckPattern from detected pattern."""
|
|
227
|
+
return DivisionCheckPattern(
|
|
228
|
+
line_number=node.lineno,
|
|
229
|
+
column=node.col_offset,
|
|
230
|
+
divisor_name=ast.unparse(var_expr),
|
|
231
|
+
operation=operation,
|
|
232
|
+
)
|
|
@@ -0,0 +1,220 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Purpose: AST-based detector for file exists LBYL patterns
|
|
3
|
+
|
|
4
|
+
Scope: Detects 'if os.path.exists(f): open(f)' and 'if Path(f).exists(): open(f)' patterns
|
|
5
|
+
|
|
6
|
+
Overview: Provides FileExistsDetector class that uses AST traversal to find LBYL anti-patterns
|
|
7
|
+
involving file existence checking. Identifies patterns where code checks if a file exists
|
|
8
|
+
before opening it. Handles both os.path.exists and pathlib.Path.exists patterns, including
|
|
9
|
+
import aliases. Returns FileExistsPattern objects containing the file path expression and
|
|
10
|
+
location. Avoids false positives for directory checks and different file paths.
|
|
11
|
+
|
|
12
|
+
Dependencies: ast module, base detector classes from pattern_detectors.base
|
|
13
|
+
|
|
14
|
+
Exports: FileExistsPattern, FileExistsDetector
|
|
15
|
+
|
|
16
|
+
Interfaces: FileExistsDetector.find_patterns(tree: ast.AST) -> list[FileExistsPattern]
|
|
17
|
+
|
|
18
|
+
Implementation: AST NodeVisitor pattern with visit_If to detect exists check followed by
|
|
19
|
+
file operation (open, read_text, write_text)
|
|
20
|
+
|
|
21
|
+
Suppressions:
|
|
22
|
+
- N802: visit_If follows Python AST visitor naming convention (camelCase required)
|
|
23
|
+
- invalid-name: visit_If follows Python AST visitor naming convention (camelCase required)
|
|
24
|
+
"""
|
|
25
|
+
|
|
26
|
+
import ast
|
|
27
|
+
from dataclasses import dataclass
|
|
28
|
+
|
|
29
|
+
from .base import BaseLBYLDetector, LBYLPattern
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
@dataclass
|
|
33
|
+
class FileExistsPattern(LBYLPattern):
|
|
34
|
+
"""Pattern data for file exists LBYL anti-pattern."""
|
|
35
|
+
|
|
36
|
+
file_path_expression: str
|
|
37
|
+
check_type: str # "os.path.exists", "Path.exists", "exists"
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
# File operation methods on Path objects
|
|
41
|
+
_PATH_FILE_METHODS = frozenset(("read_text", "write_text", "read_bytes", "write_bytes"))
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def _is_exists_attribute_call(node: ast.Call) -> bool:
|
|
45
|
+
"""Check if node is a method call to .exists() with one argument."""
|
|
46
|
+
if not isinstance(node.func, ast.Attribute):
|
|
47
|
+
return False
|
|
48
|
+
return node.func.attr == "exists" and len(node.args) == 1
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def _get_os_path_check_type(
|
|
52
|
+
func_value: ast.expr, path_arg: ast.expr
|
|
53
|
+
) -> tuple[ast.expr | None, str | None]:
|
|
54
|
+
"""Get check type for os.path.exists or alias.exists pattern."""
|
|
55
|
+
if isinstance(func_value, ast.Attribute) and func_value.attr == "path":
|
|
56
|
+
return path_arg, "os.path.exists"
|
|
57
|
+
if isinstance(func_value, ast.Name):
|
|
58
|
+
return path_arg, f"{func_value.id}.exists"
|
|
59
|
+
return None, None
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def _check_os_path_exists_attribute(node: ast.Call) -> tuple[ast.expr | None, str | None]:
|
|
63
|
+
"""Check for os.path.exists(f) or alias.exists(f) pattern."""
|
|
64
|
+
if not _is_exists_attribute_call(node):
|
|
65
|
+
return None, None
|
|
66
|
+
func = node.func
|
|
67
|
+
if not isinstance(func, ast.Attribute):
|
|
68
|
+
return None, None
|
|
69
|
+
return _get_os_path_check_type(func.value, node.args[0])
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def _check_exists_name_call(node: ast.Call) -> tuple[ast.expr | None, str | None]:
|
|
73
|
+
"""Check for exists(f) pattern from 'from os.path import exists'."""
|
|
74
|
+
if isinstance(node.func, ast.Name) and node.func.id == "exists" and len(node.args) == 1:
|
|
75
|
+
return node.args[0], "exists"
|
|
76
|
+
return None, None
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
def _is_os_path_exists_call(node: ast.expr) -> tuple[ast.expr | None, str | None]:
|
|
80
|
+
"""Check if node is os.path.exists(f) or exists(f) call."""
|
|
81
|
+
if not isinstance(node, ast.Call):
|
|
82
|
+
return None, None
|
|
83
|
+
|
|
84
|
+
result = _check_os_path_exists_attribute(node)
|
|
85
|
+
if result[0] is not None:
|
|
86
|
+
return result
|
|
87
|
+
return _check_exists_name_call(node)
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
def _is_os_path_attribute(node: ast.expr) -> bool:
|
|
91
|
+
"""Check if node is an os.path attribute access."""
|
|
92
|
+
return isinstance(node, ast.Attribute) and node.attr == "path"
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
def _check_path_constructor_exists(func_value: ast.expr) -> tuple[ast.expr | None, str | None]:
|
|
96
|
+
"""Check for Path(f).exists() pattern."""
|
|
97
|
+
if not isinstance(func_value, ast.Call):
|
|
98
|
+
return None, None
|
|
99
|
+
if not isinstance(func_value.func, ast.Name) or func_value.func.id != "Path":
|
|
100
|
+
return None, None
|
|
101
|
+
if len(func_value.args) == 1:
|
|
102
|
+
return func_value.args[0], "Path.exists"
|
|
103
|
+
return None, None
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
def _check_variable_exists(func_value: ast.expr) -> tuple[ast.expr | None, str | None]:
|
|
107
|
+
"""Check for p.exists() or self.path.exists() pattern."""
|
|
108
|
+
if isinstance(func_value, (ast.Name, ast.Attribute)):
|
|
109
|
+
return func_value, "Path.exists"
|
|
110
|
+
return None, None
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
def _is_pathlib_exists_method(node: ast.expr) -> bool:
|
|
114
|
+
"""Check if node is a .exists() method call (not os.path.exists)."""
|
|
115
|
+
if not isinstance(node, ast.Call):
|
|
116
|
+
return False
|
|
117
|
+
if not isinstance(node.func, ast.Attribute) or node.func.attr != "exists":
|
|
118
|
+
return False
|
|
119
|
+
return not _is_os_path_attribute(node.func.value)
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
def _extract_pathlib_func_value(node: ast.expr) -> ast.expr | None:
|
|
123
|
+
"""Extract func value from pathlib .exists() call, or None."""
|
|
124
|
+
if not _is_pathlib_exists_method(node):
|
|
125
|
+
return None
|
|
126
|
+
# After _is_pathlib_exists_method, we know node is Call with Attribute func
|
|
127
|
+
if not isinstance(node, ast.Call) or not isinstance(node.func, ast.Attribute):
|
|
128
|
+
return None
|
|
129
|
+
return node.func.value
|
|
130
|
+
|
|
131
|
+
|
|
132
|
+
def _is_pathlib_exists_call(node: ast.expr) -> tuple[ast.expr | None, str | None]:
|
|
133
|
+
"""Check if node is Path(f).exists() or p.exists() call."""
|
|
134
|
+
func_value = _extract_pathlib_func_value(node)
|
|
135
|
+
if func_value is None:
|
|
136
|
+
return None, None
|
|
137
|
+
|
|
138
|
+
result = _check_path_constructor_exists(func_value)
|
|
139
|
+
if result[0] is not None:
|
|
140
|
+
return result
|
|
141
|
+
return _check_variable_exists(func_value)
|
|
142
|
+
|
|
143
|
+
|
|
144
|
+
def _try_extract_exists_call(node: ast.expr) -> tuple[ast.expr | None, str | None]:
|
|
145
|
+
"""Try to extract file exists call arguments."""
|
|
146
|
+
result = _is_os_path_exists_call(node)
|
|
147
|
+
if result[0] is not None:
|
|
148
|
+
return result
|
|
149
|
+
return _is_pathlib_exists_call(node)
|
|
150
|
+
|
|
151
|
+
|
|
152
|
+
def _is_open_call(node: ast.Call, expected_path: str) -> bool:
|
|
153
|
+
"""Check if node is open(path) call."""
|
|
154
|
+
if not isinstance(node.func, ast.Name) or node.func.id != "open":
|
|
155
|
+
return False
|
|
156
|
+
return len(node.args) >= 1 and ast.dump(node.args[0]) == expected_path
|
|
157
|
+
|
|
158
|
+
|
|
159
|
+
def _is_path_method_call(node: ast.Call, expected_path: str) -> bool:
|
|
160
|
+
"""Check if node is path.read_text() or similar method call."""
|
|
161
|
+
if not isinstance(node.func, ast.Attribute):
|
|
162
|
+
return False
|
|
163
|
+
if node.func.attr not in _PATH_FILE_METHODS:
|
|
164
|
+
return False
|
|
165
|
+
return ast.dump(node.func.value) == expected_path
|
|
166
|
+
|
|
167
|
+
|
|
168
|
+
def _is_file_operation(node: ast.AST, expected_path: str) -> bool:
|
|
169
|
+
"""Check if node is a file operation on the expected path."""
|
|
170
|
+
if not isinstance(node, ast.Call):
|
|
171
|
+
return False
|
|
172
|
+
return _is_open_call(node, expected_path) or _is_path_method_call(node, expected_path)
|
|
173
|
+
|
|
174
|
+
|
|
175
|
+
def _is_inverted_check(test: ast.expr) -> bool:
|
|
176
|
+
"""Check if test is 'not exists(f)' pattern."""
|
|
177
|
+
return isinstance(test, ast.UnaryOp) and isinstance(test.op, ast.Not)
|
|
178
|
+
|
|
179
|
+
|
|
180
|
+
class FileExistsDetector(BaseLBYLDetector[FileExistsPattern]):
|
|
181
|
+
"""Detects 'if exists(f): open(f)' LBYL patterns."""
|
|
182
|
+
|
|
183
|
+
def __init__(self) -> None:
|
|
184
|
+
"""Initialize the detector."""
|
|
185
|
+
self._patterns: list[FileExistsPattern] = []
|
|
186
|
+
|
|
187
|
+
def visit_If(self, node: ast.If) -> None: # noqa: N802 # pylint: disable=invalid-name
|
|
188
|
+
"""Visit if statement to check for file exists LBYL pattern."""
|
|
189
|
+
self._check_file_exists_pattern(node)
|
|
190
|
+
self.generic_visit(node)
|
|
191
|
+
|
|
192
|
+
def _check_file_exists_pattern(self, node: ast.If) -> None:
|
|
193
|
+
"""Check if node matches file exists LBYL pattern."""
|
|
194
|
+
if _is_inverted_check(node.test):
|
|
195
|
+
return
|
|
196
|
+
|
|
197
|
+
path_expr, check_type = _try_extract_exists_call(node.test)
|
|
198
|
+
if path_expr is None or check_type is None:
|
|
199
|
+
return
|
|
200
|
+
|
|
201
|
+
if self._body_has_file_operation(node.body, path_expr):
|
|
202
|
+
self._patterns.append(self._create_pattern(node, path_expr, check_type))
|
|
203
|
+
|
|
204
|
+
def _body_has_file_operation(self, body: list[ast.stmt], path_expr: ast.expr) -> bool:
|
|
205
|
+
"""Check if body contains file operation on the same path."""
|
|
206
|
+
expected_path = ast.dump(path_expr)
|
|
207
|
+
return any(
|
|
208
|
+
_is_file_operation(node, expected_path) for stmt in body for node in ast.walk(stmt)
|
|
209
|
+
)
|
|
210
|
+
|
|
211
|
+
def _create_pattern(
|
|
212
|
+
self, node: ast.If, path_expr: ast.expr, check_type: str
|
|
213
|
+
) -> FileExistsPattern:
|
|
214
|
+
"""Create FileExistsPattern from detected pattern."""
|
|
215
|
+
return FileExistsPattern(
|
|
216
|
+
line_number=node.lineno,
|
|
217
|
+
column=node.col_offset,
|
|
218
|
+
file_path_expression=ast.unparse(path_expr),
|
|
219
|
+
check_type=check_type,
|
|
220
|
+
)
|
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Purpose: AST-based detector for hasattr LBYL patterns
|
|
3
|
+
|
|
4
|
+
Scope: Detects 'if hasattr(obj, attr): obj.attr' patterns in Python code
|
|
5
|
+
|
|
6
|
+
Overview: Provides HasattrDetector class that uses AST traversal to find LBYL anti-patterns
|
|
7
|
+
involving hasattr checking. Identifies patterns where code checks if an object has an
|
|
8
|
+
attribute before accessing it (e.g., 'if hasattr(obj, "attr"): obj.attr'). Returns
|
|
9
|
+
HasattrPattern objects containing the object name, attribute name, and location. Avoids
|
|
10
|
+
false positives for different object/attribute combinations, variable attributes, and
|
|
11
|
+
getattr usage.
|
|
12
|
+
|
|
13
|
+
Dependencies: ast module, base detector classes from pattern_detectors.base
|
|
14
|
+
|
|
15
|
+
Exports: HasattrPattern, HasattrDetector
|
|
16
|
+
|
|
17
|
+
Interfaces: HasattrDetector.find_patterns(tree: ast.AST) -> list[HasattrPattern]
|
|
18
|
+
|
|
19
|
+
Implementation: AST NodeVisitor pattern with visit_If to detect hasattr check followed by
|
|
20
|
+
attribute access
|
|
21
|
+
|
|
22
|
+
Suppressions:
|
|
23
|
+
- N802: visit_If follows Python AST visitor naming convention (camelCase required)
|
|
24
|
+
- invalid-name: visit_If follows Python AST visitor naming convention (camelCase required)
|
|
25
|
+
"""
|
|
26
|
+
|
|
27
|
+
import ast
|
|
28
|
+
from dataclasses import dataclass
|
|
29
|
+
|
|
30
|
+
from .base import BaseLBYLDetector, LBYLPattern
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
@dataclass
|
|
34
|
+
class HasattrPattern(LBYLPattern):
|
|
35
|
+
"""Pattern data for hasattr LBYL anti-pattern."""
|
|
36
|
+
|
|
37
|
+
object_name: str
|
|
38
|
+
attribute_name: str
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def _try_extract_hasattr_call(node: ast.expr) -> tuple[ast.expr | None, str | None]:
|
|
42
|
+
"""Try to extract hasattr call arguments.
|
|
43
|
+
|
|
44
|
+
Returns:
|
|
45
|
+
Tuple of (object_expr, attribute_name) or (None, None) if not a valid hasattr call.
|
|
46
|
+
"""
|
|
47
|
+
if not isinstance(node, ast.Call):
|
|
48
|
+
return None, None
|
|
49
|
+
if not isinstance(node.func, ast.Name) or node.func.id != "hasattr":
|
|
50
|
+
return None, None
|
|
51
|
+
return _extract_hasattr_args(node)
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def _extract_hasattr_args(call: ast.Call) -> tuple[ast.expr | None, str | None]:
|
|
55
|
+
"""Extract object and attribute name from hasattr call args.
|
|
56
|
+
|
|
57
|
+
Returns:
|
|
58
|
+
Tuple of (object_expr, attribute_name) or (None, None) if not valid.
|
|
59
|
+
"""
|
|
60
|
+
if len(call.args) != 2:
|
|
61
|
+
return None, None
|
|
62
|
+
|
|
63
|
+
attr_arg = call.args[1]
|
|
64
|
+
if not isinstance(attr_arg, ast.Constant) or not isinstance(attr_arg.value, str):
|
|
65
|
+
return None, None
|
|
66
|
+
|
|
67
|
+
return call.args[0], attr_arg.value
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
class HasattrDetector(BaseLBYLDetector[HasattrPattern]):
|
|
71
|
+
"""Detects 'if hasattr(obj, attr): obj.attr' LBYL patterns."""
|
|
72
|
+
|
|
73
|
+
def __init__(self) -> None:
|
|
74
|
+
"""Initialize the detector."""
|
|
75
|
+
self._patterns: list[HasattrPattern] = []
|
|
76
|
+
|
|
77
|
+
def visit_If(self, node: ast.If) -> None: # noqa: N802 # pylint: disable=invalid-name
|
|
78
|
+
"""Visit if statement to check for hasattr LBYL pattern.
|
|
79
|
+
|
|
80
|
+
Args:
|
|
81
|
+
node: AST If node to analyze
|
|
82
|
+
"""
|
|
83
|
+
self._check_hasattr_pattern(node)
|
|
84
|
+
self.generic_visit(node)
|
|
85
|
+
|
|
86
|
+
def _check_hasattr_pattern(self, node: ast.If) -> None:
|
|
87
|
+
"""Check if node is a hasattr LBYL pattern and record it."""
|
|
88
|
+
obj_expr, attr_name = _try_extract_hasattr_call(node.test)
|
|
89
|
+
if obj_expr is None or attr_name is None:
|
|
90
|
+
return
|
|
91
|
+
|
|
92
|
+
if self._body_has_attribute_access(node.body, obj_expr, attr_name):
|
|
93
|
+
self._patterns.append(self._create_pattern(node, obj_expr, attr_name))
|
|
94
|
+
|
|
95
|
+
def _body_has_attribute_access(
|
|
96
|
+
self, body: list[ast.stmt], obj_expr: ast.expr, attr_name: str
|
|
97
|
+
) -> bool:
|
|
98
|
+
"""Check if body contains obj.attr access matching the hasattr check."""
|
|
99
|
+
expected_obj = ast.dump(obj_expr)
|
|
100
|
+
return any(
|
|
101
|
+
self._is_matching_attribute(node, expected_obj, attr_name)
|
|
102
|
+
for stmt in body
|
|
103
|
+
for node in ast.walk(stmt)
|
|
104
|
+
)
|
|
105
|
+
|
|
106
|
+
def _is_matching_attribute(self, node: ast.AST, expected_obj: str, attr_name: str) -> bool:
|
|
107
|
+
"""Check if node is an attribute access matching expected object and name."""
|
|
108
|
+
if not isinstance(node, ast.Attribute):
|
|
109
|
+
return False
|
|
110
|
+
return node.attr == attr_name and ast.dump(node.value) == expected_obj
|
|
111
|
+
|
|
112
|
+
def _create_pattern(self, node: ast.If, obj_expr: ast.expr, attr_name: str) -> HasattrPattern:
|
|
113
|
+
"""Create HasattrPattern from detected pattern."""
|
|
114
|
+
return HasattrPattern(
|
|
115
|
+
line_number=node.lineno,
|
|
116
|
+
column=node.col_offset,
|
|
117
|
+
object_name=ast.unparse(obj_expr),
|
|
118
|
+
attribute_name=attr_name,
|
|
119
|
+
)
|
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Purpose: AST-based detector for isinstance LBYL patterns
|
|
3
|
+
|
|
4
|
+
Scope: Detects 'if isinstance(x, Type): x.method()' patterns in Python code
|
|
5
|
+
|
|
6
|
+
Overview: Provides IsinstanceDetector class that uses AST traversal to find LBYL anti-patterns
|
|
7
|
+
involving isinstance checking. Identifies patterns where code checks if an object is an
|
|
8
|
+
instance of a type before performing type-specific operations. Returns IsinstancePattern
|
|
9
|
+
objects containing the object name, type name, and location. This detector is disabled by
|
|
10
|
+
default in config because many isinstance checks are valid type narrowing.
|
|
11
|
+
|
|
12
|
+
Dependencies: ast module, base detector classes from pattern_detectors.base
|
|
13
|
+
|
|
14
|
+
Exports: IsinstancePattern, IsinstanceDetector
|
|
15
|
+
|
|
16
|
+
Interfaces: IsinstanceDetector.find_patterns(tree: ast.AST) -> list[IsinstancePattern]
|
|
17
|
+
|
|
18
|
+
Implementation: AST NodeVisitor pattern with visit_If to detect isinstance check followed by
|
|
19
|
+
operations on the checked object
|
|
20
|
+
|
|
21
|
+
Suppressions:
|
|
22
|
+
- N802: visit_If follows Python AST visitor naming convention (camelCase required)
|
|
23
|
+
- invalid-name: visit_If follows Python AST visitor naming convention (camelCase required)
|
|
24
|
+
"""
|
|
25
|
+
|
|
26
|
+
import ast
|
|
27
|
+
from dataclasses import dataclass
|
|
28
|
+
|
|
29
|
+
from .base import BaseLBYLDetector, LBYLPattern
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
@dataclass
|
|
33
|
+
class IsinstancePattern(LBYLPattern):
|
|
34
|
+
"""Pattern data for isinstance LBYL anti-pattern."""
|
|
35
|
+
|
|
36
|
+
object_name: str
|
|
37
|
+
type_name: str
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def _try_extract_isinstance_call(node: ast.expr) -> tuple[ast.expr | None, str | None]:
|
|
41
|
+
"""Try to extract isinstance call arguments.
|
|
42
|
+
|
|
43
|
+
Returns:
|
|
44
|
+
Tuple of (object_expr, type_name) or (None, None) if not a valid isinstance call.
|
|
45
|
+
"""
|
|
46
|
+
if not isinstance(node, ast.Call):
|
|
47
|
+
return None, None
|
|
48
|
+
if not isinstance(node.func, ast.Name) or node.func.id != "isinstance":
|
|
49
|
+
return None, None
|
|
50
|
+
return _extract_isinstance_args(node)
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def _extract_isinstance_args(call: ast.Call) -> tuple[ast.expr | None, str | None]:
|
|
54
|
+
"""Extract object and type name from isinstance call args.
|
|
55
|
+
|
|
56
|
+
Returns:
|
|
57
|
+
Tuple of (object_expr, type_name_str) or (None, None) if not valid.
|
|
58
|
+
"""
|
|
59
|
+
if len(call.args) != 2:
|
|
60
|
+
return None, None
|
|
61
|
+
return call.args[0], ast.unparse(call.args[1])
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
class IsinstanceDetector(BaseLBYLDetector[IsinstancePattern]):
|
|
65
|
+
"""Detects 'if isinstance(x, Type): x.method()' LBYL patterns."""
|
|
66
|
+
|
|
67
|
+
def __init__(self) -> None:
|
|
68
|
+
"""Initialize the detector."""
|
|
69
|
+
self._patterns: list[IsinstancePattern] = []
|
|
70
|
+
|
|
71
|
+
def visit_If(self, node: ast.If) -> None: # noqa: N802 # pylint: disable=invalid-name
|
|
72
|
+
"""Visit if statement to check for isinstance LBYL pattern.
|
|
73
|
+
|
|
74
|
+
Args:
|
|
75
|
+
node: AST If node to analyze
|
|
76
|
+
"""
|
|
77
|
+
self._check_isinstance_pattern(node)
|
|
78
|
+
self.generic_visit(node)
|
|
79
|
+
|
|
80
|
+
def _check_isinstance_pattern(self, node: ast.If) -> None:
|
|
81
|
+
"""Check if node is an isinstance LBYL pattern and record it."""
|
|
82
|
+
obj_expr, type_name = _try_extract_isinstance_call(node.test)
|
|
83
|
+
if obj_expr is None or type_name is None:
|
|
84
|
+
return
|
|
85
|
+
|
|
86
|
+
if self._body_has_object_operation(node.body, obj_expr):
|
|
87
|
+
self._patterns.append(self._create_pattern(node, obj_expr, type_name))
|
|
88
|
+
|
|
89
|
+
def _body_has_object_operation(self, body: list[ast.stmt], obj_expr: ast.expr) -> bool:
|
|
90
|
+
"""Check if body contains operations on the isinstance-checked object."""
|
|
91
|
+
expected_obj = ast.dump(obj_expr)
|
|
92
|
+
return any(
|
|
93
|
+
self._node_uses_object(node, expected_obj) for stmt in body for node in ast.walk(stmt)
|
|
94
|
+
)
|
|
95
|
+
|
|
96
|
+
def _node_uses_object(self, node: ast.AST, expected_obj: str) -> bool:
|
|
97
|
+
"""Check if AST node uses the expected object."""
|
|
98
|
+
if isinstance(node, ast.Attribute):
|
|
99
|
+
return ast.dump(node.value) == expected_obj
|
|
100
|
+
if isinstance(node, ast.Subscript):
|
|
101
|
+
return ast.dump(node.value) == expected_obj
|
|
102
|
+
if isinstance(node, ast.BinOp):
|
|
103
|
+
return self._binop_uses_object(node, expected_obj)
|
|
104
|
+
return False
|
|
105
|
+
|
|
106
|
+
def _binop_uses_object(self, node: ast.BinOp, expected_obj: str) -> bool:
|
|
107
|
+
"""Check if binary operation uses the checked object."""
|
|
108
|
+
return ast.dump(node.left) == expected_obj or ast.dump(node.right) == expected_obj
|
|
109
|
+
|
|
110
|
+
def _create_pattern(
|
|
111
|
+
self, node: ast.If, obj_expr: ast.expr, type_name: str
|
|
112
|
+
) -> IsinstancePattern:
|
|
113
|
+
"""Create IsinstancePattern from detected pattern."""
|
|
114
|
+
return IsinstancePattern(
|
|
115
|
+
line_number=node.lineno,
|
|
116
|
+
column=node.col_offset,
|
|
117
|
+
object_name=ast.unparse(obj_expr),
|
|
118
|
+
type_name=type_name,
|
|
119
|
+
)
|