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.
Files changed (56) hide show
  1. src/analyzers/rust_base.py +155 -0
  2. src/analyzers/rust_context.py +141 -0
  3. src/cli/config.py +6 -4
  4. src/cli/linters/code_patterns.py +64 -16
  5. src/cli/linters/code_smells.py +23 -14
  6. src/cli/linters/documentation.py +5 -3
  7. src/cli/linters/performance.py +23 -10
  8. src/cli/linters/shared.py +22 -6
  9. src/cli/linters/structure.py +13 -4
  10. src/cli/linters/structure_quality.py +9 -4
  11. src/cli/utils.py +4 -4
  12. src/config.py +34 -21
  13. src/core/python_lint_rule.py +101 -0
  14. src/linter_config/ignore.py +2 -1
  15. src/linters/cqs/__init__.py +54 -0
  16. src/linters/cqs/config.py +55 -0
  17. src/linters/cqs/function_analyzer.py +201 -0
  18. src/linters/cqs/input_detector.py +139 -0
  19. src/linters/cqs/linter.py +159 -0
  20. src/linters/cqs/output_detector.py +84 -0
  21. src/linters/cqs/python_analyzer.py +54 -0
  22. src/linters/cqs/types.py +82 -0
  23. src/linters/cqs/typescript_cqs_analyzer.py +61 -0
  24. src/linters/cqs/typescript_function_analyzer.py +192 -0
  25. src/linters/cqs/typescript_input_detector.py +203 -0
  26. src/linters/cqs/typescript_output_detector.py +117 -0
  27. src/linters/cqs/violation_builder.py +94 -0
  28. src/linters/dry/typescript_value_extractor.py +2 -1
  29. src/linters/file_header/linter.py +2 -1
  30. src/linters/file_placement/linter.py +6 -6
  31. src/linters/file_placement/pattern_validator.py +6 -5
  32. src/linters/file_placement/rule_checker.py +10 -5
  33. src/linters/lazy_ignores/config.py +5 -3
  34. src/linters/lazy_ignores/python_analyzer.py +5 -1
  35. src/linters/lazy_ignores/types.py +2 -1
  36. src/linters/lbyl/__init__.py +3 -1
  37. src/linters/lbyl/linter.py +67 -0
  38. src/linters/lbyl/pattern_detectors/__init__.py +30 -2
  39. src/linters/lbyl/pattern_detectors/base.py +24 -7
  40. src/linters/lbyl/pattern_detectors/dict_key_detector.py +107 -0
  41. src/linters/lbyl/pattern_detectors/division_check_detector.py +232 -0
  42. src/linters/lbyl/pattern_detectors/file_exists_detector.py +220 -0
  43. src/linters/lbyl/pattern_detectors/hasattr_detector.py +119 -0
  44. src/linters/lbyl/pattern_detectors/isinstance_detector.py +119 -0
  45. src/linters/lbyl/pattern_detectors/len_check_detector.py +173 -0
  46. src/linters/lbyl/pattern_detectors/none_check_detector.py +146 -0
  47. src/linters/lbyl/pattern_detectors/string_validator_detector.py +145 -0
  48. src/linters/lbyl/python_analyzer.py +215 -0
  49. src/linters/lbyl/violation_builder.py +354 -0
  50. src/linters/stringly_typed/ignore_checker.py +4 -6
  51. src/orchestrator/language_detector.py +5 -3
  52. {thailint-0.15.0.dist-info → thailint-0.15.1.dist-info}/METADATA +4 -2
  53. {thailint-0.15.0.dist-info → thailint-0.15.1.dist-info}/RECORD +56 -29
  54. {thailint-0.15.0.dist-info → thailint-0.15.1.dist-info}/WHEEL +0 -0
  55. {thailint-0.15.0.dist-info → thailint-0.15.1.dist-info}/entry_points.txt +0 -0
  56. {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
+ )