ballpython 2.0.0__py3-none-any.whl → 2.0.2__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.
- ballpython/py.typed +0 -0
- {ballpython-2.0.0.dist-info → ballpython-2.0.2.dist-info}/METADATA +13 -7
- ballpython-2.0.2.dist-info/RECORD +34 -0
- pycleaner/__init__.py +39 -2
- pycleaner/baseline.py +127 -0
- pycleaner/cache.py +282 -0
- pycleaner/cli.py +644 -43
- pycleaner/complexity_analyzer.py +16 -28
- pycleaner/config.py +8 -1
- pycleaner/dead_code_detector.py +275 -38
- pycleaner/dependency_auditor.py +175 -75
- pycleaner/discovery.py +206 -0
- pycleaner/explanations.py +323 -0
- pycleaner/frameworks/__init__.py +115 -0
- pycleaner/frameworks/plugins.py +290 -0
- pycleaner/modernizer.py +349 -0
- pycleaner/pipeline.py +208 -20
- pycleaner/py.typed +0 -0
- pycleaner/security_scanner.py +59 -38
- pycleaner/syntax_healer.py +188 -10
- pycleaner/taint_engine.py +8 -8
- pycleaner/test_generator.py +124 -35
- pycleaner/type_checker.py +29 -33
- pycleaner/verifier.py +495 -0
- ballpython-2.0.0.dist-info/RECORD +0 -24
- {ballpython-2.0.0.dist-info → ballpython-2.0.2.dist-info}/WHEEL +0 -0
- {ballpython-2.0.0.dist-info → ballpython-2.0.2.dist-info}/entry_points.txt +0 -0
- {ballpython-2.0.0.dist-info → ballpython-2.0.2.dist-info}/top_level.txt +0 -0
pycleaner/complexity_analyzer.py
CHANGED
|
@@ -9,10 +9,12 @@ and maximum nesting depth.
|
|
|
9
9
|
from __future__ import annotations
|
|
10
10
|
|
|
11
11
|
import ast
|
|
12
|
-
import
|
|
12
|
+
from collections.abc import Sequence
|
|
13
13
|
from dataclasses import dataclass, field
|
|
14
14
|
from pathlib import Path
|
|
15
15
|
|
|
16
|
+
from pycleaner.discovery import collect_project_python_files
|
|
17
|
+
|
|
16
18
|
|
|
17
19
|
@dataclass(slots=True)
|
|
18
20
|
class ComplexityMetrics:
|
|
@@ -328,23 +330,6 @@ class _NestingDepthCounter(ast.NodeVisitor):
|
|
|
328
330
|
class ComplexityAnalyzer:
|
|
329
331
|
"""Analyzes per-function complexity metrics across Python source files."""
|
|
330
332
|
|
|
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
333
|
def analyze_source(
|
|
349
334
|
self, source: str, filename: str = "<unknown>"
|
|
350
335
|
) -> ComplexityReport:
|
|
@@ -369,21 +354,24 @@ class ComplexityAnalyzer:
|
|
|
369
354
|
except OSError:
|
|
370
355
|
return None
|
|
371
356
|
|
|
372
|
-
def analyze_project(
|
|
357
|
+
def analyze_project(
|
|
358
|
+
self, root_dir: Path | str, exclude_patterns: Sequence[str] = ()
|
|
359
|
+
) -> ComplexityReport:
|
|
373
360
|
"""Analyze complexity across all Python files in a project."""
|
|
374
361
|
root = Path(root_dir).resolve()
|
|
375
362
|
all_functions: list[ComplexityMetrics] = []
|
|
376
363
|
files_scanned = 0
|
|
377
364
|
|
|
378
|
-
for
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
365
|
+
for fpath in collect_project_python_files(
|
|
366
|
+
root, exclude_patterns=exclude_patterns
|
|
367
|
+
):
|
|
368
|
+
try:
|
|
369
|
+
content = fpath.read_text(encoding="utf-8", errors="replace")
|
|
370
|
+
metrics = self.analyze_source(content, filename=str(fpath)).functions
|
|
371
|
+
all_functions.extend(metrics)
|
|
372
|
+
files_scanned += 1
|
|
373
|
+
except OSError:
|
|
374
|
+
continue
|
|
387
375
|
|
|
388
376
|
all_functions.sort(key=lambda f: f.cyclomatic, reverse=True)
|
|
389
377
|
return ComplexityReport(functions=all_functions, files_scanned=files_scanned)
|
pycleaner/config.py
CHANGED
|
@@ -36,6 +36,7 @@ class PyCleanerConfig:
|
|
|
36
36
|
"generated/",
|
|
37
37
|
"*_pb2.py",
|
|
38
38
|
"*_pb2_grpc.py",
|
|
39
|
+
"editor-*.py",
|
|
39
40
|
]
|
|
40
41
|
)
|
|
41
42
|
include: list[str] = field(default_factory=list)
|
|
@@ -155,7 +156,13 @@ def load_config(
|
|
|
155
156
|
"""Load and merge configuration from file and CLI overrides."""
|
|
156
157
|
file_config = _resolve_file_config(project_root, explicit_config_file)
|
|
157
158
|
merged: dict[str, Any] = {
|
|
158
|
-
"exclude": [
|
|
159
|
+
"exclude": [
|
|
160
|
+
"migrations/",
|
|
161
|
+
"generated/",
|
|
162
|
+
"*_pb2.py",
|
|
163
|
+
"*_pb2_grpc.py",
|
|
164
|
+
"editor-*.py",
|
|
165
|
+
],
|
|
159
166
|
"include": [],
|
|
160
167
|
"fix_py2_syntax": True,
|
|
161
168
|
"fix_conditional_assignments": True,
|
pycleaner/dead_code_detector.py
CHANGED
|
@@ -8,11 +8,14 @@ unreachable code after return/raise/break/continue, and empty pass branches.
|
|
|
8
8
|
from __future__ import annotations
|
|
9
9
|
|
|
10
10
|
import ast
|
|
11
|
-
import
|
|
11
|
+
from collections.abc import Sequence
|
|
12
12
|
from dataclasses import dataclass, field
|
|
13
13
|
from pathlib import Path
|
|
14
14
|
from typing import ClassVar
|
|
15
15
|
|
|
16
|
+
from pycleaner.discovery import collect_project_python_files
|
|
17
|
+
from pycleaner.frameworks import FrameworkRegistry, get_default_registry
|
|
18
|
+
|
|
16
19
|
|
|
17
20
|
@dataclass(slots=True)
|
|
18
21
|
class DeadCodeItem:
|
|
@@ -46,14 +49,30 @@ class DeadCodeReport:
|
|
|
46
49
|
class _DefinitionCollector(ast.NodeVisitor):
|
|
47
50
|
"""Collects all function and class definitions with their line numbers."""
|
|
48
51
|
|
|
49
|
-
def __init__(
|
|
52
|
+
def __init__(
|
|
53
|
+
self,
|
|
54
|
+
filepath: str,
|
|
55
|
+
tree: ast.AST | None = None,
|
|
56
|
+
registry: FrameworkRegistry | None = None,
|
|
57
|
+
) -> None:
|
|
50
58
|
self.filepath = filepath
|
|
59
|
+
self.tree = tree
|
|
60
|
+
self.registry = registry or get_default_registry()
|
|
51
61
|
self.definitions: list[tuple[str, str, int, int | None, str]] = []
|
|
52
62
|
# (name, kind, lineno, end_lineno, scope_context)
|
|
53
63
|
self._scope_stack: list[str] = []
|
|
64
|
+
self._class_stack: list[ast.ClassDef] = []
|
|
54
65
|
|
|
55
66
|
def visit_FunctionDef(self, node: ast.FunctionDef | ast.AsyncFunctionDef) -> None:
|
|
56
67
|
context = ".".join(self._scope_stack) if self._scope_stack else "<module>"
|
|
68
|
+
if self.tree is not None and self.registry.is_protected(
|
|
69
|
+
node.name, "function", node, context, self.tree, self.filepath
|
|
70
|
+
):
|
|
71
|
+
self._scope_stack.append(node.name)
|
|
72
|
+
self.generic_visit(node)
|
|
73
|
+
self._scope_stack.pop()
|
|
74
|
+
return
|
|
75
|
+
|
|
57
76
|
self.definitions.append(
|
|
58
77
|
(node.name, "function", node.lineno, node.end_lineno, context)
|
|
59
78
|
)
|
|
@@ -65,14 +84,33 @@ class _DefinitionCollector(ast.NodeVisitor):
|
|
|
65
84
|
|
|
66
85
|
def visit_ClassDef(self, node: ast.ClassDef) -> None:
|
|
67
86
|
context = ".".join(self._scope_stack) if self._scope_stack else "<module>"
|
|
87
|
+
if self.tree is not None and self.registry.is_protected(
|
|
88
|
+
node.name, "class", node, context, self.tree, self.filepath
|
|
89
|
+
):
|
|
90
|
+
self._scope_stack.append(node.name)
|
|
91
|
+
self._class_stack.append(node)
|
|
92
|
+
self.generic_visit(node)
|
|
93
|
+
self._class_stack.pop()
|
|
94
|
+
self._scope_stack.pop()
|
|
95
|
+
return
|
|
96
|
+
|
|
68
97
|
self.definitions.append(
|
|
69
98
|
(node.name, "class", node.lineno, node.end_lineno, context)
|
|
70
99
|
)
|
|
71
100
|
self._scope_stack.append(node.name)
|
|
101
|
+
self._class_stack.append(node)
|
|
72
102
|
self.generic_visit(node)
|
|
103
|
+
self._class_stack.pop()
|
|
73
104
|
self._scope_stack.pop()
|
|
74
105
|
|
|
75
106
|
def visit_Assign(self, node: ast.Assign) -> None:
|
|
107
|
+
if self._class_stack and self.tree is not None:
|
|
108
|
+
if self.registry.is_field_protected(
|
|
109
|
+
node, self._class_stack[-1], self.tree, self.filepath
|
|
110
|
+
):
|
|
111
|
+
self.generic_visit(node)
|
|
112
|
+
return
|
|
113
|
+
|
|
76
114
|
# Module-level constants or class attributes (not local function variables)
|
|
77
115
|
if not self._scope_stack or len(self._scope_stack) == 1:
|
|
78
116
|
for target in node.targets:
|
|
@@ -84,12 +122,23 @@ class _DefinitionCollector(ast.NodeVisitor):
|
|
|
84
122
|
if self._scope_stack
|
|
85
123
|
else "<module>"
|
|
86
124
|
)
|
|
125
|
+
if self.tree is not None and self.registry.is_protected(
|
|
126
|
+
name, "variable", node, context, self.tree, self.filepath
|
|
127
|
+
):
|
|
128
|
+
continue
|
|
87
129
|
self.definitions.append(
|
|
88
130
|
(name, "variable", node.lineno, node.end_lineno, context)
|
|
89
131
|
)
|
|
90
132
|
self.generic_visit(node)
|
|
91
133
|
|
|
92
134
|
def visit_AnnAssign(self, node: ast.AnnAssign) -> None:
|
|
135
|
+
if self._class_stack and self.tree is not None:
|
|
136
|
+
if self.registry.is_field_protected(
|
|
137
|
+
node, self._class_stack[-1], self.tree, self.filepath
|
|
138
|
+
):
|
|
139
|
+
self.generic_visit(node)
|
|
140
|
+
return
|
|
141
|
+
|
|
93
142
|
if (not self._scope_stack or len(self._scope_stack) == 1) and isinstance(
|
|
94
143
|
node.target, ast.Name
|
|
95
144
|
):
|
|
@@ -98,6 +147,11 @@ class _DefinitionCollector(ast.NodeVisitor):
|
|
|
98
147
|
context = (
|
|
99
148
|
".".join(self._scope_stack) if self._scope_stack else "<module>"
|
|
100
149
|
)
|
|
150
|
+
if self.tree is not None and self.registry.is_protected(
|
|
151
|
+
name, "variable", node, context, self.tree, self.filepath
|
|
152
|
+
):
|
|
153
|
+
self.generic_visit(node)
|
|
154
|
+
return
|
|
101
155
|
self.definitions.append(
|
|
102
156
|
(name, "variable", node.lineno, node.end_lineno, context)
|
|
103
157
|
)
|
|
@@ -269,6 +323,7 @@ class _ProjectScanState:
|
|
|
269
323
|
exports: set[str] = field(default_factory=set)
|
|
270
324
|
decorated: set[str] = field(default_factory=set)
|
|
271
325
|
unreachable: list[DeadCodeItem] = field(default_factory=list)
|
|
326
|
+
framework_registry: FrameworkRegistry = field(default_factory=get_default_registry)
|
|
272
327
|
|
|
273
328
|
def process_file(self, py_file: Path) -> None:
|
|
274
329
|
try:
|
|
@@ -278,7 +333,9 @@ class _ProjectScanState:
|
|
|
278
333
|
return
|
|
279
334
|
|
|
280
335
|
filepath_str = str(py_file)
|
|
281
|
-
def_collector = _DefinitionCollector(
|
|
336
|
+
def_collector = _DefinitionCollector(
|
|
337
|
+
filepath_str, tree=tree, registry=self.framework_registry
|
|
338
|
+
)
|
|
282
339
|
def_collector.visit(tree)
|
|
283
340
|
for name, kind, lineno, end_lineno, ctx in def_collector.definitions:
|
|
284
341
|
self.definitions.append((filepath_str, name, kind, lineno, end_lineno, ctx))
|
|
@@ -297,25 +354,6 @@ class _ProjectScanState:
|
|
|
297
354
|
class DeadCodeDetector:
|
|
298
355
|
"""Detects dead code across a Python project."""
|
|
299
356
|
|
|
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
357
|
# Names that should never be flagged as dead code
|
|
320
358
|
PROTECTED_NAMES = frozenset(
|
|
321
359
|
{
|
|
@@ -418,18 +456,27 @@ class DeadCodeDetector:
|
|
|
418
456
|
self,
|
|
419
457
|
ignore_decorators: set[str] | None = None,
|
|
420
458
|
ignore_names: set[str] | None = None,
|
|
459
|
+
framework_registry: FrameworkRegistry | None = None,
|
|
421
460
|
) -> None:
|
|
422
|
-
self.
|
|
423
|
-
|
|
461
|
+
self.framework_registry = framework_registry or get_default_registry()
|
|
462
|
+
extra_decorators: set[str] = set()
|
|
463
|
+
for p in self.framework_registry.plugins:
|
|
464
|
+
extra_decorators.update(p.get_protected_decorators())
|
|
465
|
+
self.ignore_decorators = (
|
|
466
|
+
(ignore_decorators or set())
|
|
467
|
+
| set(self.FRAMEWORK_DECORATORS)
|
|
468
|
+
| extra_decorators
|
|
424
469
|
)
|
|
425
470
|
self.ignore_names = ignore_names or set()
|
|
426
471
|
|
|
427
|
-
def scan_project(
|
|
472
|
+
def scan_project(
|
|
473
|
+
self, root_dir: Path | str, exclude_patterns: Sequence[str] = ()
|
|
474
|
+
) -> DeadCodeReport:
|
|
428
475
|
"""Scan an entire project directory for dead code."""
|
|
429
476
|
root = Path(root_dir).resolve()
|
|
430
|
-
py_files = self._discover_files(root)
|
|
477
|
+
py_files = self._discover_files(root, exclude_patterns=exclude_patterns)
|
|
431
478
|
|
|
432
|
-
state = _ProjectScanState()
|
|
479
|
+
state = _ProjectScanState(framework_registry=self.framework_registry)
|
|
433
480
|
for py_file in py_files:
|
|
434
481
|
state.process_file(py_file)
|
|
435
482
|
|
|
@@ -502,14 +549,204 @@ class DeadCodeDetector:
|
|
|
502
549
|
return True
|
|
503
550
|
return self._matches_ignore_pattern(name)
|
|
504
551
|
|
|
505
|
-
def _discover_files(
|
|
506
|
-
|
|
507
|
-
|
|
508
|
-
|
|
509
|
-
|
|
510
|
-
|
|
511
|
-
|
|
512
|
-
|
|
513
|
-
|
|
514
|
-
|
|
515
|
-
|
|
552
|
+
def _discover_files(
|
|
553
|
+
self, root: Path, exclude_patterns: Sequence[str] = ()
|
|
554
|
+
) -> list[Path]:
|
|
555
|
+
"""Walk the project tree and collect .py files, respecting ignore dirs and gitignore."""
|
|
556
|
+
return collect_project_python_files(root, exclude_patterns=exclude_patterns)
|
|
557
|
+
|
|
558
|
+
def fix_source(self, source: str, filename: str = "<stdin>") -> DeadCodeFixResult:
|
|
559
|
+
"""Surgically fix dead code in in-memory source."""
|
|
560
|
+
fixer = DeadCodeFixer()
|
|
561
|
+
return fixer.fix(source, filename=filename)
|
|
562
|
+
|
|
563
|
+
def fix_file(
|
|
564
|
+
self, filepath: Path | str, apply_changes: bool = True
|
|
565
|
+
) -> DeadCodeFixResult:
|
|
566
|
+
"""Surgically fix dead code in a file."""
|
|
567
|
+
path = Path(filepath).resolve()
|
|
568
|
+
content = path.read_text(encoding="utf-8", errors="replace")
|
|
569
|
+
res = self.fix_source(content, filename=str(path))
|
|
570
|
+
if apply_changes and res.changed:
|
|
571
|
+
path.write_text(res.code, encoding="utf-8")
|
|
572
|
+
return res
|
|
573
|
+
|
|
574
|
+
def fix_project(
|
|
575
|
+
self, root_dir: Path | str, exclude_patterns: Sequence[str] = ()
|
|
576
|
+
) -> dict[Path, DeadCodeFixResult]:
|
|
577
|
+
"""Surgically fix dead code across all project Python files."""
|
|
578
|
+
root = Path(root_dir).resolve()
|
|
579
|
+
py_files = self._discover_files(root, exclude_patterns=exclude_patterns)
|
|
580
|
+
results: dict[Path, DeadCodeFixResult] = {}
|
|
581
|
+
for pf in py_files:
|
|
582
|
+
res = self.fix_file(pf, apply_changes=True)
|
|
583
|
+
if res.changed:
|
|
584
|
+
results[pf] = res
|
|
585
|
+
return results
|
|
586
|
+
|
|
587
|
+
|
|
588
|
+
@dataclass(slots=True)
|
|
589
|
+
class DeadCodeFixResult:
|
|
590
|
+
"""Outcome of attempting to fix dead code in source code."""
|
|
591
|
+
|
|
592
|
+
code: str
|
|
593
|
+
changed: bool
|
|
594
|
+
pruned_items: list[str] = field(default_factory=list)
|
|
595
|
+
|
|
596
|
+
|
|
597
|
+
class _DeadCodePrunerCollector(ast.NodeVisitor):
|
|
598
|
+
"""Collects line spans of unreachable code, redundant pass, and dead branches."""
|
|
599
|
+
|
|
600
|
+
def __init__(self) -> None:
|
|
601
|
+
self.deletions: list[tuple[int, int, str]] = []
|
|
602
|
+
|
|
603
|
+
def _is_terminal_jump(self, stmt: ast.AST) -> bool:
|
|
604
|
+
if isinstance(stmt, (ast.Return, ast.Raise, ast.Break, ast.Continue)):
|
|
605
|
+
return True
|
|
606
|
+
if isinstance(stmt, ast.Expr) and isinstance(stmt.value, ast.Call):
|
|
607
|
+
func = stmt.value.func
|
|
608
|
+
if isinstance(func, ast.Name) and func.id in ("exit", "quit"):
|
|
609
|
+
return True
|
|
610
|
+
if (
|
|
611
|
+
isinstance(func, ast.Attribute)
|
|
612
|
+
and isinstance(func.value, ast.Name)
|
|
613
|
+
and func.value.id == "sys"
|
|
614
|
+
and func.attr == "exit"
|
|
615
|
+
):
|
|
616
|
+
return True
|
|
617
|
+
return False
|
|
618
|
+
|
|
619
|
+
def _inspect_stmts(self, stmts: list[ast.stmt]) -> None:
|
|
620
|
+
terminal_seen = False
|
|
621
|
+
unreachable: list[ast.stmt] = []
|
|
622
|
+
non_doc = [
|
|
623
|
+
s
|
|
624
|
+
for s in stmts
|
|
625
|
+
if not (
|
|
626
|
+
isinstance(s, ast.Expr)
|
|
627
|
+
and isinstance(s.value, ast.Constant)
|
|
628
|
+
and isinstance(s.value.value, str)
|
|
629
|
+
)
|
|
630
|
+
]
|
|
631
|
+
|
|
632
|
+
for stmt in stmts:
|
|
633
|
+
if terminal_seen:
|
|
634
|
+
unreachable.append(stmt)
|
|
635
|
+
elif self._is_terminal_jump(stmt):
|
|
636
|
+
terminal_seen = True
|
|
637
|
+
elif isinstance(stmt, ast.Pass) and len(non_doc) > 1:
|
|
638
|
+
self.deletions.append(
|
|
639
|
+
(
|
|
640
|
+
stmt.lineno,
|
|
641
|
+
getattr(stmt, "end_lineno", stmt.lineno),
|
|
642
|
+
f"Pruned redundant 'pass' at line {stmt.lineno}",
|
|
643
|
+
)
|
|
644
|
+
)
|
|
645
|
+
elif (
|
|
646
|
+
isinstance(stmt, ast.If)
|
|
647
|
+
and isinstance(stmt.test, ast.Constant)
|
|
648
|
+
and stmt.test.value in (False, 0)
|
|
649
|
+
and not stmt.orelse
|
|
650
|
+
):
|
|
651
|
+
self.deletions.append(
|
|
652
|
+
(
|
|
653
|
+
stmt.lineno,
|
|
654
|
+
getattr(stmt, "end_lineno", stmt.lineno),
|
|
655
|
+
f"Pruned dead 'if False' branch at line {stmt.lineno}",
|
|
656
|
+
)
|
|
657
|
+
)
|
|
658
|
+
self.visit(stmt)
|
|
659
|
+
|
|
660
|
+
if unreachable:
|
|
661
|
+
start_line = unreachable[0].lineno
|
|
662
|
+
end_line = getattr(unreachable[-1], "end_lineno", unreachable[-1].lineno)
|
|
663
|
+
self.deletions.append(
|
|
664
|
+
(
|
|
665
|
+
start_line,
|
|
666
|
+
end_line,
|
|
667
|
+
f"Pruned {len(unreachable)} unreachable statement(s) at lines {start_line}-{end_line}",
|
|
668
|
+
)
|
|
669
|
+
)
|
|
670
|
+
|
|
671
|
+
def visit_FunctionDef(self, node: ast.FunctionDef | ast.AsyncFunctionDef) -> None:
|
|
672
|
+
self._inspect_stmts(node.body)
|
|
673
|
+
|
|
674
|
+
visit_AsyncFunctionDef = visit_FunctionDef
|
|
675
|
+
|
|
676
|
+
def visit_ClassDef(self, node: ast.ClassDef) -> None:
|
|
677
|
+
self._inspect_stmts(node.body)
|
|
678
|
+
|
|
679
|
+
def visit_If(self, node: ast.If) -> None:
|
|
680
|
+
self._inspect_stmts(node.body)
|
|
681
|
+
if node.orelse:
|
|
682
|
+
self._inspect_stmts(node.orelse)
|
|
683
|
+
|
|
684
|
+
def visit_For(self, node: ast.For | ast.AsyncFor) -> None:
|
|
685
|
+
self._inspect_stmts(node.body)
|
|
686
|
+
if node.orelse:
|
|
687
|
+
self._inspect_stmts(node.orelse)
|
|
688
|
+
|
|
689
|
+
visit_AsyncFor = visit_For
|
|
690
|
+
|
|
691
|
+
def visit_While(self, node: ast.While) -> None:
|
|
692
|
+
self._inspect_stmts(node.body)
|
|
693
|
+
if node.orelse:
|
|
694
|
+
self._inspect_stmts(node.orelse)
|
|
695
|
+
|
|
696
|
+
def visit_Try(self, node: ast.Try) -> None:
|
|
697
|
+
self._inspect_stmts(node.body)
|
|
698
|
+
for h in node.handlers:
|
|
699
|
+
self._inspect_stmts(h.body)
|
|
700
|
+
if node.orelse:
|
|
701
|
+
self._inspect_stmts(node.orelse)
|
|
702
|
+
if node.finalbody:
|
|
703
|
+
self._inspect_stmts(node.finalbody)
|
|
704
|
+
|
|
705
|
+
def visit_With(self, node: ast.With | ast.AsyncWith) -> None:
|
|
706
|
+
self._inspect_stmts(node.body)
|
|
707
|
+
|
|
708
|
+
visit_AsyncWith = visit_With
|
|
709
|
+
|
|
710
|
+
def visit_Match(self, node: ast.AST) -> None:
|
|
711
|
+
for case in getattr(node, "cases", []):
|
|
712
|
+
self._inspect_stmts(case.body)
|
|
713
|
+
|
|
714
|
+
|
|
715
|
+
class DeadCodeFixer:
|
|
716
|
+
"""Surgically eliminates unreachable statements, dead branches, and redundant pass statements."""
|
|
717
|
+
|
|
718
|
+
def fix(self, source: str, filename: str = "<stdin>") -> DeadCodeFixResult:
|
|
719
|
+
current_code = source
|
|
720
|
+
all_pruned: list[str] = []
|
|
721
|
+
|
|
722
|
+
for _ in range(2):
|
|
723
|
+
try:
|
|
724
|
+
tree = ast.parse(current_code, filename=filename)
|
|
725
|
+
except SyntaxError:
|
|
726
|
+
break
|
|
727
|
+
|
|
728
|
+
collector = _DeadCodePrunerCollector()
|
|
729
|
+
collector.visit(tree)
|
|
730
|
+
if not collector.deletions:
|
|
731
|
+
break
|
|
732
|
+
|
|
733
|
+
lines = current_code.splitlines(keepends=True)
|
|
734
|
+
sorted_deletions = sorted(
|
|
735
|
+
collector.deletions, key=lambda x: x[0], reverse=True
|
|
736
|
+
)
|
|
737
|
+
for start_line, end_line, desc in sorted_deletions:
|
|
738
|
+
del lines[start_line - 1 : end_line]
|
|
739
|
+
all_pruned.append(desc)
|
|
740
|
+
|
|
741
|
+
candidate = "".join(lines)
|
|
742
|
+
try:
|
|
743
|
+
ast.parse(candidate, filename=filename)
|
|
744
|
+
current_code = candidate
|
|
745
|
+
except SyntaxError:
|
|
746
|
+
break
|
|
747
|
+
|
|
748
|
+
return DeadCodeFixResult(
|
|
749
|
+
code=current_code,
|
|
750
|
+
changed=current_code != source,
|
|
751
|
+
pruned_items=all_pruned,
|
|
752
|
+
)
|