ballpython 2.0.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,473 @@
1
+ """
2
+ Cyclomatic and cognitive complexity analyzer for Python source code.
3
+
4
+ Computes per-function metrics including McCabe cyclomatic complexity,
5
+ Sonar-style cognitive complexity, line count, argument count, return count,
6
+ and maximum nesting depth.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ import ast
12
+ import os
13
+ from dataclasses import dataclass, field
14
+ from pathlib import Path
15
+
16
+
17
+ @dataclass(slots=True)
18
+ class ComplexityMetrics:
19
+ """Complexity measurements for a single function or method."""
20
+
21
+ filepath: str
22
+ lineno: int
23
+ end_lineno: int | None
24
+ name: str
25
+ qualified_name: str
26
+ cyclomatic: int
27
+ cognitive: int
28
+ lines: int
29
+ args: int
30
+ returns: int
31
+ max_nesting: int
32
+
33
+ @property
34
+ def is_complex(self) -> bool:
35
+ """Quick check for any threshold violation using generous defaults."""
36
+ return (
37
+ self.cyclomatic > 10
38
+ or self.cognitive > 15
39
+ or self.lines > 50
40
+ or self.args > 5
41
+ )
42
+
43
+
44
+ @dataclass(slots=True)
45
+ class ComplexityReport:
46
+ """Full complexity analysis report."""
47
+
48
+ functions: list[ComplexityMetrics] = field(default_factory=list)
49
+ files_scanned: int = 0
50
+
51
+ @property
52
+ def count(self) -> int:
53
+ return len(self.functions)
54
+
55
+ def above_threshold(
56
+ self,
57
+ max_cyclomatic: int = 10,
58
+ max_cognitive: int = 15,
59
+ max_lines: int = 50,
60
+ max_args: int = 5,
61
+ ) -> list[ComplexityMetrics]:
62
+ """Return functions exceeding any configured threshold."""
63
+ return [
64
+ f
65
+ for f in self.functions
66
+ if f.cyclomatic > max_cyclomatic
67
+ or f.cognitive > max_cognitive
68
+ or f.lines > max_lines
69
+ or f.args > max_args
70
+ ]
71
+
72
+ @property
73
+ def average_cyclomatic(self) -> float:
74
+ if not self.functions:
75
+ return 0.0
76
+ return sum(f.cyclomatic for f in self.functions) / len(self.functions)
77
+
78
+ @property
79
+ def average_cognitive(self) -> float:
80
+ if not self.functions:
81
+ return 0.0
82
+ return sum(f.cognitive for f in self.functions) / len(self.functions)
83
+
84
+
85
+ class _CyclomaticCounter(ast.NodeVisitor):
86
+ """Counts McCabe cyclomatic complexity decision points in a function body."""
87
+
88
+ def __init__(self) -> None:
89
+ self.complexity = 1 # Base complexity
90
+ # Tracks def/async-def nesting so the counter can visit the entry
91
+ # function's own body (depth reaches 1) while refusing to descend
92
+ # into any function defined *inside* it (depth would reach 2+).
93
+ # Without this, a nested helper's branches get double-counted: once
94
+ # against itself, and again against every function that encloses it.
95
+ self._function_depth = 0
96
+
97
+ def visit_If(self, node: ast.If) -> None:
98
+ self.complexity += 1
99
+ self.generic_visit(node)
100
+
101
+ def visit_For(self, node: ast.For | ast.AsyncFor) -> None:
102
+ self.complexity += 1
103
+ self.generic_visit(node)
104
+
105
+ visit_AsyncFor = visit_For
106
+
107
+ def visit_While(self, node: ast.While) -> None:
108
+ self.complexity += 1
109
+ self.generic_visit(node)
110
+
111
+ def visit_ExceptHandler(self, node: ast.ExceptHandler) -> None:
112
+ self.complexity += 1
113
+ self.generic_visit(node)
114
+
115
+ def visit_With(self, node: ast.With | ast.AsyncWith) -> None:
116
+ self.complexity += 1
117
+ self.generic_visit(node)
118
+
119
+ visit_AsyncWith = visit_With
120
+
121
+ def visit_Assert(self, node: ast.Assert) -> None:
122
+ self.complexity += 1
123
+ self.generic_visit(node)
124
+
125
+ def visit_BoolOp(self, node: ast.BoolOp) -> None:
126
+ # Each 'and' or 'or' adds a decision branch
127
+ self.complexity += len(node.values) - 1
128
+ self.generic_visit(node)
129
+
130
+ def visit_IfExp(self, node: ast.IfExp) -> None:
131
+ # Ternary expression: x if cond else y
132
+ self.complexity += 1
133
+ self.generic_visit(node)
134
+
135
+ def visit_comprehension(self, node: ast.comprehension) -> None:
136
+ self.complexity += 1
137
+ # Each filter condition is an additional branch
138
+ self.complexity += len(node.ifs)
139
+ self.generic_visit(node)
140
+
141
+ def visit_Match(self, node: ast.Match) -> None:
142
+ # Each case is a branch
143
+ self.complexity += len(node.cases)
144
+ self.generic_visit(node)
145
+
146
+ def visit_FunctionDef(self, node: ast.FunctionDef | ast.AsyncFunctionDef) -> None:
147
+ self._function_depth += 1
148
+ if self._function_depth == 1:
149
+ # This is the entry function being scored; walk its body.
150
+ self.generic_visit(node)
151
+ # A nested def is scored as its own independent entry elsewhere;
152
+ # do not descend into it from here.
153
+ self._function_depth -= 1
154
+
155
+ visit_AsyncFunctionDef = visit_FunctionDef
156
+
157
+
158
+ class _CognitiveCounter(ast.NodeVisitor):
159
+ """Computes Sonar-style cognitive complexity score."""
160
+
161
+ def __init__(self) -> None:
162
+ self.score = 0
163
+ self._nesting = 0
164
+
165
+ def _increment(self, nesting_penalty: bool = True) -> None:
166
+ self.score += 1
167
+ if nesting_penalty:
168
+ self.score += self._nesting
169
+
170
+ def _handle_elif(self, orelse_node: ast.If) -> None:
171
+ self.score += 1
172
+ self._nesting += 1
173
+ for child in orelse_node.body:
174
+ self.visit(child)
175
+ self._nesting -= 1
176
+ for sub in orelse_node.orelse:
177
+ if isinstance(sub, ast.If):
178
+ self.visit_If(sub)
179
+ else:
180
+ self.visit(sub)
181
+
182
+ def _handle_else(self, orelse_node: ast.AST) -> None:
183
+ self.score += 1
184
+ self._nesting += 1
185
+ self.visit(orelse_node)
186
+ self._nesting -= 1
187
+
188
+ def visit_If(self, node: ast.If) -> None:
189
+ self._increment(nesting_penalty=True)
190
+ self._nesting += 1
191
+ for child in node.body:
192
+ self.visit(child)
193
+ self._nesting -= 1
194
+
195
+ for orelse_node in node.orelse:
196
+ if isinstance(orelse_node, ast.If):
197
+ self._handle_elif(orelse_node)
198
+ else:
199
+ self._handle_else(orelse_node)
200
+
201
+ def visit_For(self, node: ast.For | ast.AsyncFor) -> None:
202
+ self._increment(nesting_penalty=True)
203
+ self._nesting += 1
204
+ for child in node.body:
205
+ self.visit(child)
206
+ self._nesting -= 1
207
+ for child in node.orelse:
208
+ self.visit(child)
209
+
210
+ visit_AsyncFor = visit_For
211
+
212
+ def visit_While(self, node: ast.While) -> None:
213
+ self._increment(nesting_penalty=True)
214
+ self._nesting += 1
215
+ for child in node.body:
216
+ self.visit(child)
217
+ self._nesting -= 1
218
+ for child in node.orelse:
219
+ self.visit(child)
220
+
221
+ def visit_ExceptHandler(self, node: ast.ExceptHandler) -> None:
222
+ self._increment(nesting_penalty=True)
223
+ self._nesting += 1
224
+ for child in node.body:
225
+ self.visit(child)
226
+ self._nesting -= 1
227
+
228
+ def visit_BoolOp(self, node: ast.BoolOp) -> None:
229
+ # Sequences of same operator (a and b and c) count as 1
230
+ # Mixed operators count each change
231
+ self.score += 1
232
+ self.generic_visit(node)
233
+
234
+ def visit_FunctionDef(self, node: ast.FunctionDef | ast.AsyncFunctionDef) -> None:
235
+ # The caller only ever feeds this visitor the *children* of the
236
+ # function being scored (see _extract_functions), never the
237
+ # function's own node, so any FunctionDef reaching here is always a
238
+ # nested helper. Nested functions get their own report entry and
239
+ # must not contribute to the enclosing function's cognitive score.
240
+ return
241
+
242
+ visit_AsyncFunctionDef = visit_FunctionDef
243
+
244
+ def visit_IfExp(self, node: ast.IfExp) -> None:
245
+ self._increment(nesting_penalty=True)
246
+ self.generic_visit(node)
247
+
248
+ def visit_Break(self, node: ast.Break) -> None:
249
+ self.score += 1
250
+
251
+ def visit_Continue(self, node: ast.Continue) -> None:
252
+ self.score += 1
253
+
254
+ def visit_Match(self, node: ast.Match) -> None:
255
+ self._increment(nesting_penalty=True)
256
+ self._nesting += 1
257
+ for case in node.cases:
258
+ for child in case.body:
259
+ self.visit(child)
260
+ self._nesting -= 1
261
+
262
+ def visit_Try(self, node: ast.Try) -> None:
263
+ self._increment(nesting_penalty=True)
264
+ self._nesting += 1
265
+ for child in node.body:
266
+ self.visit(child)
267
+ self._nesting -= 1
268
+ for handler in node.handlers:
269
+ self.visit(handler)
270
+ for child in node.finalbody:
271
+ self.visit(child)
272
+ for child in node.orelse:
273
+ self.visit(child)
274
+
275
+ # Python 3.11+ TryStar
276
+ def visit_TryStar(self, node: ast.TryStar) -> None: # type: ignore[attr-defined]
277
+ self._increment(nesting_penalty=True)
278
+ self._nesting += 1
279
+ for child in node.body:
280
+ self.visit(child)
281
+ self._nesting -= 1
282
+ for handler in node.handlers:
283
+ self.visit(handler)
284
+ for child in node.finalbody:
285
+ self.visit(child)
286
+
287
+
288
+ class _NestingDepthCounter(ast.NodeVisitor):
289
+ """Tracks maximum nesting depth within a function body."""
290
+
291
+ NESTING_NODES = (
292
+ ast.If,
293
+ ast.For,
294
+ ast.AsyncFor,
295
+ ast.While,
296
+ ast.With,
297
+ ast.AsyncWith,
298
+ ast.Try,
299
+ ast.ExceptHandler,
300
+ )
301
+
302
+ def __init__(self) -> None:
303
+ self.max_depth = 0
304
+ self._current_depth = 0
305
+ self._function_depth = 0
306
+
307
+ def generic_visit(self, node: ast.AST) -> None:
308
+ if isinstance(node, self.NESTING_NODES):
309
+ self._current_depth += 1
310
+ self.max_depth = max(self.max_depth, self._current_depth)
311
+ super().generic_visit(node)
312
+ self._current_depth -= 1
313
+ else:
314
+ super().generic_visit(node)
315
+
316
+ def visit_FunctionDef(self, node: ast.FunctionDef | ast.AsyncFunctionDef) -> None:
317
+ self._function_depth += 1
318
+ if self._function_depth == 1:
319
+ # Entry function being scored — walk its body normally.
320
+ self.generic_visit(node)
321
+ # A nested def starts counting from its own zero depth in its own
322
+ # report entry; it must not extend the enclosing function's depth.
323
+ self._function_depth -= 1
324
+
325
+ visit_AsyncFunctionDef = visit_FunctionDef
326
+
327
+
328
+ class ComplexityAnalyzer:
329
+ """Analyzes per-function complexity metrics across Python source files."""
330
+
331
+ IGNORE_DIRS = frozenset(
332
+ {
333
+ ".git",
334
+ ".venv",
335
+ "venv",
336
+ "env",
337
+ "__pycache__",
338
+ "build",
339
+ "dist",
340
+ ".tox",
341
+ ".mypy_cache",
342
+ ".pytest_cache",
343
+ ".ruff_cache",
344
+ "site-packages",
345
+ }
346
+ )
347
+
348
+ def analyze_source(
349
+ self, source: str, filename: str = "<unknown>"
350
+ ) -> ComplexityReport:
351
+ """Analyze complexity metrics for all functions in a source string."""
352
+ try:
353
+ tree = ast.parse(source, filename=filename)
354
+ except SyntaxError:
355
+ return ComplexityReport(files_scanned=1)
356
+
357
+ functions = self._extract_functions(tree, filename, source)
358
+ return ComplexityReport(functions=functions, files_scanned=1)
359
+
360
+ def _collect_file_metrics(
361
+ self, current_root: str, fname: str
362
+ ) -> list[ComplexityMetrics] | None:
363
+ if not fname.endswith(".py"):
364
+ return None
365
+ fpath = Path(current_root) / fname
366
+ try:
367
+ content = fpath.read_text(encoding="utf-8", errors="replace")
368
+ return self.analyze_source(content, filename=str(fpath)).functions
369
+ except OSError:
370
+ return None
371
+
372
+ def analyze_project(self, root_dir: Path | str) -> ComplexityReport:
373
+ """Analyze complexity across all Python files in a project."""
374
+ root = Path(root_dir).resolve()
375
+ all_functions: list[ComplexityMetrics] = []
376
+ files_scanned = 0
377
+
378
+ for current_root, dirs, filenames in os.walk(root):
379
+ dirs[:] = [
380
+ d for d in dirs if d not in self.IGNORE_DIRS and not d.startswith(".")
381
+ ]
382
+ for fname in filenames:
383
+ file_funcs = self._collect_file_metrics(current_root, fname)
384
+ if file_funcs is not None:
385
+ all_functions.extend(file_funcs)
386
+ files_scanned += 1
387
+
388
+ all_functions.sort(key=lambda f: f.cyclomatic, reverse=True)
389
+ return ComplexityReport(functions=all_functions, files_scanned=files_scanned)
390
+
391
+ @staticmethod
392
+ def _count_function_args(node: ast.FunctionDef | ast.AsyncFunctionDef) -> int:
393
+ all_args = node.args.posonlyargs + node.args.args + node.args.kwonlyargs
394
+ count = len(all_args)
395
+ if node.args.vararg:
396
+ count += 1
397
+ if node.args.kwarg:
398
+ count += 1
399
+ if all_args and all_args[0].arg in ("self", "cls"):
400
+ count -= 1
401
+ return count
402
+
403
+ def _build_function_metrics(
404
+ self,
405
+ node: ast.FunctionDef | ast.AsyncFunctionDef,
406
+ tree: ast.Module,
407
+ filename: str,
408
+ ) -> ComplexityMetrics:
409
+ start = node.lineno
410
+ end = node.end_lineno or start
411
+ return_count = sum(
412
+ 1 for c in ast.walk(node) if isinstance(c, ast.Return) and c is not node
413
+ )
414
+
415
+ cyclo = _CyclomaticCounter()
416
+ cyclo.visit(node)
417
+
418
+ cog = _CognitiveCounter()
419
+ for child in node.body:
420
+ cog.visit(child)
421
+
422
+ nesting = _NestingDepthCounter()
423
+ nesting.visit(node)
424
+
425
+ return ComplexityMetrics(
426
+ filepath=filename,
427
+ lineno=start,
428
+ end_lineno=end,
429
+ name=node.name,
430
+ qualified_name=self._get_qualified_name(node, tree),
431
+ cyclomatic=cyclo.complexity,
432
+ cognitive=cog.score,
433
+ lines=end - start + 1,
434
+ args=self._count_function_args(node),
435
+ returns=return_count,
436
+ max_nesting=nesting.max_depth,
437
+ )
438
+
439
+ def _extract_functions(
440
+ self,
441
+ tree: ast.Module,
442
+ filename: str,
443
+ source: str,
444
+ ) -> list[ComplexityMetrics]:
445
+ """Extract and analyze all function/method definitions from an AST."""
446
+ results: list[ComplexityMetrics] = []
447
+ for node in ast.walk(tree):
448
+ if isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef)):
449
+ results.append(self._build_function_metrics(node, tree, filename))
450
+ return results
451
+
452
+ @staticmethod
453
+ def _get_qualified_name(
454
+ target: ast.FunctionDef | ast.AsyncFunctionDef,
455
+ tree: ast.Module,
456
+ ) -> str:
457
+ """Resolve a function's qualified name including class scope."""
458
+ parents: dict[int, ast.AST] = {}
459
+ for parent in ast.walk(tree):
460
+ for child in ast.iter_child_nodes(parent):
461
+ parents[id(child)] = parent
462
+
463
+ names: list[str] = [target.name]
464
+ current: ast.AST = target
465
+ while id(current) in parents:
466
+ parent = parents[id(current)]
467
+ if isinstance(
468
+ parent, (ast.ClassDef, ast.FunctionDef, ast.AsyncFunctionDef)
469
+ ):
470
+ names.insert(0, parent.name)
471
+ current = parent
472
+
473
+ return ".".join(names)