inspectr 0.0.5__py3-none-any.whl → 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.
- inspectr/__main__.py +28 -3
- inspectr/authenticity.py +19 -4
- inspectr/bare_ratio.py +10 -1
- inspectr/compare_funcs.py +118 -0
- inspectr/complexity.py +738 -0
- inspectr/count_exceptions.py +10 -1
- inspectr/duplicates.py +188 -21
- inspectr/size_counts.py +10 -1
- inspectr/with_open.py +10 -1
- {inspectr-0.0.5.dist-info → inspectr-0.1.0.dist-info}/METADATA +38 -3
- inspectr-0.1.0.dist-info/RECORD +16 -0
- inspectr-0.0.5.dist-info/RECORD +0 -14
- {inspectr-0.0.5.dist-info → inspectr-0.1.0.dist-info}/WHEEL +0 -0
- {inspectr-0.0.5.dist-info → inspectr-0.1.0.dist-info}/entry_points.txt +0 -0
- {inspectr-0.0.5.dist-info → inspectr-0.1.0.dist-info}/licenses/LICENSE +0 -0
- {inspectr-0.0.5.dist-info → inspectr-0.1.0.dist-info}/top_level.txt +0 -0
inspectr/complexity.py
ADDED
@@ -0,0 +1,738 @@
|
|
1
|
+
#!/usr/bin/env python3
|
2
|
+
import ast
|
3
|
+
import sys
|
4
|
+
from dataclasses import dataclass, field
|
5
|
+
from collections import defaultdict
|
6
|
+
from typing import List, Dict, Optional, Any
|
7
|
+
from pathlib import Path
|
8
|
+
from colorama import init, Fore, Style
|
9
|
+
|
10
|
+
# initialize colorama for cross-platform colored output
|
11
|
+
init()
|
12
|
+
|
13
|
+
@dataclass
|
14
|
+
class Complexity:
|
15
|
+
expression: str
|
16
|
+
is_approximate: bool = False
|
17
|
+
details: List[str] = field(default_factory=list)
|
18
|
+
|
19
|
+
@classmethod
|
20
|
+
def constant(cls):
|
21
|
+
return cls("O(1)", False, [])
|
22
|
+
|
23
|
+
@classmethod
|
24
|
+
def linear(cls, coefficient: int = 1):
|
25
|
+
expr = f"O({coefficient}n)" if coefficient != 1 else "O(n)"
|
26
|
+
return cls(expr, False, [])
|
27
|
+
|
28
|
+
@classmethod
|
29
|
+
def approximate(cls, expr: str):
|
30
|
+
return cls(expr, True, [])
|
31
|
+
|
32
|
+
def with_detail(self, detail: str):
|
33
|
+
self.details.append(detail)
|
34
|
+
return self
|
35
|
+
|
36
|
+
def combine_sequential(self, other: 'Complexity') -> 'Complexity':
|
37
|
+
"""For sequential operations, we add complexities"""
|
38
|
+
is_approx = self.is_approximate or other.is_approximate
|
39
|
+
details = self.details + other.details
|
40
|
+
|
41
|
+
# don't add O(1) unless both are O(1)
|
42
|
+
if self.expression == "O(1)" and other.expression == "O(1)":
|
43
|
+
expr = "O(2)"
|
44
|
+
elif self.expression == "O(1)":
|
45
|
+
expr = other.expression
|
46
|
+
elif other.expression == "O(1)":
|
47
|
+
expr = self.expression
|
48
|
+
elif self.expression == other.expression:
|
49
|
+
# Same complexity - combine coefficients
|
50
|
+
if self.expression == "O(n)":
|
51
|
+
expr = "O(2n)"
|
52
|
+
elif "O(" in self.expression and "n)" in self.expression:
|
53
|
+
# Extract coefficient
|
54
|
+
import re
|
55
|
+
match = re.match(r'O\((\d*)n\)', self.expression)
|
56
|
+
if match:
|
57
|
+
coef = int(match.group(1)) if match.group(1) else 1
|
58
|
+
expr = f"O({coef*2}n)"
|
59
|
+
else:
|
60
|
+
expr = f"{self.expression}+{other.expression}"
|
61
|
+
else:
|
62
|
+
expr = f"{self.expression}+{other.expression}"
|
63
|
+
elif "O(" in self.expression and "O(" in other.expression:
|
64
|
+
# try to combine similar terms
|
65
|
+
expr = self._combine_similar_terms(self.expression, other.expression)
|
66
|
+
if not expr:
|
67
|
+
expr = f"{self.expression}+{other.expression}"
|
68
|
+
else:
|
69
|
+
expr = f"{self.expression}+{other.expression}"
|
70
|
+
|
71
|
+
return Complexity(expr, is_approx, details)
|
72
|
+
|
73
|
+
def combine_nested(self, other: 'Complexity') -> 'Complexity':
|
74
|
+
"""For nested operations, we multiply complexities"""
|
75
|
+
is_approx = self.is_approximate or other.is_approximate
|
76
|
+
details = self.details + other.details
|
77
|
+
|
78
|
+
if self.expression == "O(1)":
|
79
|
+
expr = other.expression
|
80
|
+
elif other.expression == "O(1)":
|
81
|
+
expr = self.expression
|
82
|
+
else:
|
83
|
+
expr = f"{self.expression}*{other.expression}"
|
84
|
+
|
85
|
+
return Complexity(expr, is_approx, details)
|
86
|
+
|
87
|
+
def max(self, other: 'Complexity') -> 'Complexity':
|
88
|
+
"""Return the maximum complexity (for if/else branches)"""
|
89
|
+
# for branches, we should take the worse case
|
90
|
+
# this is a simplified comparison
|
91
|
+
self_weight = self._get_weight()
|
92
|
+
other_weight = other._get_weight()
|
93
|
+
|
94
|
+
if self_weight >= other_weight:
|
95
|
+
return self
|
96
|
+
else:
|
97
|
+
return other
|
98
|
+
|
99
|
+
def _combine_similar_terms(self, expr1: str, expr2: str) -> Optional[str]:
|
100
|
+
"""Combine similar complexity terms with coefficients"""
|
101
|
+
import re
|
102
|
+
|
103
|
+
# parse expressions into a dict of complexity -> coefficient
|
104
|
+
terms = defaultdict(int)
|
105
|
+
|
106
|
+
for expr in [expr1, expr2]:
|
107
|
+
# split by + if present
|
108
|
+
parts = expr.split('+') if '+' in expr else [expr]
|
109
|
+
for part in parts:
|
110
|
+
part = part.strip()
|
111
|
+
if part == "O(1)":
|
112
|
+
terms["1"] += 1
|
113
|
+
elif part == "O(n)":
|
114
|
+
terms["n"] += 1
|
115
|
+
elif match := re.match(r'O\((\d+)\)', part):
|
116
|
+
terms["1"] += int(match.group(1))
|
117
|
+
elif match := re.match(r'O\((\d*)n\)', part):
|
118
|
+
coef = int(match.group(1)) if match.group(1) else 1
|
119
|
+
terms["n"] += coef
|
120
|
+
elif "n*log(n)" in part:
|
121
|
+
terms["n*log(n)"] += 1
|
122
|
+
elif "n²" in part:
|
123
|
+
terms["n²"] += 1
|
124
|
+
elif "n³" in part:
|
125
|
+
terms["n³"] += 1
|
126
|
+
else:
|
127
|
+
return None # can't parse, return None
|
128
|
+
|
129
|
+
# build result
|
130
|
+
result_parts = []
|
131
|
+
for complexity in ["n³", "n²", "n*log(n)", "n", "1"]:
|
132
|
+
if complexity in terms and terms[complexity] > 0:
|
133
|
+
coef = terms[complexity]
|
134
|
+
result_parts.append(f"O({coef if coef > 1 else ''}{complexity})")
|
135
|
+
|
136
|
+
return "+".join(result_parts) if result_parts else None
|
137
|
+
|
138
|
+
|
139
|
+
def _get_weight(self) -> int:
|
140
|
+
"""Get a rough weight for complexity comparison"""
|
141
|
+
expr = self.expression
|
142
|
+
if 'n⁴' in expr or 'n^4' in expr:
|
143
|
+
return 4
|
144
|
+
elif 'n³' in expr or 'n^3' in expr:
|
145
|
+
return 3
|
146
|
+
elif 'n²' in expr or 'n^2' in expr:
|
147
|
+
return 2
|
148
|
+
elif 'n*log' in expr:
|
149
|
+
return 1.5
|
150
|
+
elif 'n' in expr:
|
151
|
+
return 1
|
152
|
+
elif 'log' in expr:
|
153
|
+
return 0.5
|
154
|
+
else:
|
155
|
+
return 0
|
156
|
+
|
157
|
+
def simplify(self) -> 'Complexity':
|
158
|
+
"""Simplify the complexity expression WITHOUT reducing to dominant term"""
|
159
|
+
expr = self.expression
|
160
|
+
|
161
|
+
# first handle nested multiplications (convert to exponents)
|
162
|
+
if '*' in expr:
|
163
|
+
expr = self._simplify_multiplications(expr)
|
164
|
+
|
165
|
+
# then combine similar terms in additions
|
166
|
+
if '+' in expr:
|
167
|
+
expr = self._combine_additions(expr)
|
168
|
+
|
169
|
+
# clean up constants
|
170
|
+
if expr == "O(0)" or not expr:
|
171
|
+
expr = "O(1)"
|
172
|
+
|
173
|
+
return Complexity(expr, self.is_approximate, self.details)
|
174
|
+
|
175
|
+
def _simplify_multiplications(self, expr: str) -> str:
|
176
|
+
"""Simplify multiplication expressions"""
|
177
|
+
import re
|
178
|
+
|
179
|
+
# handle O(n²)*O(n²) -> O(n⁴) etc
|
180
|
+
if "O(n²)*O(n²)" in expr:
|
181
|
+
expr = expr.replace("O(n²)*O(n²)", "O(n⁴)")
|
182
|
+
if "O(n³)*O(n)" in expr or "O(n)*O(n³)" in expr:
|
183
|
+
expr = expr.replace("O(n³)*O(n)", "O(n⁴)")
|
184
|
+
expr = expr.replace("O(n)*O(n³)", "O(n⁴)")
|
185
|
+
if "O(n²)*O(n)" in expr or "O(n)*O(n²)" in expr:
|
186
|
+
expr = expr.replace("O(n²)*O(n)", "O(n³)")
|
187
|
+
expr = expr.replace("O(n)*O(n²)", "O(n³)")
|
188
|
+
|
189
|
+
# count O(n) multiplications
|
190
|
+
n_count = expr.count("O(n)*O(n)")
|
191
|
+
if n_count > 0:
|
192
|
+
power_notation = {1: "²", 2: "⁴"}
|
193
|
+
if n_count in power_notation:
|
194
|
+
expr = expr.replace("O(n)*O(n)", f"O(n{power_notation[n_count]})", 1)
|
195
|
+
|
196
|
+
# count individual O(n) terms being multiplied
|
197
|
+
parts = []
|
198
|
+
current = ""
|
199
|
+
depth = 0
|
200
|
+
|
201
|
+
for char in expr:
|
202
|
+
if char == '(':
|
203
|
+
depth += 1
|
204
|
+
current += char
|
205
|
+
elif char == ')':
|
206
|
+
depth -= 1
|
207
|
+
current += char
|
208
|
+
elif char == '*' and depth == 0:
|
209
|
+
parts.append(current)
|
210
|
+
current = ""
|
211
|
+
else:
|
212
|
+
current += char
|
213
|
+
|
214
|
+
if current:
|
215
|
+
parts.append(current)
|
216
|
+
|
217
|
+
# count O(n) occurrences
|
218
|
+
n_count = sum(1 for p in parts if p == "O(n)")
|
219
|
+
other_parts = [p for p in parts if p != "O(n)" and p != "O(1)"]
|
220
|
+
|
221
|
+
if n_count >= 2:
|
222
|
+
power_notation = {2: "²", 3: "³", 4: "⁴"}
|
223
|
+
if n_count in power_notation:
|
224
|
+
result = f"O(n{power_notation[n_count]})"
|
225
|
+
else:
|
226
|
+
result = f"O(n^{n_count})"
|
227
|
+
# add other multiplicative factors
|
228
|
+
for part in other_parts:
|
229
|
+
if "log" in part:
|
230
|
+
result = result.replace(")", "*log(n))")
|
231
|
+
elif part and part != "O(1)":
|
232
|
+
result = f"{result}*{part}"
|
233
|
+
|
234
|
+
expr = result
|
235
|
+
elif n_count == 1:
|
236
|
+
# single O(n) with other factors
|
237
|
+
result = "O(n)"
|
238
|
+
for part in other_parts:
|
239
|
+
if "log" in part:
|
240
|
+
result = "O(n*log(n))"
|
241
|
+
elif part and part != "O(1)":
|
242
|
+
result = f"{result}*{part}"
|
243
|
+
expr = result
|
244
|
+
|
245
|
+
return expr
|
246
|
+
|
247
|
+
def _combine_additions(self, expr: str) -> str:
|
248
|
+
"""Combine similar terms in additions"""
|
249
|
+
import re
|
250
|
+
|
251
|
+
parts = expr.split('+')
|
252
|
+
terms = defaultdict(int)
|
253
|
+
|
254
|
+
for part in parts:
|
255
|
+
part = part.strip()
|
256
|
+
if part == "O(1)":
|
257
|
+
terms["1"] += 1
|
258
|
+
elif match := re.match(r'O\((\d+)\)', part):
|
259
|
+
terms["1"] += int(match.group(1))
|
260
|
+
elif part == "O(n)":
|
261
|
+
terms["n"] += 1
|
262
|
+
elif match := re.match(r'O\((\d+)n\)', part):
|
263
|
+
terms["n"] += int(match.group(1))
|
264
|
+
elif "n*log(n)" in part:
|
265
|
+
terms["n*log(n)"] += 1
|
266
|
+
elif "log(n)" in part and "n*" not in part:
|
267
|
+
terms["log(n)"] += 1
|
268
|
+
elif "n²" in part or "n^2" in part:
|
269
|
+
terms["n²"] += 1
|
270
|
+
elif "n³" in part or "n^3" in part:
|
271
|
+
terms["n³"] += 1
|
272
|
+
elif "n⁴" in part or "n^4" in part:
|
273
|
+
terms["n⁴"] += 1
|
274
|
+
else:
|
275
|
+
# unknown term, keep as-is
|
276
|
+
if part and part != "O(1)":
|
277
|
+
terms[part] = 1
|
278
|
+
|
279
|
+
# build result with proper ordering
|
280
|
+
result_parts = []
|
281
|
+
order = ["n⁴", "n³", "n²", "n*log(n)", "n", "log(n)", "1"]
|
282
|
+
|
283
|
+
for complexity in order:
|
284
|
+
if complexity in terms and terms[complexity] > 0:
|
285
|
+
count = terms[complexity]
|
286
|
+
if complexity == "1":
|
287
|
+
if count == 1:
|
288
|
+
result_parts.append("O(1)")
|
289
|
+
result_parts.append(f"O({count})")
|
290
|
+
elif complexity == "n":
|
291
|
+
if count == 1:
|
292
|
+
result_parts.append("O(n)")
|
293
|
+
else:
|
294
|
+
result_parts.append(f"O({count}n)")
|
295
|
+
else:
|
296
|
+
if count == 1:
|
297
|
+
result_parts.append(f"O({complexity})")
|
298
|
+
else:
|
299
|
+
# for higher order terms, we don't usually show coefficient
|
300
|
+
# but we could if needed
|
301
|
+
result_parts.append(f"O({complexity})")
|
302
|
+
|
303
|
+
# add any unknown terms
|
304
|
+
for term, count in terms.items():
|
305
|
+
if term not in order and count > 0:
|
306
|
+
result_parts.append(term)
|
307
|
+
|
308
|
+
# remove O(1) terms if there are higher order terms
|
309
|
+
if len(result_parts) > 1:
|
310
|
+
result_parts = [p for p in result_parts if not (p == "O(1)" or re.match(r'O\(\d+\)', p))]
|
311
|
+
# actually, keep constant terms as per requirement
|
312
|
+
# revert this filtering
|
313
|
+
result_parts = []
|
314
|
+
for complexity in order:
|
315
|
+
if complexity in terms and terms[complexity] > 0:
|
316
|
+
count = terms[complexity]
|
317
|
+
if complexity == "1":
|
318
|
+
if count == 1:
|
319
|
+
result_parts.append("1")
|
320
|
+
else:
|
321
|
+
result_parts.append(str(count))
|
322
|
+
elif complexity == "n":
|
323
|
+
if count == 1:
|
324
|
+
result_parts.append("n")
|
325
|
+
else:
|
326
|
+
result_parts.append(f"{count}n")
|
327
|
+
else:
|
328
|
+
if count == 1:
|
329
|
+
result_parts.append(complexity)
|
330
|
+
else:
|
331
|
+
# for polynomial terms, coefficient usually not shown
|
332
|
+
result_parts.append(complexity)
|
333
|
+
|
334
|
+
if result_parts:
|
335
|
+
return "O(" + " + ".join(result_parts) + ")"
|
336
|
+
else:
|
337
|
+
return "O(1)"
|
338
|
+
|
339
|
+
@dataclass
|
340
|
+
class AntiPattern:
|
341
|
+
line: int
|
342
|
+
pattern_type: str
|
343
|
+
description: str
|
344
|
+
|
345
|
+
@dataclass
|
346
|
+
class FunctionAnalysis:
|
347
|
+
name: str
|
348
|
+
complexity: Complexity
|
349
|
+
anti_patterns: List[AntiPattern]
|
350
|
+
|
351
|
+
class Analyzer(ast.NodeVisitor):
|
352
|
+
def __init__(self):
|
353
|
+
self.functions: Dict[str, Complexity] = {}
|
354
|
+
self.variable_types: Dict[str, str] = {}
|
355
|
+
self.call_stack: List[str] = [] # Track call stack for recursion
|
356
|
+
self.max_call_depth = 3
|
357
|
+
self.current_class = None
|
358
|
+
self.results: List[FunctionAnalysis] = []
|
359
|
+
self.anti_patterns: List[AntiPattern] = []
|
360
|
+
|
361
|
+
def analyze_file(self, content: str, filename: str = "<file>") -> List[FunctionAnalysis]:
|
362
|
+
"""Analyze Python file content"""
|
363
|
+
try:
|
364
|
+
tree = ast.parse(content, filename)
|
365
|
+
except SyntaxError as e:
|
366
|
+
raise ValueError(f"Parse error: {e}")
|
367
|
+
|
368
|
+
self.visit(tree)
|
369
|
+
return self.results
|
370
|
+
|
371
|
+
def visit_ClassDef(self, node: ast.ClassDef):
|
372
|
+
"""Visit class definition"""
|
373
|
+
old_class = self.current_class
|
374
|
+
self.current_class = node.name
|
375
|
+
self.generic_visit(node)
|
376
|
+
self.current_class = old_class
|
377
|
+
|
378
|
+
def visit_FunctionDef(self, node: ast.FunctionDef):
|
379
|
+
"""Visit function definition"""
|
380
|
+
self.anti_patterns = []
|
381
|
+
self.variable_types.clear()
|
382
|
+
self.call_stack = [] # reset call stack for each function
|
383
|
+
|
384
|
+
# extract type annotations from parameters
|
385
|
+
for arg in node.args.args:
|
386
|
+
if arg.annotation:
|
387
|
+
if isinstance(arg.annotation, ast.Subscript):
|
388
|
+
# handle List[int], Dict[str, int], etc.
|
389
|
+
if isinstance(arg.annotation.value, ast.Name):
|
390
|
+
self.variable_types[arg.arg] = arg.annotation.value.id
|
391
|
+
elif isinstance(arg.annotation, ast.Name):
|
392
|
+
self.variable_types[arg.arg] = arg.annotation.id
|
393
|
+
|
394
|
+
# analyze function body
|
395
|
+
complexity = self.analyze_body(node.body)
|
396
|
+
|
397
|
+
# store function name with class prefix if in a class
|
398
|
+
if self.current_class:
|
399
|
+
func_name = f"{self.current_class}.{node.name}"
|
400
|
+
else:
|
401
|
+
func_name = node.name
|
402
|
+
|
403
|
+
self.functions[func_name] = complexity
|
404
|
+
|
405
|
+
analysis = FunctionAnalysis(
|
406
|
+
name=func_name,
|
407
|
+
complexity=complexity,
|
408
|
+
anti_patterns=self.anti_patterns.copy()
|
409
|
+
)
|
410
|
+
self.results.append(analysis)
|
411
|
+
|
412
|
+
# don't visit nested functions
|
413
|
+
return
|
414
|
+
|
415
|
+
visit_AsyncFunctionDef = visit_FunctionDef
|
416
|
+
|
417
|
+
def analyze_body(self, body: List[ast.stmt]) -> Complexity:
|
418
|
+
"""Analyze a list of statements"""
|
419
|
+
total = Complexity.constant()
|
420
|
+
|
421
|
+
# Track variable assignments for type inference
|
422
|
+
for stmt in body:
|
423
|
+
if isinstance(stmt, ast.Assign):
|
424
|
+
if isinstance(stmt.value, ast.Call):
|
425
|
+
if isinstance(stmt.value.func, ast.Name):
|
426
|
+
if stmt.value.func.id == 'set':
|
427
|
+
# Variable is assigned a set
|
428
|
+
for target in stmt.targets:
|
429
|
+
if isinstance(target, ast.Name):
|
430
|
+
self.variable_types[target.id] = 'set'
|
431
|
+
elif stmt.value.func.id == 'list':
|
432
|
+
for target in stmt.targets:
|
433
|
+
if isinstance(target, ast.Name):
|
434
|
+
self.variable_types[target.id] = 'list'
|
435
|
+
|
436
|
+
|
437
|
+
for stmt in body:
|
438
|
+
stmt_complexity = self.analyze_statement(stmt)
|
439
|
+
total = total.combine_sequential(stmt_complexity)
|
440
|
+
|
441
|
+
return total.simplify()
|
442
|
+
|
443
|
+
def analyze_statement(self, stmt: ast.stmt) -> Complexity:
|
444
|
+
"""Analyze a single statement"""
|
445
|
+
if isinstance(stmt, ast.For):
|
446
|
+
return self.analyze_for_loop(stmt)
|
447
|
+
elif isinstance(stmt, ast.While):
|
448
|
+
return self.analyze_while_loop(stmt)
|
449
|
+
elif isinstance(stmt, ast.If):
|
450
|
+
return self.analyze_if(stmt)
|
451
|
+
elif isinstance(stmt, (ast.Return, ast.Expr)):
|
452
|
+
if isinstance(stmt, ast.Return) and stmt.value:
|
453
|
+
return self.analyze_expr(stmt.value)
|
454
|
+
elif isinstance(stmt, ast.Expr):
|
455
|
+
return self.analyze_expr(stmt.value)
|
456
|
+
return Complexity.constant()
|
457
|
+
elif isinstance(stmt, (ast.Assign, ast.AugAssign, ast.AnnAssign)):
|
458
|
+
if isinstance(stmt, ast.Assign):
|
459
|
+
return self.analyze_expr(stmt.value)
|
460
|
+
elif isinstance(stmt, ast.AugAssign):
|
461
|
+
return self.analyze_expr(stmt.value)
|
462
|
+
elif isinstance(stmt, ast.AnnAssign) and stmt.value:
|
463
|
+
return self.analyze_expr(stmt.value)
|
464
|
+
return Complexity.constant()
|
465
|
+
else:
|
466
|
+
return Complexity.constant()
|
467
|
+
|
468
|
+
def analyze_for_loop(self, node: ast.For) -> Complexity:
|
469
|
+
"""Analyze for loop complexity"""
|
470
|
+
iter_complexity = self.estimate_iteration_count(node.iter)
|
471
|
+
body_complexity = self.analyze_body(node.body)
|
472
|
+
|
473
|
+
combined = iter_complexity.combine_nested(body_complexity)
|
474
|
+
return combined.with_detail("for loop").simplify()
|
475
|
+
|
476
|
+
def analyze_while_loop(self, node: ast.While) -> Complexity:
|
477
|
+
"""Analyze while loop complexity"""
|
478
|
+
body_complexity = self.analyze_body(node.body)
|
479
|
+
|
480
|
+
# while loops have unknown iterations in static analysis
|
481
|
+
if body_complexity.expression == "O(1)":
|
482
|
+
result = Complexity.linear(1)
|
483
|
+
else:
|
484
|
+
result = body_complexity.combine_nested(Complexity.linear(1))
|
485
|
+
result.is_approximate = True
|
486
|
+
|
487
|
+
return result.with_detail("while loop (unknown iterations)").simplify()
|
488
|
+
|
489
|
+
def analyze_if(self, node: ast.If) -> Complexity:
|
490
|
+
"""Analyze if statement - take max of branches"""
|
491
|
+
if_body = self.analyze_body(node.body)
|
492
|
+
else_body = self.analyze_body(node.orelse)
|
493
|
+
return if_body.max(else_body)
|
494
|
+
|
495
|
+
def estimate_iteration_count(self, iter_node: ast.expr) -> Complexity:
|
496
|
+
"""Estimate iteration count for loop iterator"""
|
497
|
+
if isinstance(iter_node, ast.Call):
|
498
|
+
if isinstance(iter_node.func, ast.Name):
|
499
|
+
if iter_node.func.id == 'range':
|
500
|
+
return Complexity.linear(1)
|
501
|
+
elif iter_node.func.id in ('enumerate', 'zip'):
|
502
|
+
# these iterate over their arguments
|
503
|
+
if iter_node.args:
|
504
|
+
return self.estimate_iteration_count(iter_node.args[0])
|
505
|
+
return Complexity.linear(1)
|
506
|
+
return Complexity.approximate("O(n)")
|
507
|
+
elif isinstance(iter_node, (ast.Name, ast.Attribute)):
|
508
|
+
return Complexity.linear(1)
|
509
|
+
elif isinstance(iter_node, (ast.List, ast.Tuple)):
|
510
|
+
# literal list/tuple - count elements
|
511
|
+
return Complexity.linear(1)
|
512
|
+
else:
|
513
|
+
return Complexity.approximate("O(n)")
|
514
|
+
|
515
|
+
def analyze_expr(self, expr: ast.expr) -> Complexity:
|
516
|
+
"""Analyze expression complexity"""
|
517
|
+
if isinstance(expr, (ast.ListComp, ast.SetComp, ast.DictComp, ast.GeneratorExp)):
|
518
|
+
return self.analyze_comprehension(expr)
|
519
|
+
elif isinstance(expr, ast.Call):
|
520
|
+
return self.analyze_call(expr)
|
521
|
+
elif isinstance(expr, ast.Compare):
|
522
|
+
return self.analyze_compare(expr)
|
523
|
+
elif isinstance(expr, ast.Lambda):
|
524
|
+
# analyze lambda body as expression
|
525
|
+
return self.analyze_expr(expr.body)
|
526
|
+
elif isinstance(expr, ast.List):
|
527
|
+
total = Complexity.constant()
|
528
|
+
for el in expr.elts:
|
529
|
+
total = total.combine_sequential(self.analyze_expr(el))
|
530
|
+
return total
|
531
|
+
else:
|
532
|
+
return Complexity.constant()
|
533
|
+
|
534
|
+
def analyze_comprehension(self, node) -> Complexity:
|
535
|
+
"""Analyze list/set/dict comprehension or generator expression"""
|
536
|
+
complexity = Complexity.constant()
|
537
|
+
|
538
|
+
for generator in node.generators:
|
539
|
+
iter_complexity = self.estimate_iteration_count(generator.iter)
|
540
|
+
complexity = complexity.combine_nested(iter_complexity)
|
541
|
+
|
542
|
+
# check for anti-pattern (using list comp where generator would work)
|
543
|
+
if isinstance(node, ast.ListComp):
|
544
|
+
self.anti_patterns.append(AntiPattern(
|
545
|
+
line=node.lineno,
|
546
|
+
pattern_type="list_comprehension",
|
547
|
+
description="List comprehension could potentially be replaced with generator expression for memory efficiency"
|
548
|
+
))
|
549
|
+
|
550
|
+
return complexity.with_detail("comprehension").simplify()
|
551
|
+
|
552
|
+
def analyze_call(self, node: ast.Call) -> Complexity:
|
553
|
+
"""Analyze function call complexity"""
|
554
|
+
if isinstance(node.func, ast.Name):
|
555
|
+
func_name = node.func.id
|
556
|
+
|
557
|
+
# built-in functions with known complexity
|
558
|
+
complexity_map = {
|
559
|
+
'len': Complexity.constant(),
|
560
|
+
'print': Complexity.constant(),
|
561
|
+
'sum': Complexity.linear(1),
|
562
|
+
'max': Complexity.linear(1),
|
563
|
+
'min': Complexity.linear(1),
|
564
|
+
'sorted': Complexity("O(n*log(n))", False),
|
565
|
+
'reversed': Complexity.linear(1),
|
566
|
+
'enumerate': Complexity.constant(),
|
567
|
+
'zip': Complexity.constant(),
|
568
|
+
'map': Complexity.constant(),
|
569
|
+
'filter': Complexity.constant(),
|
570
|
+
'list': Complexity.linear(1) if node.args else Complexity.constant(),
|
571
|
+
'set': Complexity.linear(1) if node.args else Complexity.constant(),
|
572
|
+
'dict': Complexity.constant(),
|
573
|
+
'all': Complexity.linear(1),
|
574
|
+
'any': Complexity.linear(1),
|
575
|
+
}
|
576
|
+
|
577
|
+
if func_name in complexity_map:
|
578
|
+
complexity = complexity_map[func_name]
|
579
|
+
elif func_name in self.functions:
|
580
|
+
# check recursion depth
|
581
|
+
if func_name in self.call_stack:
|
582
|
+
# already in call stack - check depth
|
583
|
+
depth = self.call_stack.count(func_name)
|
584
|
+
if depth >= self.max_call_depth:
|
585
|
+
known_complexity = self.functions.get(func_name, Complexity.approximate("O(?)"))
|
586
|
+
complexity = Complexity.approximate(f"at least {known_complexity.expression}")
|
587
|
+
complexity.with_detail(f"(maximum recursion depth {self.max_call_depth} reached)")
|
588
|
+
else:
|
589
|
+
# allow the recursive call but track it
|
590
|
+
self.call_stack.append(func_name)
|
591
|
+
complexity = self.functions[func_name]
|
592
|
+
self.call_stack.pop()
|
593
|
+
else:
|
594
|
+
# first call to this function
|
595
|
+
self.call_stack.append(func_name)
|
596
|
+
complexity = self.functions[func_name]
|
597
|
+
self.call_stack.pop()
|
598
|
+
else:
|
599
|
+
complexity = Complexity.approximate("O(?)")
|
600
|
+
complexity.with_detail(f"unknown function: {func_name}")
|
601
|
+
|
602
|
+
# analyze arguments
|
603
|
+
for arg in node.args:
|
604
|
+
arg_complexity = self.analyze_expr(arg)
|
605
|
+
complexity = complexity.combine_sequential(arg_complexity)
|
606
|
+
|
607
|
+
return complexity
|
608
|
+
|
609
|
+
elif isinstance(node.func, ast.Attribute):
|
610
|
+
return self.analyze_method_call(node.func)
|
611
|
+
else:
|
612
|
+
return Complexity.approximate("O(?)")
|
613
|
+
|
614
|
+
def analyze_method_call(self, attr: ast.Attribute) -> Complexity:
|
615
|
+
"""Analyze method call complexity"""
|
616
|
+
method_complexity = {
|
617
|
+
'append': Complexity.constant(),
|
618
|
+
'pop': Complexity.constant(),
|
619
|
+
'insert': Complexity.linear(1),
|
620
|
+
'remove': Complexity.linear(1),
|
621
|
+
'sort': Complexity("O(n*log(n))", False),
|
622
|
+
'index': Complexity.linear(1),
|
623
|
+
'count': Complexity.linear(1),
|
624
|
+
'extend': Complexity.linear(1),
|
625
|
+
'copy': Complexity.linear(1),
|
626
|
+
'clear': Complexity.constant(),
|
627
|
+
'get': Complexity.constant(),
|
628
|
+
'items': Complexity.linear(1),
|
629
|
+
'keys': Complexity.linear(1),
|
630
|
+
'values': Complexity.linear(1),
|
631
|
+
'update': Complexity.linear(1),
|
632
|
+
'add': Complexity.constant(), # set.add
|
633
|
+
'discard': Complexity.constant(), # set.discard
|
634
|
+
'union': Complexity.linear(1),
|
635
|
+
'intersection': Complexity.linear(1),
|
636
|
+
'difference': Complexity.linear(1),
|
637
|
+
}
|
638
|
+
|
639
|
+
return method_complexity.get(attr.attr, Complexity.approximate("O(?)")).simplify()
|
640
|
+
|
641
|
+
def analyze_compare(self, node: ast.Compare) -> Complexity:
|
642
|
+
"""Analyze comparison operations"""
|
643
|
+
for i, op in enumerate(node.ops):
|
644
|
+
if isinstance(op, (ast.In, ast.NotIn)):
|
645
|
+
if i < len(node.comparators):
|
646
|
+
comparator = node.comparators[i]
|
647
|
+
|
648
|
+
if isinstance(comparator, ast.List):
|
649
|
+
# list literal membership test - always O(n)
|
650
|
+
self.anti_patterns.append(AntiPattern(
|
651
|
+
line=node.lineno,
|
652
|
+
pattern_type="list_membership",
|
653
|
+
description="Membership test with list literal - use set for O(1) lookup instead of O(n)"
|
654
|
+
))
|
655
|
+
return Complexity.linear(1).with_detail("list membership check")
|
656
|
+
|
657
|
+
elif isinstance(comparator, ast.Set):
|
658
|
+
# set literal membership test - O(1)
|
659
|
+
return Complexity.constant().with_detail("set membership check")
|
660
|
+
|
661
|
+
elif isinstance(comparator, ast.Name):
|
662
|
+
# check variable type if known
|
663
|
+
var_type = self.variable_types.get(comparator.id)
|
664
|
+
if var_type in ('List', 'list'):
|
665
|
+
self.anti_patterns.append(AntiPattern(
|
666
|
+
line=node.lineno,
|
667
|
+
pattern_type="membership_check",
|
668
|
+
description="Membership test with List type - consider using Set for O(1) lookup instead of O(n)"
|
669
|
+
))
|
670
|
+
return Complexity.linear(1).with_detail("list membership check")
|
671
|
+
elif var_type in ('Set', 'set', 'Dict', 'dict'):
|
672
|
+
return Complexity.constant().with_detail("set/dict membership check")
|
673
|
+
else:
|
674
|
+
# unknown type - assume worst case (list)
|
675
|
+
self.anti_patterns.append(AntiPattern(
|
676
|
+
line=node.lineno,
|
677
|
+
pattern_type="membership_check",
|
678
|
+
description="Membership test with unknown type - if this is a list, consider using set for O(1) lookup"
|
679
|
+
))
|
680
|
+
return Complexity.approximate("O(n)").with_detail("membership check (unknown type)")
|
681
|
+
|
682
|
+
elif isinstance(comparator, ast.Attribute):
|
683
|
+
# accessing an attribute - assume list for worst case
|
684
|
+
self.anti_patterns.append(AntiPattern(
|
685
|
+
line=node.lineno,
|
686
|
+
pattern_type="membership_check",
|
687
|
+
description="Membership test with unknown type - if this is a list, consider using set for O(1) lookup"
|
688
|
+
))
|
689
|
+
return Complexity.approximate("O(n)").with_detail("membership check (unknown type)")
|
690
|
+
|
691
|
+
return Complexity.constant()
|
692
|
+
|
693
|
+
def main(files: List[Path], **kwargs) -> None:
|
694
|
+
for file_path in files:
|
695
|
+
if not file_path.exists():
|
696
|
+
print(f"{Fore.RED}{Style.BRIGHT}Error:{Style.RESET_ALL} File does not exist: {file_path}")
|
697
|
+
continue
|
698
|
+
|
699
|
+
if not file_path.is_file():
|
700
|
+
print(f"{Fore.RED}{Style.BRIGHT}Error:{Style.RESET_ALL} Not a file: {file_path}")
|
701
|
+
continue
|
702
|
+
|
703
|
+
try:
|
704
|
+
content = file_path.read_text()
|
705
|
+
except Exception as e:
|
706
|
+
print(f"{Fore.RED}{Style.BRIGHT}Error:{Style.RESET_ALL} Failed to read file: {e}")
|
707
|
+
continue
|
708
|
+
|
709
|
+
analyzer = Analyzer()
|
710
|
+
|
711
|
+
try:
|
712
|
+
results = analyzer.analyze_file(content, str(file_path))
|
713
|
+
except ValueError as e:
|
714
|
+
print(f"{Fore.RED}{Style.BRIGHT}Analysis failed:{Style.RESET_ALL} {e}")
|
715
|
+
continue
|
716
|
+
|
717
|
+
print(f"\n{Fore.CYAN}{'═' * 80}{Style.RESET_ALL}")
|
718
|
+
print(f"{Fore.CYAN}{Style.BRIGHT} Python Complexity Analysis: {file_path}{Style.RESET_ALL}")
|
719
|
+
print(f"{Fore.CYAN}{'═' * 80}{Style.RESET_ALL}\n")
|
720
|
+
|
721
|
+
has_approximate = any(r.complexity.is_approximate for r in results)
|
722
|
+
|
723
|
+
for analysis in results:
|
724
|
+
approx_marker = " *" if analysis.complexity.is_approximate else ""
|
725
|
+
|
726
|
+
print(f"{Fore.GREEN}{Style.BRIGHT}Function/Method:{Style.RESET_ALL} {Fore.YELLOW}{analysis.name}{Style.RESET_ALL}")
|
727
|
+
print(f" {Fore.BLUE}{Style.BRIGHT}Complexity:{Style.RESET_ALL} {Fore.MAGENTA}{analysis.complexity.expression}{Style.RESET_ALL}{Fore.RED}{Style.BRIGHT}{approx_marker}{Style.RESET_ALL}")
|
728
|
+
|
729
|
+
if analysis.anti_patterns:
|
730
|
+
print(f" {Fore.YELLOW}{Style.BRIGHT}⚠ Performance Issues:{Style.RESET_ALL}")
|
731
|
+
for ap in analysis.anti_patterns:
|
732
|
+
print(f" {Fore.RED}•{Style.RESET_ALL} [line {ap.line}]: {Fore.YELLOW}{ap.description}{Style.RESET_ALL}")
|
733
|
+
|
734
|
+
print()
|
735
|
+
|
736
|
+
if has_approximate:
|
737
|
+
print(f"{Fore.YELLOW}{Style.BRIGHT}Note:{Style.RESET_ALL} {Fore.RED}{Style.BRIGHT}*{Style.RESET_ALL} Complexity marked with * is approximate due to static analysis limitations")
|
738
|
+
|