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.
@@ -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 ""