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,515 @@
1
+ """
2
+ Dead code detector for Python codebases.
3
+
4
+ AST-based analysis that identifies unused functions, classes, variables,
5
+ unreachable code after return/raise/break/continue, and empty pass branches.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import ast
11
+ import os
12
+ from dataclasses import dataclass, field
13
+ from pathlib import Path
14
+ from typing import ClassVar
15
+
16
+
17
+ @dataclass(slots=True)
18
+ class DeadCodeItem:
19
+ """A single piece of detected dead code."""
20
+
21
+ filepath: str
22
+ lineno: int
23
+ end_lineno: int | None
24
+ name: str
25
+ kind: str # 'function', 'class', 'variable', 'unreachable', 'empty-branch', 'unused-import'
26
+ reason: str
27
+ confidence: str = "high" # 'high', 'medium', 'low'
28
+
29
+
30
+ @dataclass(slots=True)
31
+ class DeadCodeReport:
32
+ """Full dead code analysis report for a project."""
33
+
34
+ items: list[DeadCodeItem] = field(default_factory=list)
35
+ files_scanned: int = 0
36
+ total_definitions: int = 0
37
+
38
+ @property
39
+ def count(self) -> int:
40
+ return len(self.items)
41
+
42
+ def by_kind(self, kind: str) -> list[DeadCodeItem]:
43
+ return [item for item in self.items if item.kind == kind]
44
+
45
+
46
+ class _DefinitionCollector(ast.NodeVisitor):
47
+ """Collects all function and class definitions with their line numbers."""
48
+
49
+ def __init__(self, filepath: str) -> None:
50
+ self.filepath = filepath
51
+ self.definitions: list[tuple[str, str, int, int | None, str]] = []
52
+ # (name, kind, lineno, end_lineno, scope_context)
53
+ self._scope_stack: list[str] = []
54
+
55
+ def visit_FunctionDef(self, node: ast.FunctionDef | ast.AsyncFunctionDef) -> None:
56
+ context = ".".join(self._scope_stack) if self._scope_stack else "<module>"
57
+ self.definitions.append(
58
+ (node.name, "function", node.lineno, node.end_lineno, context)
59
+ )
60
+ self._scope_stack.append(node.name)
61
+ self.generic_visit(node)
62
+ self._scope_stack.pop()
63
+
64
+ visit_AsyncFunctionDef = visit_FunctionDef
65
+
66
+ def visit_ClassDef(self, node: ast.ClassDef) -> None:
67
+ context = ".".join(self._scope_stack) if self._scope_stack else "<module>"
68
+ self.definitions.append(
69
+ (node.name, "class", node.lineno, node.end_lineno, context)
70
+ )
71
+ self._scope_stack.append(node.name)
72
+ self.generic_visit(node)
73
+ self._scope_stack.pop()
74
+
75
+ def visit_Assign(self, node: ast.Assign) -> None:
76
+ # Module-level constants or class attributes (not local function variables)
77
+ if not self._scope_stack or len(self._scope_stack) == 1:
78
+ for target in node.targets:
79
+ if isinstance(target, ast.Name):
80
+ name = target.id
81
+ if not name.startswith("__"):
82
+ context = (
83
+ ".".join(self._scope_stack)
84
+ if self._scope_stack
85
+ else "<module>"
86
+ )
87
+ self.definitions.append(
88
+ (name, "variable", node.lineno, node.end_lineno, context)
89
+ )
90
+ self.generic_visit(node)
91
+
92
+ def visit_AnnAssign(self, node: ast.AnnAssign) -> None:
93
+ if (not self._scope_stack or len(self._scope_stack) == 1) and isinstance(
94
+ node.target, ast.Name
95
+ ):
96
+ name = node.target.id
97
+ if not name.startswith("__"):
98
+ context = (
99
+ ".".join(self._scope_stack) if self._scope_stack else "<module>"
100
+ )
101
+ self.definitions.append(
102
+ (name, "variable", node.lineno, node.end_lineno, context)
103
+ )
104
+ self.generic_visit(node)
105
+
106
+
107
+ class _ReferenceCollector(ast.NodeVisitor):
108
+ """Collects all name references (loads) across source code."""
109
+
110
+ def __init__(self) -> None:
111
+ self.referenced_names: set[str] = set()
112
+ self.all_exports: set[str] = set()
113
+ self.decorated_names: set[str] = set()
114
+
115
+ def visit_Name(self, node: ast.Name) -> None:
116
+ if isinstance(node.ctx, ast.Load):
117
+ self.referenced_names.add(node.id)
118
+ self.generic_visit(node)
119
+
120
+ def visit_Attribute(self, node: ast.Attribute) -> None:
121
+ self.referenced_names.add(node.attr)
122
+ self.generic_visit(node)
123
+
124
+ def visit_FunctionDef(self, node: ast.FunctionDef | ast.AsyncFunctionDef) -> None:
125
+ if node.decorator_list:
126
+ self.decorated_names.add(node.name)
127
+ self.generic_visit(node)
128
+
129
+ visit_AsyncFunctionDef = visit_FunctionDef
130
+
131
+ def visit_ClassDef(self, node: ast.ClassDef) -> None:
132
+ if node.decorator_list:
133
+ self.decorated_names.add(node.name)
134
+ self.generic_visit(node)
135
+
136
+ def visit_Assign(self, node: ast.Assign) -> None:
137
+ # Detect __all__ = ['name1', 'name2']
138
+ for target in node.targets:
139
+ if (
140
+ isinstance(target, ast.Name)
141
+ and target.id == "__all__"
142
+ and isinstance(node.value, (ast.List, ast.Tuple, ast.Set))
143
+ ):
144
+ for elt in node.value.elts:
145
+ if isinstance(elt, ast.Constant) and isinstance(elt.value, str):
146
+ self.all_exports.add(elt.value)
147
+ self.generic_visit(node)
148
+
149
+
150
+ class _UnreachableCodeDetector(ast.NodeVisitor):
151
+ """Detects code after unconditional return/raise/break/continue and empty branches."""
152
+
153
+ def __init__(self, filepath: str, source_lines: list[str] | None = None) -> None:
154
+ self.filepath = filepath
155
+ self.source_lines = source_lines
156
+ self.items: list[DeadCodeItem] = []
157
+
158
+ def visit_Module(self, node: ast.Module) -> None:
159
+ self._check_body(node.body)
160
+ self.generic_visit(node)
161
+
162
+ def visit_FunctionDef(self, node: ast.FunctionDef | ast.AsyncFunctionDef) -> None:
163
+ self._check_body(node.body)
164
+ self.generic_visit(node)
165
+
166
+ visit_AsyncFunctionDef = visit_FunctionDef
167
+
168
+ def visit_ClassDef(self, node: ast.ClassDef) -> None:
169
+ self._check_body(node.body)
170
+ self.generic_visit(node)
171
+
172
+ def visit_If(self, node: ast.If) -> None:
173
+ self._check_body(node.body)
174
+ self._check_empty_branch(node.body, "if", node.lineno)
175
+ if node.orelse:
176
+ self._check_body(node.orelse)
177
+ first_orelse = node.orelse[0]
178
+ label = "elif" if isinstance(first_orelse, ast.If) else "else"
179
+ lineno = (
180
+ first_orelse.lineno
181
+ if isinstance(first_orelse, ast.If)
182
+ else node.orelse[0].lineno
183
+ )
184
+ self._check_empty_branch(node.orelse, label, lineno)
185
+ self.generic_visit(node)
186
+
187
+ def visit_Try(self, node: ast.Try) -> None:
188
+ self._check_body(node.body)
189
+ self._check_body(node.finalbody)
190
+ self._check_body(node.orelse)
191
+ self.generic_visit(node)
192
+
193
+ def visit_ExceptHandler(self, node: ast.ExceptHandler) -> None:
194
+ self._check_body(node.body)
195
+ self._check_empty_branch(node.body, "except", node.lineno)
196
+ self.generic_visit(node)
197
+
198
+ def visit_For(self, node: ast.For | ast.AsyncFor) -> None:
199
+ self._check_body(node.body)
200
+ self.generic_visit(node)
201
+
202
+ visit_AsyncFor = visit_For
203
+
204
+ def visit_While(self, node: ast.While) -> None:
205
+ self._check_body(node.body)
206
+ self.generic_visit(node)
207
+
208
+ def _check_body(self, body: list[ast.stmt]) -> None:
209
+ """Check for unreachable code after return/raise/break/continue."""
210
+ for i, stmt in enumerate(body):
211
+ if isinstance(stmt, (ast.Return, ast.Raise, ast.Break, ast.Continue)):
212
+ remaining = body[i + 1 :]
213
+ for unreachable in remaining:
214
+ # Skip if the unreachable statement is a function/class def (declarations)
215
+ if isinstance(
216
+ unreachable,
217
+ (ast.FunctionDef, ast.AsyncFunctionDef, ast.ClassDef),
218
+ ):
219
+ continue
220
+ self.items.append(
221
+ DeadCodeItem(
222
+ filepath=self.filepath,
223
+ lineno=unreachable.lineno,
224
+ end_lineno=getattr(unreachable, "end_lineno", None),
225
+ name="<unreachable>",
226
+ kind="unreachable",
227
+ reason=f"Code after unconditional {type(stmt).__name__.lower()} on line {stmt.lineno}",
228
+ confidence="high",
229
+ )
230
+ )
231
+ break
232
+
233
+ def _check_empty_branch(
234
+ self, body: list[ast.stmt], branch_kind: str, lineno: int
235
+ ) -> None:
236
+ """Check for empty branches containing only pass with no comment."""
237
+ if len(body) == 1 and isinstance(body[0], ast.Pass):
238
+ pass_node = body[0]
239
+ if self.source_lines:
240
+ idx = pass_node.lineno - 1
241
+ if 0 <= idx < len(self.source_lines) and "#" in self.source_lines[idx]:
242
+ return
243
+ # Check if preceding line is an explanatory comment
244
+ if (
245
+ idx > 0
246
+ and 0 <= idx - 1 < len(self.source_lines)
247
+ and self.source_lines[idx - 1].strip().startswith("#")
248
+ ):
249
+ return
250
+ self.items.append(
251
+ DeadCodeItem(
252
+ filepath=self.filepath,
253
+ lineno=lineno,
254
+ end_lineno=pass_node.lineno,
255
+ name=f"empty {branch_kind}",
256
+ kind="empty-branch",
257
+ reason=f"'{branch_kind}' block contains only 'pass' with no implementation",
258
+ confidence="low",
259
+ )
260
+ )
261
+
262
+
263
+ @dataclass(slots=True)
264
+ class _ProjectScanState:
265
+ definitions: list[tuple[str, str, str, int, int | None, str]] = field(
266
+ default_factory=list
267
+ )
268
+ references: set[str] = field(default_factory=set)
269
+ exports: set[str] = field(default_factory=set)
270
+ decorated: set[str] = field(default_factory=set)
271
+ unreachable: list[DeadCodeItem] = field(default_factory=list)
272
+
273
+ def process_file(self, py_file: Path) -> None:
274
+ try:
275
+ content = py_file.read_text(encoding="utf-8", errors="replace")
276
+ tree = ast.parse(content, filename=str(py_file))
277
+ except SyntaxError:
278
+ return
279
+
280
+ filepath_str = str(py_file)
281
+ def_collector = _DefinitionCollector(filepath_str)
282
+ def_collector.visit(tree)
283
+ for name, kind, lineno, end_lineno, ctx in def_collector.definitions:
284
+ self.definitions.append((filepath_str, name, kind, lineno, end_lineno, ctx))
285
+
286
+ ref_collector = _ReferenceCollector()
287
+ ref_collector.visit(tree)
288
+ self.references.update(ref_collector.referenced_names)
289
+ self.exports.update(ref_collector.all_exports)
290
+ self.decorated.update(ref_collector.decorated_names)
291
+
292
+ unreachable = _UnreachableCodeDetector(filepath_str, content.splitlines())
293
+ unreachable.visit(tree)
294
+ self.unreachable.extend(unreachable.items)
295
+
296
+
297
+ class DeadCodeDetector:
298
+ """Detects dead code across a Python project."""
299
+
300
+ IGNORE_DIRS = frozenset(
301
+ {
302
+ ".git",
303
+ ".venv",
304
+ "venv",
305
+ "env",
306
+ "__pycache__",
307
+ "build",
308
+ "dist",
309
+ ".tox",
310
+ ".mypy_cache",
311
+ ".pytest_cache",
312
+ ".ruff_cache",
313
+ "site-packages",
314
+ "node_modules",
315
+ ".eggs",
316
+ }
317
+ )
318
+
319
+ # Names that should never be flagged as dead code
320
+ PROTECTED_NAMES = frozenset(
321
+ {
322
+ "__init__",
323
+ "__new__",
324
+ "__del__",
325
+ "__repr__",
326
+ "__str__",
327
+ "__bytes__",
328
+ "__format__",
329
+ "__hash__",
330
+ "__bool__",
331
+ "__len__",
332
+ "__getitem__",
333
+ "__setitem__",
334
+ "__delitem__",
335
+ "__iter__",
336
+ "__next__",
337
+ "__contains__",
338
+ "__enter__",
339
+ "__exit__",
340
+ "__aenter__",
341
+ "__aexit__",
342
+ "__await__",
343
+ "__aiter__",
344
+ "__anext__",
345
+ "__call__",
346
+ "__eq__",
347
+ "__ne__",
348
+ "__lt__",
349
+ "__le__",
350
+ "__gt__",
351
+ "__ge__",
352
+ "__add__",
353
+ "__radd__",
354
+ "__sub__",
355
+ "__mul__",
356
+ "__truediv__",
357
+ "__floordiv__",
358
+ "__mod__",
359
+ "__pow__",
360
+ "__and__",
361
+ "__or__",
362
+ "__xor__",
363
+ "__neg__",
364
+ "__pos__",
365
+ "__abs__",
366
+ "__invert__",
367
+ "__getattr__",
368
+ "__setattr__",
369
+ "__delattr__",
370
+ "__get__",
371
+ "__set__",
372
+ "__delete__",
373
+ "__init_subclass__",
374
+ "__class_getitem__",
375
+ "__post_init__",
376
+ "__set_name__",
377
+ "setUp",
378
+ "tearDown",
379
+ "setUpClass",
380
+ "tearDownClass",
381
+ "main",
382
+ "setup",
383
+ "teardown",
384
+ }
385
+ )
386
+
387
+ FRAMEWORK_DECORATORS: ClassVar[frozenset[str]] = frozenset(
388
+ {
389
+ "app.route",
390
+ "router.get",
391
+ "router.post",
392
+ "router.put",
393
+ "router.delete",
394
+ "router.patch",
395
+ "pytest.fixture",
396
+ "abstractmethod",
397
+ "staticmethod",
398
+ "classmethod",
399
+ "property",
400
+ "override",
401
+ "register",
402
+ "receiver",
403
+ "celery.task",
404
+ "click.command",
405
+ "click.group",
406
+ "app.get",
407
+ "app.post",
408
+ "app.put",
409
+ "app.delete",
410
+ "app.patch",
411
+ "app.task",
412
+ "app.on_event",
413
+ "on_event",
414
+ }
415
+ )
416
+
417
+ def __init__(
418
+ self,
419
+ ignore_decorators: set[str] | None = None,
420
+ ignore_names: set[str] | None = None,
421
+ ) -> None:
422
+ self.ignore_decorators = (ignore_decorators or set()) | set(
423
+ self.FRAMEWORK_DECORATORS
424
+ )
425
+ self.ignore_names = ignore_names or set()
426
+
427
+ def scan_project(self, root_dir: Path | str) -> DeadCodeReport:
428
+ """Scan an entire project directory for dead code."""
429
+ root = Path(root_dir).resolve()
430
+ py_files = self._discover_files(root)
431
+
432
+ state = _ProjectScanState()
433
+ for py_file in py_files:
434
+ state.process_file(py_file)
435
+
436
+ items: list[DeadCodeItem] = list(state.unreachable)
437
+ for filepath, name, kind, lineno, end_lineno, _ in state.definitions:
438
+ if self._should_skip(
439
+ name, state.references, state.exports, state.decorated
440
+ ):
441
+ continue
442
+ items.append(
443
+ DeadCodeItem(
444
+ filepath=filepath,
445
+ lineno=lineno,
446
+ end_lineno=end_lineno,
447
+ name=name,
448
+ kind=kind,
449
+ reason=f"{kind.capitalize()} '{name}' is defined but never referenced in the project",
450
+ confidence="medium",
451
+ )
452
+ )
453
+
454
+ items.sort(key=lambda x: (x.filepath, x.lineno))
455
+ return DeadCodeReport(
456
+ items=items,
457
+ files_scanned=len(py_files),
458
+ total_definitions=len(state.definitions),
459
+ )
460
+
461
+ def scan_source(self, source: str, filename: str = "<unknown>") -> DeadCodeReport:
462
+ """Scan a single source string for dead code patterns (unreachable/empty only)."""
463
+ try:
464
+ tree = ast.parse(source, filename=filename)
465
+ except SyntaxError:
466
+ return DeadCodeReport()
467
+
468
+ unreachable = _UnreachableCodeDetector(filename, source.splitlines())
469
+ unreachable.visit(tree)
470
+
471
+ return DeadCodeReport(
472
+ items=unreachable.items,
473
+ files_scanned=1,
474
+ total_definitions=0,
475
+ )
476
+
477
+ def _matches_ignore_pattern(self, name: str) -> bool:
478
+ for pattern in self.ignore_names:
479
+ if pattern.endswith("*") and name.startswith(pattern[:-1]):
480
+ return True
481
+ if name == pattern:
482
+ return True
483
+ return False
484
+
485
+ def _is_name_exempt(self, name: str) -> bool:
486
+ if name in self.PROTECTED_NAMES:
487
+ return True
488
+ exempt_prefixes = ("_", "test_", "Test", "visit_")
489
+ return name.startswith(exempt_prefixes) or name == "generic_visit"
490
+
491
+ def _should_skip(
492
+ self,
493
+ name: str,
494
+ references: set[str],
495
+ exports: set[str],
496
+ decorated: set[str],
497
+ ) -> bool:
498
+ """Determine if a definition should be skipped (not flagged as dead)."""
499
+ if name in references or name in exports or name in decorated:
500
+ return True
501
+ if self._is_name_exempt(name):
502
+ return True
503
+ return self._matches_ignore_pattern(name)
504
+
505
+ def _discover_files(self, root: Path) -> list[Path]:
506
+ """Walk the project tree and collect .py files, respecting ignore dirs."""
507
+ files: list[Path] = []
508
+ for current_root, dirs, filenames in os.walk(root):
509
+ dirs[:] = [
510
+ d for d in dirs if d not in self.IGNORE_DIRS and not d.startswith(".")
511
+ ]
512
+ for fname in filenames:
513
+ if fname.endswith(".py"):
514
+ files.append(Path(current_root) / fname)
515
+ return sorted(files)