python-oop-analyzer 0.1.0__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.
- oop_analyzer/__init__.py +12 -0
- oop_analyzer/analyzer.py +373 -0
- oop_analyzer/cli.py +160 -0
- oop_analyzer/config.py +155 -0
- oop_analyzer/formatters/__init__.py +31 -0
- oop_analyzer/formatters/base.py +101 -0
- oop_analyzer/formatters/html_formatter.py +222 -0
- oop_analyzer/formatters/json_formatter.py +37 -0
- oop_analyzer/formatters/xml_formatter.py +113 -0
- oop_analyzer/py.typed +0 -0
- oop_analyzer/rules/__init__.py +56 -0
- oop_analyzer/rules/base.py +186 -0
- oop_analyzer/rules/boolean_flag.py +391 -0
- oop_analyzer/rules/coupling.py +616 -0
- oop_analyzer/rules/dictionary_usage.py +526 -0
- oop_analyzer/rules/encapsulation.py +291 -0
- oop_analyzer/rules/functions_to_objects.py +331 -0
- oop_analyzer/rules/null_object.py +472 -0
- oop_analyzer/rules/polymorphism.py +428 -0
- oop_analyzer/rules/reference_exposure.py +348 -0
- oop_analyzer/rules/type_code.py +450 -0
- oop_analyzer/safety.py +163 -0
- python_oop_analyzer-0.1.0.dist-info/METADATA +383 -0
- python_oop_analyzer-0.1.0.dist-info/RECORD +27 -0
- python_oop_analyzer-0.1.0.dist-info/WHEEL +4 -0
- python_oop_analyzer-0.1.0.dist-info/entry_points.txt +2 -0
- python_oop_analyzer-0.1.0.dist-info/licenses/LICENSE +21 -0
|
@@ -0,0 +1,428 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Polymorphism Rule - If Blocks Replaceable by Polymorphism.
|
|
3
|
+
|
|
4
|
+
This rule detects if/elif chains and switch-like patterns that could
|
|
5
|
+
be replaced by polymorphism (strategy pattern, state pattern, etc.).
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
import ast
|
|
9
|
+
from typing import Any
|
|
10
|
+
|
|
11
|
+
from .base import BaseRule, RuleResult, RuleViolation
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
class PolymorphismRule(BaseRule):
|
|
15
|
+
"""
|
|
16
|
+
Detects if blocks that could be replaced by polymorphism.
|
|
17
|
+
|
|
18
|
+
Patterns detected:
|
|
19
|
+
- Long if/elif chains checking the same variable
|
|
20
|
+
- Type checking with isinstance() in conditionals
|
|
21
|
+
- Checking object type/kind attributes
|
|
22
|
+
- Switch-like patterns (match statements in Python 3.10+)
|
|
23
|
+
"""
|
|
24
|
+
|
|
25
|
+
name = "polymorphism"
|
|
26
|
+
description = "Find if blocks replaceable by polymorphism"
|
|
27
|
+
severity = "warning"
|
|
28
|
+
|
|
29
|
+
def __init__(self, options: dict[str, Any] | None = None):
|
|
30
|
+
super().__init__(options)
|
|
31
|
+
self.min_branches = self.options.get("min_branches", 3)
|
|
32
|
+
self.check_isinstance = self.options.get("check_isinstance", True)
|
|
33
|
+
self.check_type_attributes = self.options.get("check_type_attributes", True)
|
|
34
|
+
|
|
35
|
+
def analyze(
|
|
36
|
+
self,
|
|
37
|
+
tree: ast.Module,
|
|
38
|
+
source: str,
|
|
39
|
+
file_path: str,
|
|
40
|
+
) -> RuleResult:
|
|
41
|
+
"""Analyze the AST for polymorphism opportunities."""
|
|
42
|
+
visitor = PolymorphismVisitor(
|
|
43
|
+
file_path=file_path,
|
|
44
|
+
source=source,
|
|
45
|
+
min_branches=self.min_branches,
|
|
46
|
+
check_isinstance=self.check_isinstance,
|
|
47
|
+
check_type_attributes=self.check_type_attributes,
|
|
48
|
+
)
|
|
49
|
+
visitor.visit(tree)
|
|
50
|
+
|
|
51
|
+
return RuleResult(
|
|
52
|
+
rule_name=self.name,
|
|
53
|
+
violations=visitor.violations,
|
|
54
|
+
summary={
|
|
55
|
+
"total_opportunities": len(visitor.violations),
|
|
56
|
+
"isinstance_checks": visitor.isinstance_count,
|
|
57
|
+
"type_attribute_checks": visitor.type_attr_count,
|
|
58
|
+
"long_if_chains": visitor.long_chain_count,
|
|
59
|
+
},
|
|
60
|
+
metadata={
|
|
61
|
+
"patterns": visitor.patterns,
|
|
62
|
+
},
|
|
63
|
+
)
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
class PolymorphismVisitor(ast.NodeVisitor):
|
|
67
|
+
"""AST visitor that detects polymorphism opportunities."""
|
|
68
|
+
|
|
69
|
+
TYPE_ATTRIBUTES = {"type", "kind", "category", "variant", "mode", "status"}
|
|
70
|
+
|
|
71
|
+
def __init__(
|
|
72
|
+
self,
|
|
73
|
+
file_path: str,
|
|
74
|
+
source: str,
|
|
75
|
+
min_branches: int = 3,
|
|
76
|
+
check_isinstance: bool = True,
|
|
77
|
+
check_type_attributes: bool = True,
|
|
78
|
+
):
|
|
79
|
+
self.file_path = file_path
|
|
80
|
+
self.source = source
|
|
81
|
+
self.min_branches = min_branches
|
|
82
|
+
self.check_isinstance = check_isinstance
|
|
83
|
+
self.check_type_attributes = check_type_attributes
|
|
84
|
+
|
|
85
|
+
self.violations: list[RuleViolation] = []
|
|
86
|
+
self.patterns: list[dict[str, Any]] = []
|
|
87
|
+
self.isinstance_count = 0
|
|
88
|
+
self.type_attr_count = 0
|
|
89
|
+
self.long_chain_count = 0
|
|
90
|
+
|
|
91
|
+
self._current_function: str | None = None
|
|
92
|
+
self._current_class: str | None = None
|
|
93
|
+
|
|
94
|
+
def visit_ClassDef(self, node: ast.ClassDef) -> None:
|
|
95
|
+
"""Track class context."""
|
|
96
|
+
old_class = self._current_class
|
|
97
|
+
self._current_class = node.name
|
|
98
|
+
self.generic_visit(node)
|
|
99
|
+
self._current_class = old_class
|
|
100
|
+
|
|
101
|
+
def visit_FunctionDef(self, node: ast.FunctionDef) -> None:
|
|
102
|
+
"""Track function context."""
|
|
103
|
+
old_function = self._current_function
|
|
104
|
+
self._current_function = node.name
|
|
105
|
+
self.generic_visit(node)
|
|
106
|
+
self._current_function = old_function
|
|
107
|
+
|
|
108
|
+
def visit_AsyncFunctionDef(self, node: ast.AsyncFunctionDef) -> None:
|
|
109
|
+
"""Handle async functions."""
|
|
110
|
+
old_function = self._current_function
|
|
111
|
+
self._current_function = node.name
|
|
112
|
+
self.generic_visit(node)
|
|
113
|
+
self._current_function = old_function
|
|
114
|
+
|
|
115
|
+
def visit_If(self, node: ast.If) -> None:
|
|
116
|
+
"""Analyze if statements for polymorphism opportunities."""
|
|
117
|
+
# Count branches in this if/elif chain
|
|
118
|
+
branches = self._count_branches(node)
|
|
119
|
+
|
|
120
|
+
if branches >= self.min_branches:
|
|
121
|
+
# Check what kind of pattern this is
|
|
122
|
+
pattern_info = self._analyze_if_pattern(node)
|
|
123
|
+
|
|
124
|
+
if pattern_info:
|
|
125
|
+
self._add_violation(node, branches, pattern_info)
|
|
126
|
+
|
|
127
|
+
# Also check for isinstance in any if
|
|
128
|
+
if self.check_isinstance:
|
|
129
|
+
self._check_isinstance_pattern(node)
|
|
130
|
+
|
|
131
|
+
# Check for type attribute comparisons
|
|
132
|
+
if self.check_type_attributes:
|
|
133
|
+
self._check_type_attribute_pattern(node)
|
|
134
|
+
|
|
135
|
+
self.generic_visit(node)
|
|
136
|
+
|
|
137
|
+
def visit_Match(self, node: ast.Match) -> None:
|
|
138
|
+
"""Analyze match statements (Python 3.10+)."""
|
|
139
|
+
num_cases = len(node.cases)
|
|
140
|
+
|
|
141
|
+
if num_cases >= self.min_branches:
|
|
142
|
+
self._add_match_violation(node, num_cases)
|
|
143
|
+
|
|
144
|
+
self.generic_visit(node)
|
|
145
|
+
|
|
146
|
+
def _count_branches(self, node: ast.If) -> int:
|
|
147
|
+
"""Count the number of branches in an if/elif chain."""
|
|
148
|
+
count = 1 # The if itself
|
|
149
|
+
|
|
150
|
+
# Count elif branches
|
|
151
|
+
current = node
|
|
152
|
+
while current.orelse:
|
|
153
|
+
if len(current.orelse) == 1 and isinstance(current.orelse[0], ast.If):
|
|
154
|
+
count += 1
|
|
155
|
+
current = current.orelse[0]
|
|
156
|
+
else:
|
|
157
|
+
# Has an else clause
|
|
158
|
+
count += 1
|
|
159
|
+
break
|
|
160
|
+
|
|
161
|
+
return count
|
|
162
|
+
|
|
163
|
+
def _analyze_if_pattern(self, node: ast.If) -> dict[str, Any] | None:
|
|
164
|
+
"""Analyze what variable/pattern the if chain is checking."""
|
|
165
|
+
checked_vars: list[str] = []
|
|
166
|
+
|
|
167
|
+
current: ast.If | None = node
|
|
168
|
+
while current:
|
|
169
|
+
var = self._get_checked_variable(current.test)
|
|
170
|
+
if var:
|
|
171
|
+
checked_vars.append(var)
|
|
172
|
+
|
|
173
|
+
if current.orelse and len(current.orelse) == 1:
|
|
174
|
+
if isinstance(current.orelse[0], ast.If):
|
|
175
|
+
current = current.orelse[0]
|
|
176
|
+
else:
|
|
177
|
+
break
|
|
178
|
+
else:
|
|
179
|
+
break
|
|
180
|
+
|
|
181
|
+
if not checked_vars:
|
|
182
|
+
return None
|
|
183
|
+
|
|
184
|
+
# Check if all branches check the same variable
|
|
185
|
+
if len(set(checked_vars)) == 1:
|
|
186
|
+
return {
|
|
187
|
+
"type": "same_variable",
|
|
188
|
+
"variable": checked_vars[0],
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
# Check if most branches check the same variable
|
|
192
|
+
from collections import Counter
|
|
193
|
+
|
|
194
|
+
counter = Counter(checked_vars)
|
|
195
|
+
most_common = counter.most_common(1)[0]
|
|
196
|
+
if most_common[1] >= len(checked_vars) * 0.7:
|
|
197
|
+
return {
|
|
198
|
+
"type": "mostly_same_variable",
|
|
199
|
+
"variable": most_common[0],
|
|
200
|
+
"consistency": most_common[1] / len(checked_vars),
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
return None
|
|
204
|
+
|
|
205
|
+
def _get_checked_variable(self, test: ast.expr) -> str | None:
|
|
206
|
+
"""Extract the variable being checked in a condition."""
|
|
207
|
+
if isinstance(test, ast.Compare):
|
|
208
|
+
left = test.left
|
|
209
|
+
if isinstance(left, ast.Attribute):
|
|
210
|
+
return self._get_attribute_name(left)
|
|
211
|
+
elif isinstance(left, ast.Name):
|
|
212
|
+
return left.id
|
|
213
|
+
|
|
214
|
+
if isinstance(test, ast.Call):
|
|
215
|
+
if isinstance(test.func, ast.Name) and test.func.id == "isinstance":
|
|
216
|
+
if test.args and isinstance(test.args[0], ast.Name):
|
|
217
|
+
return test.args[0].id
|
|
218
|
+
|
|
219
|
+
if isinstance(test, ast.BoolOp):
|
|
220
|
+
# Check first value
|
|
221
|
+
if test.values:
|
|
222
|
+
return self._get_checked_variable(test.values[0])
|
|
223
|
+
|
|
224
|
+
return None
|
|
225
|
+
|
|
226
|
+
def _get_attribute_name(self, node: ast.Attribute) -> str:
|
|
227
|
+
"""Get the full attribute name (e.g., 'obj.type')."""
|
|
228
|
+
parts: list[str] = [node.attr]
|
|
229
|
+
current = node.value
|
|
230
|
+
|
|
231
|
+
while isinstance(current, ast.Attribute):
|
|
232
|
+
parts.append(current.attr)
|
|
233
|
+
current = current.value
|
|
234
|
+
|
|
235
|
+
if isinstance(current, ast.Name):
|
|
236
|
+
parts.append(current.id)
|
|
237
|
+
|
|
238
|
+
parts.reverse()
|
|
239
|
+
return ".".join(parts)
|
|
240
|
+
|
|
241
|
+
def _check_isinstance_pattern(self, node: ast.If) -> None:
|
|
242
|
+
"""Check for isinstance() usage in conditionals."""
|
|
243
|
+
if self._contains_isinstance(node.test):
|
|
244
|
+
self.isinstance_count += 1
|
|
245
|
+
self.violations.append(
|
|
246
|
+
RuleViolation(
|
|
247
|
+
rule_name="polymorphism",
|
|
248
|
+
message=(
|
|
249
|
+
"isinstance() check detected. This often indicates a need for polymorphism."
|
|
250
|
+
),
|
|
251
|
+
file_path=self.file_path,
|
|
252
|
+
line=node.lineno,
|
|
253
|
+
column=node.col_offset,
|
|
254
|
+
severity="warning",
|
|
255
|
+
suggestion=(
|
|
256
|
+
"Instead of checking types with isinstance(), consider "
|
|
257
|
+
"using polymorphism. Define a common interface/base class "
|
|
258
|
+
"and let each type implement its own behavior."
|
|
259
|
+
),
|
|
260
|
+
code_snippet=self._get_source_line(node.lineno),
|
|
261
|
+
metadata={
|
|
262
|
+
"pattern": "isinstance_check",
|
|
263
|
+
"function": self._current_function,
|
|
264
|
+
"class": self._current_class,
|
|
265
|
+
},
|
|
266
|
+
)
|
|
267
|
+
)
|
|
268
|
+
self.patterns.append(
|
|
269
|
+
{
|
|
270
|
+
"type": "isinstance",
|
|
271
|
+
"line": node.lineno,
|
|
272
|
+
}
|
|
273
|
+
)
|
|
274
|
+
|
|
275
|
+
def _contains_isinstance(self, node: ast.expr) -> bool:
|
|
276
|
+
"""Check if an expression contains isinstance()."""
|
|
277
|
+
if isinstance(node, ast.Call):
|
|
278
|
+
if isinstance(node.func, ast.Name) and node.func.id == "isinstance":
|
|
279
|
+
return True
|
|
280
|
+
|
|
281
|
+
if isinstance(node, ast.BoolOp):
|
|
282
|
+
return any(self._contains_isinstance(v) for v in node.values)
|
|
283
|
+
|
|
284
|
+
if isinstance(node, ast.UnaryOp):
|
|
285
|
+
return self._contains_isinstance(node.operand)
|
|
286
|
+
|
|
287
|
+
return False
|
|
288
|
+
|
|
289
|
+
def _check_type_attribute_pattern(self, node: ast.If) -> None:
|
|
290
|
+
"""Check for type/kind attribute comparisons."""
|
|
291
|
+
attr_name = self._get_type_attribute_check(node.test)
|
|
292
|
+
if attr_name:
|
|
293
|
+
self.type_attr_count += 1
|
|
294
|
+
self.violations.append(
|
|
295
|
+
RuleViolation(
|
|
296
|
+
rule_name="polymorphism",
|
|
297
|
+
message=(
|
|
298
|
+
f"Checking '{attr_name}' attribute suggests type-based branching. "
|
|
299
|
+
f"Consider using polymorphism instead."
|
|
300
|
+
),
|
|
301
|
+
file_path=self.file_path,
|
|
302
|
+
line=node.lineno,
|
|
303
|
+
column=node.col_offset,
|
|
304
|
+
severity="warning",
|
|
305
|
+
suggestion=(
|
|
306
|
+
f"Instead of checking the '{attr_name.split('.')[-1]}' attribute, "
|
|
307
|
+
f"consider using polymorphism. Create subclasses that implement "
|
|
308
|
+
f"the behavior directly."
|
|
309
|
+
),
|
|
310
|
+
code_snippet=self._get_source_line(node.lineno),
|
|
311
|
+
metadata={
|
|
312
|
+
"pattern": "type_attribute",
|
|
313
|
+
"attribute": attr_name,
|
|
314
|
+
"function": self._current_function,
|
|
315
|
+
"class": self._current_class,
|
|
316
|
+
},
|
|
317
|
+
)
|
|
318
|
+
)
|
|
319
|
+
self.patterns.append(
|
|
320
|
+
{
|
|
321
|
+
"type": "type_attribute",
|
|
322
|
+
"attribute": attr_name,
|
|
323
|
+
"line": node.lineno,
|
|
324
|
+
}
|
|
325
|
+
)
|
|
326
|
+
|
|
327
|
+
def _get_type_attribute_check(self, node: ast.expr) -> str | None:
|
|
328
|
+
"""Check if comparing a type-like attribute."""
|
|
329
|
+
if isinstance(node, ast.Compare):
|
|
330
|
+
left = node.left
|
|
331
|
+
if isinstance(left, ast.Attribute):
|
|
332
|
+
if left.attr.lower() in self.TYPE_ATTRIBUTES:
|
|
333
|
+
return self._get_attribute_name(left)
|
|
334
|
+
|
|
335
|
+
if isinstance(node, ast.BoolOp):
|
|
336
|
+
for value in node.values:
|
|
337
|
+
result = self._get_type_attribute_check(value)
|
|
338
|
+
if result:
|
|
339
|
+
return result
|
|
340
|
+
|
|
341
|
+
return None
|
|
342
|
+
|
|
343
|
+
def _add_violation(
|
|
344
|
+
self,
|
|
345
|
+
node: ast.If,
|
|
346
|
+
branches: int,
|
|
347
|
+
pattern_info: dict[str, Any],
|
|
348
|
+
) -> None:
|
|
349
|
+
"""Add a violation for a long if/elif chain."""
|
|
350
|
+
self.long_chain_count += 1
|
|
351
|
+
|
|
352
|
+
variable = pattern_info.get("variable", "unknown")
|
|
353
|
+
|
|
354
|
+
self.violations.append(
|
|
355
|
+
RuleViolation(
|
|
356
|
+
rule_name="polymorphism",
|
|
357
|
+
message=(
|
|
358
|
+
f"Long if/elif chain with {branches} branches checking '{variable}'. "
|
|
359
|
+
f"Consider replacing with polymorphism."
|
|
360
|
+
),
|
|
361
|
+
file_path=self.file_path,
|
|
362
|
+
line=node.lineno,
|
|
363
|
+
column=node.col_offset,
|
|
364
|
+
severity="warning",
|
|
365
|
+
suggestion=(
|
|
366
|
+
f"This if/elif chain could be replaced with polymorphism. "
|
|
367
|
+
f"Consider using Strategy pattern, State pattern, or simple "
|
|
368
|
+
f"method dispatch based on the value of '{variable}'."
|
|
369
|
+
),
|
|
370
|
+
code_snippet=self._get_source_line(node.lineno),
|
|
371
|
+
metadata={
|
|
372
|
+
"pattern": "long_if_chain",
|
|
373
|
+
"branches": branches,
|
|
374
|
+
"checked_variable": variable,
|
|
375
|
+
"function": self._current_function,
|
|
376
|
+
"class": self._current_class,
|
|
377
|
+
},
|
|
378
|
+
)
|
|
379
|
+
)
|
|
380
|
+
self.patterns.append(
|
|
381
|
+
{
|
|
382
|
+
"type": "long_if_chain",
|
|
383
|
+
"branches": branches,
|
|
384
|
+
"variable": variable,
|
|
385
|
+
"line": node.lineno,
|
|
386
|
+
}
|
|
387
|
+
)
|
|
388
|
+
|
|
389
|
+
def _add_match_violation(self, node: ast.Match, num_cases: int) -> None:
|
|
390
|
+
"""Add a violation for match statement."""
|
|
391
|
+
self.violations.append(
|
|
392
|
+
RuleViolation(
|
|
393
|
+
rule_name="polymorphism",
|
|
394
|
+
message=(
|
|
395
|
+
f"Match statement with {num_cases} cases. "
|
|
396
|
+
f"Consider if polymorphism would be more appropriate."
|
|
397
|
+
),
|
|
398
|
+
file_path=self.file_path,
|
|
399
|
+
line=node.lineno,
|
|
400
|
+
column=node.col_offset,
|
|
401
|
+
severity="info",
|
|
402
|
+
suggestion=(
|
|
403
|
+
"While match statements are useful, many cases might indicate "
|
|
404
|
+
"an opportunity for polymorphism where each case becomes a class."
|
|
405
|
+
),
|
|
406
|
+
code_snippet=self._get_source_line(node.lineno),
|
|
407
|
+
metadata={
|
|
408
|
+
"pattern": "match_statement",
|
|
409
|
+
"cases": num_cases,
|
|
410
|
+
"function": self._current_function,
|
|
411
|
+
"class": self._current_class,
|
|
412
|
+
},
|
|
413
|
+
)
|
|
414
|
+
)
|
|
415
|
+
self.patterns.append(
|
|
416
|
+
{
|
|
417
|
+
"type": "match_statement",
|
|
418
|
+
"cases": num_cases,
|
|
419
|
+
"line": node.lineno,
|
|
420
|
+
}
|
|
421
|
+
)
|
|
422
|
+
|
|
423
|
+
def _get_source_line(self, line_number: int) -> str:
|
|
424
|
+
"""Get a specific line from the source code."""
|
|
425
|
+
lines = self.source.splitlines()
|
|
426
|
+
if 1 <= line_number <= len(lines):
|
|
427
|
+
return lines[line_number - 1].strip()
|
|
428
|
+
return ""
|