ballpython 2.0.7__py3-none-any.whl → 2.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.
- {ballpython-2.0.7.dist-info → ballpython-2.1.0.dist-info}/METADATA +1 -1
- {ballpython-2.0.7.dist-info → ballpython-2.1.0.dist-info}/RECORD +14 -14
- pycleaner/cache.py +3 -1
- pycleaner/cli.py +40 -14
- pycleaner/dead_code_detector.py +552 -15
- pycleaner/dependency_auditor.py +129 -14
- pycleaner/linter_formatter.py +6 -5
- pycleaner/modernizer.py +2 -1
- pycleaner/security_scanner.py +2 -1
- pycleaner/syntax_healer.py +2 -1
- pycleaner/verifier.py +3 -2
- {ballpython-2.0.7.dist-info → ballpython-2.1.0.dist-info}/WHEEL +0 -0
- {ballpython-2.0.7.dist-info → ballpython-2.1.0.dist-info}/entry_points.txt +0 -0
- {ballpython-2.0.7.dist-info → ballpython-2.1.0.dist-info}/top_level.txt +0 -0
|
@@ -5,30 +5,30 @@ ballpython/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
|
5
5
|
pycleaner/__init__.py,sha256=VzmbjUip6i0wpKh-s6XBwgBkIeUgI1duGyKDIiv7Pls,3042
|
|
6
6
|
pycleaner/__main__.py,sha256=8QBFrPyxId563F6cipPMNVEBTG8TIZUgxQfp8gk1VSI,135
|
|
7
7
|
pycleaner/baseline.py,sha256=ljED3x3akie0SB3fzhVno_1UHlI2id-9vta0NIGC0mw,3857
|
|
8
|
-
pycleaner/cache.py,sha256=
|
|
9
|
-
pycleaner/cli.py,sha256=
|
|
8
|
+
pycleaner/cache.py,sha256=T3JIxYg8ZezRbSpXYXPD-SyMUkjg4nfKsdO0IuNypOY,10774
|
|
9
|
+
pycleaner/cli.py,sha256=NswwV_3cbHiL63Pj6qDtSpV_98zXvGo7UuBHwNAxpQ8,73752
|
|
10
10
|
pycleaner/complexity_analyzer.py,sha256=j2ktzgR-isWE4xWMe56N3JFLLuYCi-S84BIlRBe0v-I,14975
|
|
11
11
|
pycleaner/config.py,sha256=XhloyaY_9pdwbQuc3QaAPCerLeXz-i1da4xT50h-pjY,8514
|
|
12
|
-
pycleaner/dead_code_detector.py,sha256=
|
|
13
|
-
pycleaner/dependency_auditor.py,sha256=
|
|
12
|
+
pycleaner/dead_code_detector.py,sha256=uufGt7EtGusQdtbX3rUCHIrF8IAu-S16jrViBJdnV-g,52092
|
|
13
|
+
pycleaner/dependency_auditor.py,sha256=EmSqr_ayUqmdSZ7Nm4QAT4qfikF9VToLL8eXVecIDpw,23395
|
|
14
14
|
pycleaner/discovery.py,sha256=dt3OmHg886hy_Z0p80ccMyDkITQxz4biaJY_zB3MU_g,6151
|
|
15
15
|
pycleaner/explanations.py,sha256=2OOU_l3YL5o_Br_6fP6MF8HlY75-1JRmw3eTYT2lu8M,14219
|
|
16
16
|
pycleaner/import_resolver.py,sha256=Wgrch335AQVsnDv5-7F0l4Zm2oQPkNebPEgTRa7ayAg,32855
|
|
17
|
-
pycleaner/linter_formatter.py,sha256=
|
|
18
|
-
pycleaner/modernizer.py,sha256=
|
|
17
|
+
pycleaner/linter_formatter.py,sha256=wdTjwguPWzkJ9KRMcQpFHEirb3ogc9qay69yiphz16Y,21356
|
|
18
|
+
pycleaner/modernizer.py,sha256=kd9_G5EzdW_O-9zm1hA6v33Sb__UbpMZzSvopF-UK5w,16820
|
|
19
19
|
pycleaner/pipeline.py,sha256=ZG7_9PNz0jBadFrZoiI7juLtUJXglGQhLgQhrLJqEac,20612
|
|
20
20
|
pycleaner/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
21
|
-
pycleaner/security_scanner.py,sha256=
|
|
22
|
-
pycleaner/syntax_healer.py,sha256=
|
|
21
|
+
pycleaner/security_scanner.py,sha256=8nyRtoCjBYAB_sI5azlNsEMm2S0s2zE7fPhc7QCHWR0,23527
|
|
22
|
+
pycleaner/syntax_healer.py,sha256=5VYclqkI3WFnm6yfG0zRLMkHN4QMU4aN4ZYCcSUsmZk,27408
|
|
23
23
|
pycleaner/taint_engine.py,sha256=-bBq38YoQgbO2FHCDxDctZpXWQTqwuS56CEWFNOY000,26870
|
|
24
24
|
pycleaner/test_generator.py,sha256=MM3t0Dx5lyJB5s144IKXq2tXrE8ohKcSvicPR3c9MlU,19680
|
|
25
25
|
pycleaner/type_checker.py,sha256=zkUqi-BNLUzJT0tIECNh7_7GP29Y7_6fMXhVuguwMKg,37881
|
|
26
26
|
pycleaner/typeshed_resolver.py,sha256=N-kWU_CI5DG59lQMkt7wMW9sHYjNCBdfFS42b4y2Kr4,12742
|
|
27
|
-
pycleaner/verifier.py,sha256=
|
|
27
|
+
pycleaner/verifier.py,sha256=QlBwnsrhWWRK3LzvJjpNix474drCNp6LP7FiUgJxMsg,19333
|
|
28
28
|
pycleaner/frameworks/__init__.py,sha256=SOuSRl39af86eoQx7Qm8rTJGD4jsnEvgyhhQ6cVmRVA,3462
|
|
29
29
|
pycleaner/frameworks/plugins.py,sha256=qunMzS3q7xp1GEGxqkuhrnm-raiSRhYQMzmDxsEhSUg,11452
|
|
30
|
-
ballpython-2.0.
|
|
31
|
-
ballpython-2.0.
|
|
32
|
-
ballpython-2.0.
|
|
33
|
-
ballpython-2.0.
|
|
34
|
-
ballpython-2.0.
|
|
30
|
+
ballpython-2.1.0.dist-info/METADATA,sha256=98OjKH0i9ih05fQ9JX2eriRljEboAEOVIgk4TwQ9hQ8,3466
|
|
31
|
+
ballpython-2.1.0.dist-info/WHEEL,sha256=YVMoNqKzERt-wjUZwJ33xBGAwnFl-4cqbYkTtWa4itE,91
|
|
32
|
+
ballpython-2.1.0.dist-info/entry_points.txt,sha256=6ux-5UxoVSmlK5p9M-GDVt24sCdwTQq9wgRDkpMIYU8,81
|
|
33
|
+
ballpython-2.1.0.dist-info/top_level.txt,sha256=u6lmy8RWZ_saxCZAQTL7EijB4sunegOiVyk5fHjpwLk,21
|
|
34
|
+
ballpython-2.1.0.dist-info/RECORD,,
|
pycleaner/cache.py
CHANGED
|
@@ -15,6 +15,7 @@ from __future__ import annotations
|
|
|
15
15
|
import ast
|
|
16
16
|
import hashlib
|
|
17
17
|
import json
|
|
18
|
+
import logging
|
|
18
19
|
import sqlite3
|
|
19
20
|
import time
|
|
20
21
|
from pathlib import Path
|
|
@@ -22,6 +23,7 @@ from typing import TYPE_CHECKING, Any
|
|
|
22
23
|
|
|
23
24
|
if TYPE_CHECKING:
|
|
24
25
|
from pycleaner.pipeline import CleanResult
|
|
26
|
+
|
|
25
27
|
from pycleaner.verifier import VerificationTier
|
|
26
28
|
|
|
27
29
|
|
|
@@ -46,7 +48,7 @@ def extract_file_dependencies(source: str) -> list[str]:
|
|
|
46
48
|
elif isinstance(node, ast.ImportFrom) and node.module:
|
|
47
49
|
deps.append(node.module.split(".")[0])
|
|
48
50
|
except SyntaxError:
|
|
49
|
-
|
|
51
|
+
logging.getLogger(__name__).debug("Suppressed exception", exc_info=True)
|
|
50
52
|
return sorted(set(deps))
|
|
51
53
|
|
|
52
54
|
|
pycleaner/cli.py
CHANGED
|
@@ -157,6 +157,11 @@ def _register_fix_subparsers(subparsers: Any) -> None:
|
|
|
157
157
|
def _register_analysis_subparsers(subparsers: Any) -> None:
|
|
158
158
|
audit_p = subparsers.add_parser("audit", help="Audit project dependencies")
|
|
159
159
|
_add_common_args(audit_p)
|
|
160
|
+
audit_p.add_argument(
|
|
161
|
+
"--fix",
|
|
162
|
+
action="store_true",
|
|
163
|
+
help="Auto-fix requirements.txt and pyproject.toml and prune unused packages",
|
|
164
|
+
)
|
|
160
165
|
audit_p.add_argument(
|
|
161
166
|
"--fix-deps", action="store_true", help="Auto-fix requirements.txt"
|
|
162
167
|
)
|
|
@@ -897,15 +902,13 @@ def _render_audit_terminal_output(
|
|
|
897
902
|
f" [red]Missing:[/red] {', '.join(sorted(audit_report.missing_packages))}"
|
|
898
903
|
)
|
|
899
904
|
if not should_fix:
|
|
900
|
-
print_msg(
|
|
901
|
-
" [dim]Tip: Run with -a/--all or --fix-deps to append them[/dim]"
|
|
902
|
-
)
|
|
905
|
+
print_msg(" [dim]Tip: Run with -a/--all or --fix to append them[/dim]")
|
|
903
906
|
if audit_report.unused_packages:
|
|
904
907
|
print_msg(
|
|
905
908
|
f" [yellow]Unused:[/yellow] {', '.join(sorted(audit_report.unused_packages))}"
|
|
906
909
|
)
|
|
907
910
|
if audit_report.fixed_requirements:
|
|
908
|
-
print_msg(" [green]
|
|
911
|
+
print_msg(" [green]Project dependencies synchronized on disk.[/green]")
|
|
909
912
|
|
|
910
913
|
|
|
911
914
|
def _run_fix_audit(
|
|
@@ -918,8 +921,18 @@ def _run_fix_audit(
|
|
|
918
921
|
) -> DependencyAuditReport:
|
|
919
922
|
exclude_patterns = config.exclude if config else ()
|
|
920
923
|
auditor = DependencyAuditor(root_dir, exclude_patterns=exclude_patterns)
|
|
921
|
-
fix_any =
|
|
922
|
-
|
|
924
|
+
fix_any = (
|
|
925
|
+
getattr(args, "fix_deps", False)
|
|
926
|
+
or getattr(args, "fix_all", False)
|
|
927
|
+
or getattr(args, "fix", False)
|
|
928
|
+
or apply_changes
|
|
929
|
+
)
|
|
930
|
+
prune_any = (
|
|
931
|
+
getattr(args, "prune_deps", False)
|
|
932
|
+
or getattr(args, "fix_all", False)
|
|
933
|
+
or getattr(args, "fix", False)
|
|
934
|
+
or apply_changes
|
|
935
|
+
)
|
|
923
936
|
should_fix = apply_changes and fix_any
|
|
924
937
|
should_prune = apply_changes and prune_any
|
|
925
938
|
audit_report = auditor.audit(
|
|
@@ -1009,6 +1022,12 @@ def _cmd_fix(
|
|
|
1009
1022
|
config=config,
|
|
1010
1023
|
target_files=py_files,
|
|
1011
1024
|
)
|
|
1025
|
+
if opts.apply_changes and (
|
|
1026
|
+
audit_report.fixed_requirements
|
|
1027
|
+
or getattr(audit_report, "fixed_pyproject", False)
|
|
1028
|
+
):
|
|
1029
|
+
state.changed_count += 1
|
|
1030
|
+
|
|
1012
1031
|
print_msg(
|
|
1013
1032
|
f"\n[bold]Summary: {len(py_files)} inspected, {state.changed_count} updated, {state.error_count} errors.[/bold]"
|
|
1014
1033
|
)
|
|
@@ -1412,8 +1431,10 @@ def _render_audit_cli_output(
|
|
|
1412
1431
|
)
|
|
1413
1432
|
if not audit_report.missing_packages and not audit_report.unused_packages:
|
|
1414
1433
|
print_msg(" [green]All dependencies clean.[/green]")
|
|
1415
|
-
if audit_report.fixed_requirements
|
|
1416
|
-
|
|
1434
|
+
if audit_report.fixed_requirements or getattr(
|
|
1435
|
+
audit_report, "fixed_pyproject", False
|
|
1436
|
+
):
|
|
1437
|
+
print_msg(" [green]Project dependencies synchronized on disk.[/green]")
|
|
1417
1438
|
|
|
1418
1439
|
return (
|
|
1419
1440
|
1
|
|
@@ -1427,10 +1448,12 @@ def _cmd_audit(args: argparse.Namespace, config: PyCleanerConfig, print_msg) ->
|
|
|
1427
1448
|
root_dir = find_project_root(args.paths[0] if args.paths else None)
|
|
1428
1449
|
|
|
1429
1450
|
is_check = getattr(args, "check", False)
|
|
1451
|
+
fix_flag = getattr(args, "fix", False) or getattr(args, "fix_deps", False)
|
|
1452
|
+
prune_flag = getattr(args, "fix", False) or getattr(args, "prune_deps", False)
|
|
1430
1453
|
auditor = DependencyAuditor(root_dir, exclude_patterns=config.exclude)
|
|
1431
1454
|
audit_report = auditor.audit(
|
|
1432
|
-
fix=
|
|
1433
|
-
prune_unused=
|
|
1455
|
+
fix=fix_flag and not is_check,
|
|
1456
|
+
prune_unused=prune_flag and not is_check,
|
|
1434
1457
|
)
|
|
1435
1458
|
|
|
1436
1459
|
if getattr(args, "json", False):
|
|
@@ -1964,6 +1987,8 @@ def _run_ultimate_phases(
|
|
|
1964
1987
|
print_msg(
|
|
1965
1988
|
"\n[bold blue]Phase 2: Project Dependency Audit & Reconciliation[/bold blue]"
|
|
1966
1989
|
)
|
|
1990
|
+
args.fix = True
|
|
1991
|
+
args.prune_deps = True
|
|
1967
1992
|
audit_rc = _cmd_audit(args, config, print_msg)
|
|
1968
1993
|
print_msg(
|
|
1969
1994
|
"\n[bold blue]Phase 3: Bidirectional Type Verification & Typeshed Resolution[/bold blue]"
|
|
@@ -1981,7 +2006,7 @@ def _run_ultimate_phases(
|
|
|
1981
2006
|
def _cmd_ultimate(
|
|
1982
2007
|
args: argparse.Namespace, config: PyCleanerConfig, print_msg: Any, console: Any
|
|
1983
2008
|
) -> int:
|
|
1984
|
-
"""The Ultimate Python Tool flagship runner: executes full multi-layer analysis."""
|
|
2009
|
+
"""The Ultimate Python Tool flagship runner: executes full multi-layer analysis and automated healing."""
|
|
1985
2010
|
print_msg(
|
|
1986
2011
|
"[bold magenta]=== PyCleaner Ultimate: Full Spectrum Analysis & Healing ===[/bold magenta]\n"
|
|
1987
2012
|
)
|
|
@@ -1989,14 +2014,15 @@ def _cmd_ultimate(
|
|
|
1989
2014
|
args, config, print_msg, console
|
|
1990
2015
|
)
|
|
1991
2016
|
print_msg(
|
|
1992
|
-
"\n[bold blue]Phase 6: Structural Complexity & Dead Code Discovery[/bold blue]"
|
|
2017
|
+
"\n[bold blue]Phase 6: Structural Complexity & Dead Code Discovery & Healing[/bold blue]"
|
|
1993
2018
|
)
|
|
1994
2019
|
_cmd_complexity(args, config, print_msg, console)
|
|
1995
|
-
|
|
2020
|
+
args.fix = True
|
|
2021
|
+
dead_rc = _cmd_dead_code(args, config, print_msg, console)
|
|
1996
2022
|
print_msg(
|
|
1997
2023
|
"\n[bold magenta]=== Ultimate Python Tool: Analysis Complete ===[/bold magenta]"
|
|
1998
2024
|
)
|
|
1999
|
-
return max(fix_rc, audit_rc, type_rc, taint_rc, scan_rc)
|
|
2025
|
+
return max(fix_rc, audit_rc, type_rc, taint_rc, scan_rc, dead_rc)
|
|
2000
2026
|
|
|
2001
2027
|
|
|
2002
2028
|
def _collect_file_mtimes(files: Sequence[Path]) -> dict[Path, float]:
|
pycleaner/dead_code_detector.py
CHANGED
|
@@ -8,6 +8,7 @@ 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 logging
|
|
11
12
|
from collections.abc import Sequence
|
|
12
13
|
from dataclasses import dataclass, field
|
|
13
14
|
from pathlib import Path
|
|
@@ -201,6 +202,199 @@ class _ReferenceCollector(ast.NodeVisitor):
|
|
|
201
202
|
self.generic_visit(node)
|
|
202
203
|
|
|
203
204
|
|
|
205
|
+
def _is_pure_expression(node: ast.AST | None) -> bool:
|
|
206
|
+
"""Determine if an AST expression is pure (guaranteed free of side effects)."""
|
|
207
|
+
if node is None:
|
|
208
|
+
return True
|
|
209
|
+
if isinstance(node, ast.Constant):
|
|
210
|
+
return True
|
|
211
|
+
if isinstance(node, ast.Name):
|
|
212
|
+
return True
|
|
213
|
+
if isinstance(node, (ast.Tuple, ast.List, ast.Set)):
|
|
214
|
+
return all(_is_pure_expression(elt) for elt in node.elts)
|
|
215
|
+
if isinstance(node, ast.Dict):
|
|
216
|
+
return all(
|
|
217
|
+
(k is None or _is_pure_expression(k)) and _is_pure_expression(v)
|
|
218
|
+
for k, v in zip(node.keys, node.values)
|
|
219
|
+
)
|
|
220
|
+
if isinstance(node, ast.UnaryOp):
|
|
221
|
+
return _is_pure_expression(node.operand)
|
|
222
|
+
if isinstance(node, ast.BinOp):
|
|
223
|
+
return _is_pure_expression(node.left) and _is_pure_expression(node.right)
|
|
224
|
+
if isinstance(node, ast.BoolOp):
|
|
225
|
+
return all(_is_pure_expression(v) for v in node.values)
|
|
226
|
+
if isinstance(node, ast.Compare):
|
|
227
|
+
return _is_pure_expression(node.left) and all(
|
|
228
|
+
_is_pure_expression(c) for c in node.comparators
|
|
229
|
+
)
|
|
230
|
+
if isinstance(node, ast.JoinedStr):
|
|
231
|
+
for val in node.values:
|
|
232
|
+
if isinstance(val, ast.FormattedValue) and not _is_pure_expression(
|
|
233
|
+
val.value
|
|
234
|
+
):
|
|
235
|
+
return False
|
|
236
|
+
return True
|
|
237
|
+
if isinstance(node, ast.Lambda):
|
|
238
|
+
return True
|
|
239
|
+
return False
|
|
240
|
+
|
|
241
|
+
|
|
242
|
+
def _walk_stmts_in_scope(body: list[ast.stmt]) -> list[tuple[ast.stmt, list[ast.stmt]]]:
|
|
243
|
+
"""Yield (stmt, parent_body) for statements within this scope without descending into nested functions/classes."""
|
|
244
|
+
result: list[tuple[ast.stmt, list[ast.stmt]]] = []
|
|
245
|
+
for s in body:
|
|
246
|
+
result.append((s, body))
|
|
247
|
+
if isinstance(s, (ast.If, ast.While, ast.For, ast.AsyncFor)):
|
|
248
|
+
result.extend(_walk_stmts_in_scope(s.body))
|
|
249
|
+
if s.orelse:
|
|
250
|
+
result.extend(_walk_stmts_in_scope(s.orelse))
|
|
251
|
+
elif isinstance(s, ast.Try):
|
|
252
|
+
result.extend(_walk_stmts_in_scope(s.body))
|
|
253
|
+
for h in s.handlers:
|
|
254
|
+
result.extend(_walk_stmts_in_scope(h.body))
|
|
255
|
+
if s.orelse:
|
|
256
|
+
result.extend(_walk_stmts_in_scope(s.orelse))
|
|
257
|
+
if s.finalbody:
|
|
258
|
+
result.extend(_walk_stmts_in_scope(s.finalbody))
|
|
259
|
+
elif isinstance(s, (ast.With, ast.AsyncWith)):
|
|
260
|
+
result.extend(_walk_stmts_in_scope(s.body))
|
|
261
|
+
elif hasattr(ast, "Match") and isinstance(s, ast.Match):
|
|
262
|
+
for case in s.cases:
|
|
263
|
+
result.extend(_walk_stmts_in_scope(case.body))
|
|
264
|
+
return result
|
|
265
|
+
|
|
266
|
+
|
|
267
|
+
def _has_logging_import(tree: ast.AST) -> bool:
|
|
268
|
+
for node in ast.walk(tree):
|
|
269
|
+
if isinstance(node, ast.Import):
|
|
270
|
+
for alias in node.names:
|
|
271
|
+
if alias.name == "logging":
|
|
272
|
+
return True
|
|
273
|
+
elif isinstance(node, ast.ImportFrom):
|
|
274
|
+
if node.module and (
|
|
275
|
+
node.module == "logging" or node.module.startswith("logging.")
|
|
276
|
+
):
|
|
277
|
+
return True
|
|
278
|
+
return False
|
|
279
|
+
|
|
280
|
+
|
|
281
|
+
def _detect_unused_locals_in_function(
|
|
282
|
+
func_node: ast.FunctionDef | ast.AsyncFunctionDef,
|
|
283
|
+
filepath: str,
|
|
284
|
+
) -> list[DeadCodeItem]:
|
|
285
|
+
items: list[DeadCodeItem] = []
|
|
286
|
+
args_set: set[str] = set()
|
|
287
|
+
for a in func_node.args.posonlyargs:
|
|
288
|
+
args_set.add(a.arg)
|
|
289
|
+
for a in func_node.args.args:
|
|
290
|
+
args_set.add(a.arg)
|
|
291
|
+
for a in func_node.args.kwonlyargs:
|
|
292
|
+
args_set.add(a.arg)
|
|
293
|
+
if func_node.args.vararg:
|
|
294
|
+
args_set.add(func_node.args.vararg.arg)
|
|
295
|
+
if func_node.args.kwarg:
|
|
296
|
+
args_set.add(func_node.args.kwarg.arg)
|
|
297
|
+
|
|
298
|
+
explicit_globals: set[str] = set()
|
|
299
|
+
has_dynamic = False
|
|
300
|
+
loaded_names: set[str] = set()
|
|
301
|
+
|
|
302
|
+
for sub in ast.walk(func_node):
|
|
303
|
+
if isinstance(sub, (ast.Global, ast.Nonlocal)):
|
|
304
|
+
explicit_globals.update(sub.names)
|
|
305
|
+
elif isinstance(sub, ast.Name) and isinstance(sub.ctx, ast.Load):
|
|
306
|
+
loaded_names.add(sub.id)
|
|
307
|
+
elif isinstance(sub, ast.Call):
|
|
308
|
+
func = sub.func
|
|
309
|
+
if isinstance(func, ast.Name) and func.id in (
|
|
310
|
+
"locals",
|
|
311
|
+
"vars",
|
|
312
|
+
"eval",
|
|
313
|
+
"exec",
|
|
314
|
+
):
|
|
315
|
+
has_dynamic = True
|
|
316
|
+
|
|
317
|
+
if has_dynamic:
|
|
318
|
+
return items
|
|
319
|
+
|
|
320
|
+
stmts_in_scope = _walk_stmts_in_scope(func_node.body)
|
|
321
|
+
seen_unused: set[str] = set()
|
|
322
|
+
for stmt, _ in stmts_in_scope:
|
|
323
|
+
if isinstance(stmt, ast.Assign):
|
|
324
|
+
for target in stmt.targets:
|
|
325
|
+
if isinstance(target, ast.Name):
|
|
326
|
+
name = target.id
|
|
327
|
+
if (
|
|
328
|
+
not name.startswith("_")
|
|
329
|
+
and name not in args_set
|
|
330
|
+
and name not in explicit_globals
|
|
331
|
+
and name not in loaded_names
|
|
332
|
+
and name not in seen_unused
|
|
333
|
+
):
|
|
334
|
+
seen_unused.add(name)
|
|
335
|
+
items.append(
|
|
336
|
+
DeadCodeItem(
|
|
337
|
+
filepath=filepath,
|
|
338
|
+
lineno=target.lineno,
|
|
339
|
+
end_lineno=getattr(target, "end_lineno", target.lineno),
|
|
340
|
+
name=name,
|
|
341
|
+
kind="variable",
|
|
342
|
+
reason=f"Local variable '{name}' is assigned in '{func_node.name}' but never used",
|
|
343
|
+
confidence="high",
|
|
344
|
+
)
|
|
345
|
+
)
|
|
346
|
+
elif isinstance(target, (ast.Tuple, ast.List)):
|
|
347
|
+
for elt in target.elts:
|
|
348
|
+
if isinstance(elt, ast.Name):
|
|
349
|
+
name = elt.id
|
|
350
|
+
if (
|
|
351
|
+
not name.startswith("_")
|
|
352
|
+
and name not in args_set
|
|
353
|
+
and name not in explicit_globals
|
|
354
|
+
and name not in loaded_names
|
|
355
|
+
and name not in seen_unused
|
|
356
|
+
):
|
|
357
|
+
seen_unused.add(name)
|
|
358
|
+
items.append(
|
|
359
|
+
DeadCodeItem(
|
|
360
|
+
filepath=filepath,
|
|
361
|
+
lineno=elt.lineno,
|
|
362
|
+
end_lineno=getattr(
|
|
363
|
+
elt, "end_lineno", elt.lineno
|
|
364
|
+
),
|
|
365
|
+
name=name,
|
|
366
|
+
kind="variable",
|
|
367
|
+
reason=f"Local variable '{name}' is assigned in '{func_node.name}' but never used",
|
|
368
|
+
confidence="high",
|
|
369
|
+
)
|
|
370
|
+
)
|
|
371
|
+
elif isinstance(stmt, ast.AnnAssign):
|
|
372
|
+
if isinstance(stmt.target, ast.Name):
|
|
373
|
+
name = stmt.target.id
|
|
374
|
+
if (
|
|
375
|
+
not name.startswith("_")
|
|
376
|
+
and name not in args_set
|
|
377
|
+
and name not in explicit_globals
|
|
378
|
+
and name not in loaded_names
|
|
379
|
+
and name not in seen_unused
|
|
380
|
+
):
|
|
381
|
+
seen_unused.add(name)
|
|
382
|
+
items.append(
|
|
383
|
+
DeadCodeItem(
|
|
384
|
+
filepath=filepath,
|
|
385
|
+
lineno=stmt.target.lineno,
|
|
386
|
+
end_lineno=getattr(
|
|
387
|
+
stmt.target, "end_lineno", stmt.target.lineno
|
|
388
|
+
),
|
|
389
|
+
name=name,
|
|
390
|
+
kind="variable",
|
|
391
|
+
reason=f"Local variable '{name}' is assigned in '{func_node.name}' but never used",
|
|
392
|
+
confidence="high",
|
|
393
|
+
)
|
|
394
|
+
)
|
|
395
|
+
return items
|
|
396
|
+
|
|
397
|
+
|
|
204
398
|
class _UnreachableCodeDetector(ast.NodeVisitor):
|
|
205
399
|
"""Detects code after unconditional return/raise/break/continue and empty branches."""
|
|
206
400
|
|
|
@@ -215,6 +409,7 @@ class _UnreachableCodeDetector(ast.NodeVisitor):
|
|
|
215
409
|
|
|
216
410
|
def visit_FunctionDef(self, node: ast.FunctionDef | ast.AsyncFunctionDef) -> None:
|
|
217
411
|
self._check_body(node.body)
|
|
412
|
+
self.items.extend(_detect_unused_locals_in_function(node, self.filepath))
|
|
218
413
|
self.generic_visit(node)
|
|
219
414
|
|
|
220
415
|
visit_AsyncFunctionDef = visit_FunctionDef
|
|
@@ -721,38 +916,380 @@ class _DeadCodePrunerCollector(ast.NodeVisitor):
|
|
|
721
916
|
|
|
722
917
|
|
|
723
918
|
class DeadCodeFixer:
|
|
724
|
-
"""Surgically eliminates unreachable statements, dead branches, and
|
|
919
|
+
"""Surgically eliminates unreachable statements, dead branches, heals empty except blocks, and fixes unused variables."""
|
|
725
920
|
|
|
726
921
|
def fix(self, source: str, filename: str = "<stdin>") -> DeadCodeFixResult:
|
|
727
922
|
current_code = source
|
|
728
923
|
all_pruned: list[str] = []
|
|
729
924
|
|
|
730
|
-
for _ in range(
|
|
925
|
+
for _ in range(3):
|
|
926
|
+
changed_this_pass = False
|
|
927
|
+
|
|
928
|
+
# --- Pass 1: Empty Except Healer ---
|
|
731
929
|
try:
|
|
732
930
|
tree = ast.parse(current_code, filename=filename)
|
|
733
931
|
except SyntaxError:
|
|
734
932
|
break
|
|
735
933
|
|
|
736
|
-
|
|
737
|
-
|
|
738
|
-
|
|
934
|
+
except_handlers_to_heal: list[tuple[int, int]] = []
|
|
935
|
+
for node in ast.walk(tree):
|
|
936
|
+
if isinstance(node, ast.ExceptHandler):
|
|
937
|
+
if len(node.body) == 1 and isinstance(node.body[0], ast.Pass):
|
|
938
|
+
pass_node = node.body[0]
|
|
939
|
+
except_handlers_to_heal.append((node.lineno, pass_node.lineno))
|
|
940
|
+
|
|
941
|
+
if except_handlers_to_heal:
|
|
942
|
+
lines = current_code.splitlines(keepends=True)
|
|
943
|
+
needs_logging_import = not _has_logging_import(tree)
|
|
944
|
+
|
|
945
|
+
# Sort by line number descending so replacements do not shift earlier lines
|
|
946
|
+
for handler_line, pass_line in sorted(
|
|
947
|
+
except_handlers_to_heal, key=lambda x: x[1], reverse=True
|
|
948
|
+
):
|
|
949
|
+
idx = pass_line - 1
|
|
950
|
+
if 0 <= idx < len(lines):
|
|
951
|
+
orig_line = lines[idx]
|
|
952
|
+
indent = orig_line[: len(orig_line) - len(orig_line.lstrip())]
|
|
953
|
+
lines[idx] = (
|
|
954
|
+
f"{indent}logging.getLogger(__name__).debug("
|
|
955
|
+
f"'Suppressed exception', exc_info=True)\n"
|
|
956
|
+
)
|
|
957
|
+
all_pruned.append(
|
|
958
|
+
f"Healed empty except block at line {handler_line} with debug logging"
|
|
959
|
+
)
|
|
960
|
+
changed_this_pass = True
|
|
961
|
+
|
|
962
|
+
if needs_logging_import and changed_this_pass:
|
|
963
|
+
insert_idx = 0
|
|
964
|
+
while insert_idx < len(lines) and (
|
|
965
|
+
lines[insert_idx].startswith("#!")
|
|
966
|
+
or "coding:" in lines[insert_idx]
|
|
967
|
+
or "coding=" in lines[insert_idx]
|
|
968
|
+
):
|
|
969
|
+
insert_idx += 1
|
|
970
|
+
|
|
971
|
+
doc_node = (
|
|
972
|
+
tree.body[0]
|
|
973
|
+
if tree.body
|
|
974
|
+
and isinstance(tree.body[0], ast.Expr)
|
|
975
|
+
and isinstance(tree.body[0].value, ast.Constant)
|
|
976
|
+
and isinstance(tree.body[0].value.value, str)
|
|
977
|
+
else None
|
|
978
|
+
)
|
|
979
|
+
if (
|
|
980
|
+
doc_node
|
|
981
|
+
and hasattr(doc_node, "end_lineno")
|
|
982
|
+
and doc_node.end_lineno is not None
|
|
983
|
+
):
|
|
984
|
+
insert_idx = max(insert_idx, doc_node.end_lineno)
|
|
985
|
+
|
|
986
|
+
for stmt in tree.body:
|
|
987
|
+
if (
|
|
988
|
+
isinstance(stmt, ast.ImportFrom)
|
|
989
|
+
and stmt.module == "__future__"
|
|
990
|
+
and hasattr(stmt, "end_lineno")
|
|
991
|
+
and stmt.end_lineno is not None
|
|
992
|
+
):
|
|
993
|
+
insert_idx = max(insert_idx, stmt.end_lineno)
|
|
994
|
+
|
|
995
|
+
lines.insert(insert_idx, "import logging\n")
|
|
996
|
+
all_pruned.append("Added 'import logging' for healed except block")
|
|
997
|
+
|
|
998
|
+
candidate = "".join(lines)
|
|
999
|
+
try:
|
|
1000
|
+
ast.parse(candidate, filename=filename)
|
|
1001
|
+
current_code = candidate
|
|
1002
|
+
except SyntaxError:
|
|
1003
|
+
logging.getLogger(__name__).debug(
|
|
1004
|
+
"Suppressed exception", exc_info=True
|
|
1005
|
+
)
|
|
1006
|
+
|
|
1007
|
+
# --- Pass 2: Dead Variable Fixer ---
|
|
1008
|
+
try:
|
|
1009
|
+
tree = ast.parse(current_code, filename=filename)
|
|
1010
|
+
except SyntaxError:
|
|
739
1011
|
break
|
|
740
1012
|
|
|
741
|
-
|
|
742
|
-
|
|
743
|
-
|
|
744
|
-
|
|
745
|
-
for
|
|
746
|
-
|
|
747
|
-
|
|
1013
|
+
renames: list[tuple[int, int, int, str, str]] = []
|
|
1014
|
+
deletions: list[tuple[int, int, list[ast.stmt], str]] = []
|
|
1015
|
+
|
|
1016
|
+
# 2a. Function-level local variables
|
|
1017
|
+
for node in ast.walk(tree):
|
|
1018
|
+
if isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef)):
|
|
1019
|
+
args_set: set[str] = set()
|
|
1020
|
+
for a in node.args.posonlyargs:
|
|
1021
|
+
args_set.add(a.arg)
|
|
1022
|
+
for a in node.args.args:
|
|
1023
|
+
args_set.add(a.arg)
|
|
1024
|
+
for a in node.args.kwonlyargs:
|
|
1025
|
+
args_set.add(a.arg)
|
|
1026
|
+
if node.args.vararg:
|
|
1027
|
+
args_set.add(node.args.vararg.arg)
|
|
1028
|
+
if node.args.kwarg:
|
|
1029
|
+
args_set.add(node.args.kwarg.arg)
|
|
1030
|
+
|
|
1031
|
+
explicit_globals: set[str] = set()
|
|
1032
|
+
has_dynamic = False
|
|
1033
|
+
loaded_names: set[str] = set()
|
|
1034
|
+
|
|
1035
|
+
for sub in ast.walk(node):
|
|
1036
|
+
if isinstance(sub, (ast.Global, ast.Nonlocal)):
|
|
1037
|
+
explicit_globals.update(sub.names)
|
|
1038
|
+
elif isinstance(sub, ast.Name) and isinstance(
|
|
1039
|
+
sub.ctx, ast.Load
|
|
1040
|
+
):
|
|
1041
|
+
loaded_names.add(sub.id)
|
|
1042
|
+
elif isinstance(sub, ast.Call):
|
|
1043
|
+
func = sub.func
|
|
1044
|
+
if isinstance(func, ast.Name) and func.id in (
|
|
1045
|
+
"locals",
|
|
1046
|
+
"vars",
|
|
1047
|
+
"eval",
|
|
1048
|
+
"exec",
|
|
1049
|
+
):
|
|
1050
|
+
has_dynamic = True
|
|
1051
|
+
|
|
1052
|
+
if has_dynamic:
|
|
1053
|
+
continue
|
|
748
1054
|
|
|
749
|
-
|
|
1055
|
+
stmts_in_scope = _walk_stmts_in_scope(node.body)
|
|
1056
|
+
for stmt, parent_body in stmts_in_scope:
|
|
1057
|
+
if isinstance(stmt, ast.Assign):
|
|
1058
|
+
if len(stmt.targets) == 1 and isinstance(
|
|
1059
|
+
stmt.targets[0], ast.Name
|
|
1060
|
+
):
|
|
1061
|
+
target = stmt.targets[0]
|
|
1062
|
+
name = target.id
|
|
1063
|
+
if (
|
|
1064
|
+
not name.startswith("_")
|
|
1065
|
+
and name not in args_set
|
|
1066
|
+
and name not in explicit_globals
|
|
1067
|
+
and name not in loaded_names
|
|
1068
|
+
):
|
|
1069
|
+
if _is_pure_expression(stmt.value):
|
|
1070
|
+
deletions.append(
|
|
1071
|
+
(
|
|
1072
|
+
stmt.lineno,
|
|
1073
|
+
getattr(
|
|
1074
|
+
stmt, "end_lineno", stmt.lineno
|
|
1075
|
+
),
|
|
1076
|
+
parent_body,
|
|
1077
|
+
name,
|
|
1078
|
+
)
|
|
1079
|
+
)
|
|
1080
|
+
else:
|
|
1081
|
+
if hasattr(target, "col_offset") and hasattr(
|
|
1082
|
+
target, "end_col_offset"
|
|
1083
|
+
):
|
|
1084
|
+
renames.append(
|
|
1085
|
+
(
|
|
1086
|
+
target.lineno,
|
|
1087
|
+
target.col_offset,
|
|
1088
|
+
target.end_col_offset,
|
|
1089
|
+
f"_{name}",
|
|
1090
|
+
name,
|
|
1091
|
+
)
|
|
1092
|
+
)
|
|
1093
|
+
elif len(stmt.targets) == 1 and isinstance(
|
|
1094
|
+
stmt.targets[0], (ast.Tuple, ast.List)
|
|
1095
|
+
):
|
|
1096
|
+
for elt in stmt.targets[0].elts:
|
|
1097
|
+
if isinstance(elt, ast.Name):
|
|
1098
|
+
name = elt.id
|
|
1099
|
+
if (
|
|
1100
|
+
not name.startswith("_")
|
|
1101
|
+
and name not in args_set
|
|
1102
|
+
and name not in explicit_globals
|
|
1103
|
+
and name not in loaded_names
|
|
1104
|
+
and hasattr(elt, "col_offset")
|
|
1105
|
+
and hasattr(elt, "end_col_offset")
|
|
1106
|
+
):
|
|
1107
|
+
renames.append(
|
|
1108
|
+
(
|
|
1109
|
+
elt.lineno,
|
|
1110
|
+
elt.col_offset,
|
|
1111
|
+
elt.end_col_offset,
|
|
1112
|
+
f"_{name}",
|
|
1113
|
+
name,
|
|
1114
|
+
)
|
|
1115
|
+
)
|
|
1116
|
+
elif isinstance(stmt, ast.AnnAssign):
|
|
1117
|
+
if isinstance(stmt.target, ast.Name):
|
|
1118
|
+
name = stmt.target.id
|
|
1119
|
+
if (
|
|
1120
|
+
not name.startswith("_")
|
|
1121
|
+
and name not in args_set
|
|
1122
|
+
and name not in explicit_globals
|
|
1123
|
+
and name not in loaded_names
|
|
1124
|
+
):
|
|
1125
|
+
if stmt.value is not None and _is_pure_expression(
|
|
1126
|
+
stmt.value
|
|
1127
|
+
):
|
|
1128
|
+
deletions.append(
|
|
1129
|
+
(
|
|
1130
|
+
stmt.lineno,
|
|
1131
|
+
getattr(
|
|
1132
|
+
stmt, "end_lineno", stmt.lineno
|
|
1133
|
+
),
|
|
1134
|
+
parent_body,
|
|
1135
|
+
name,
|
|
1136
|
+
)
|
|
1137
|
+
)
|
|
1138
|
+
else:
|
|
1139
|
+
target = stmt.target
|
|
1140
|
+
if hasattr(target, "col_offset") and hasattr(
|
|
1141
|
+
target, "end_col_offset"
|
|
1142
|
+
):
|
|
1143
|
+
renames.append(
|
|
1144
|
+
(
|
|
1145
|
+
target.lineno,
|
|
1146
|
+
target.col_offset,
|
|
1147
|
+
target.end_col_offset,
|
|
1148
|
+
f"_{name}",
|
|
1149
|
+
name,
|
|
1150
|
+
)
|
|
1151
|
+
)
|
|
1152
|
+
|
|
1153
|
+
# 2b. Module-level script variables (when not an __init__.py package export)
|
|
1154
|
+
if not filename.endswith("__init__.py"):
|
|
1155
|
+
module_loads: set[str] = set()
|
|
1156
|
+
module_exports: set[str] = set()
|
|
1157
|
+
for sub in ast.walk(tree):
|
|
1158
|
+
if isinstance(sub, ast.Name) and isinstance(sub.ctx, ast.Load):
|
|
1159
|
+
module_loads.add(sub.id)
|
|
1160
|
+
elif (
|
|
1161
|
+
isinstance(sub, ast.Assign)
|
|
1162
|
+
and any(
|
|
1163
|
+
isinstance(t, ast.Name) and t.id == "__all__"
|
|
1164
|
+
for t in sub.targets
|
|
1165
|
+
)
|
|
1166
|
+
and isinstance(sub.value, (ast.List, ast.Tuple, ast.Set))
|
|
1167
|
+
):
|
|
1168
|
+
for elt in sub.value.elts:
|
|
1169
|
+
if isinstance(elt, ast.Constant) and isinstance(
|
|
1170
|
+
elt.value, str
|
|
1171
|
+
):
|
|
1172
|
+
module_exports.add(elt.value)
|
|
1173
|
+
|
|
1174
|
+
for stmt in tree.body:
|
|
1175
|
+
if (
|
|
1176
|
+
isinstance(stmt, ast.Assign)
|
|
1177
|
+
and len(stmt.targets) == 1
|
|
1178
|
+
and isinstance(stmt.targets[0], ast.Name)
|
|
1179
|
+
):
|
|
1180
|
+
target = stmt.targets[0]
|
|
1181
|
+
name = target.id
|
|
1182
|
+
if (
|
|
1183
|
+
not name.startswith("_")
|
|
1184
|
+
and name != "__all__"
|
|
1185
|
+
and name not in module_loads
|
|
1186
|
+
and name not in module_exports
|
|
1187
|
+
):
|
|
1188
|
+
if _is_pure_expression(stmt.value):
|
|
1189
|
+
deletions.append(
|
|
1190
|
+
(
|
|
1191
|
+
stmt.lineno,
|
|
1192
|
+
getattr(stmt, "end_lineno", stmt.lineno),
|
|
1193
|
+
tree.body,
|
|
1194
|
+
name,
|
|
1195
|
+
)
|
|
1196
|
+
)
|
|
1197
|
+
else:
|
|
1198
|
+
if hasattr(target, "col_offset") and hasattr(
|
|
1199
|
+
target, "end_col_offset"
|
|
1200
|
+
):
|
|
1201
|
+
renames.append(
|
|
1202
|
+
(
|
|
1203
|
+
target.lineno,
|
|
1204
|
+
target.col_offset,
|
|
1205
|
+
target.end_col_offset,
|
|
1206
|
+
f"_{name}",
|
|
1207
|
+
name,
|
|
1208
|
+
)
|
|
1209
|
+
)
|
|
1210
|
+
|
|
1211
|
+
if renames or deletions:
|
|
1212
|
+
lines = current_code.splitlines(keepends=True)
|
|
1213
|
+
|
|
1214
|
+
# 1. Apply renames line-by-line, ordered by column descending
|
|
1215
|
+
renames_by_line: dict[int, list[tuple[int, int, str, str]]] = {}
|
|
1216
|
+
for lineno, col, end_col, new_text, name in renames:
|
|
1217
|
+
renames_by_line.setdefault(lineno, []).append(
|
|
1218
|
+
(col, end_col, new_text, name)
|
|
1219
|
+
)
|
|
1220
|
+
|
|
1221
|
+
for lineno, line_renames in renames_by_line.items():
|
|
1222
|
+
idx = lineno - 1
|
|
1223
|
+
if 0 <= idx < len(lines):
|
|
1224
|
+
line_str = lines[idx]
|
|
1225
|
+
for col, end_col, new_text, name in sorted(
|
|
1226
|
+
line_renames, key=lambda x: x[0], reverse=True
|
|
1227
|
+
):
|
|
1228
|
+
line_str = line_str[:col] + new_text + line_str[end_col:]
|
|
1229
|
+
all_pruned.append(
|
|
1230
|
+
f"Prefixed unused variable '{name}' with '_' at line {lineno} to preserve side effects"
|
|
1231
|
+
)
|
|
1232
|
+
changed_this_pass = True
|
|
1233
|
+
lines[idx] = line_str
|
|
1234
|
+
|
|
1235
|
+
# 2. Apply deletions ordered by start line descending
|
|
1236
|
+
for start_line, end_line, parent_body, name in sorted(
|
|
1237
|
+
deletions, key=lambda x: x[0], reverse=True
|
|
1238
|
+
):
|
|
1239
|
+
start_idx = start_line - 1
|
|
1240
|
+
if 0 <= start_idx < len(lines):
|
|
1241
|
+
if len(parent_body) == 1:
|
|
1242
|
+
orig_line = lines[start_idx]
|
|
1243
|
+
indent = orig_line[
|
|
1244
|
+
: len(orig_line) - len(orig_line.lstrip())
|
|
1245
|
+
]
|
|
1246
|
+
lines[start_idx:end_line] = [f"{indent}pass\n"]
|
|
1247
|
+
else:
|
|
1248
|
+
del lines[start_idx:end_line]
|
|
1249
|
+
all_pruned.append(
|
|
1250
|
+
f"Pruned unused local variable '{name}' assignment at line {start_line}"
|
|
1251
|
+
)
|
|
1252
|
+
changed_this_pass = True
|
|
1253
|
+
|
|
1254
|
+
candidate = "".join(lines)
|
|
1255
|
+
try:
|
|
1256
|
+
ast.parse(candidate, filename=filename)
|
|
1257
|
+
current_code = candidate
|
|
1258
|
+
except SyntaxError:
|
|
1259
|
+
logging.getLogger(__name__).debug(
|
|
1260
|
+
"Suppressed exception", exc_info=True
|
|
1261
|
+
)
|
|
1262
|
+
|
|
1263
|
+
# --- Pass 3: Unreachable Code & Redundant Pass ---
|
|
750
1264
|
try:
|
|
751
|
-
ast.parse(
|
|
752
|
-
current_code = candidate
|
|
1265
|
+
tree = ast.parse(current_code, filename=filename)
|
|
753
1266
|
except SyntaxError:
|
|
754
1267
|
break
|
|
755
1268
|
|
|
1269
|
+
collector = _DeadCodePrunerCollector()
|
|
1270
|
+
collector.visit(tree)
|
|
1271
|
+
if collector.deletions:
|
|
1272
|
+
lines = current_code.splitlines(keepends=True)
|
|
1273
|
+
sorted_deletions = sorted(
|
|
1274
|
+
collector.deletions, key=lambda x: x[0], reverse=True
|
|
1275
|
+
)
|
|
1276
|
+
for start_line, end_line, desc in sorted_deletions:
|
|
1277
|
+
del lines[start_line - 1 : end_line]
|
|
1278
|
+
all_pruned.append(desc)
|
|
1279
|
+
changed_this_pass = True
|
|
1280
|
+
|
|
1281
|
+
candidate = "".join(lines)
|
|
1282
|
+
try:
|
|
1283
|
+
ast.parse(candidate, filename=filename)
|
|
1284
|
+
current_code = candidate
|
|
1285
|
+
except SyntaxError:
|
|
1286
|
+
logging.getLogger(__name__).debug(
|
|
1287
|
+
"Suppressed exception", exc_info=True
|
|
1288
|
+
)
|
|
1289
|
+
|
|
1290
|
+
if not changed_this_pass:
|
|
1291
|
+
break
|
|
1292
|
+
|
|
756
1293
|
return DeadCodeFixResult(
|
|
757
1294
|
code=current_code,
|
|
758
1295
|
changed=current_code != source,
|
pycleaner/dependency_auditor.py
CHANGED
|
@@ -10,6 +10,7 @@ from __future__ import annotations
|
|
|
10
10
|
|
|
11
11
|
import ast
|
|
12
12
|
import importlib.metadata
|
|
13
|
+
import logging
|
|
13
14
|
import re
|
|
14
15
|
import shutil
|
|
15
16
|
import sys
|
|
@@ -31,6 +32,7 @@ class DependencyAuditReport:
|
|
|
31
32
|
missing_packages: set[str] = field(default_factory=set)
|
|
32
33
|
unused_packages: set[str] = field(default_factory=set)
|
|
33
34
|
fixed_requirements: bool = False
|
|
35
|
+
fixed_pyproject: bool = False
|
|
34
36
|
details: list[str] = field(default_factory=list)
|
|
35
37
|
|
|
36
38
|
|
|
@@ -99,7 +101,7 @@ class DependencyAuditor:
|
|
|
99
101
|
try:
|
|
100
102
|
dists.update(importlib.metadata.packages_distributions())
|
|
101
103
|
except AttributeError:
|
|
102
|
-
|
|
104
|
+
logging.getLogger(__name__).debug("Suppressed exception", exc_info=True)
|
|
103
105
|
|
|
104
106
|
site_packages = self._find_venv_site_packages()
|
|
105
107
|
if site_packages and site_packages.is_dir():
|
|
@@ -116,9 +118,11 @@ class DependencyAuditor:
|
|
|
116
118
|
if top_mod:
|
|
117
119
|
dists.setdefault(top_mod, []).insert(0, dist_name)
|
|
118
120
|
except OSError:
|
|
119
|
-
|
|
121
|
+
logging.getLogger(__name__).debug(
|
|
122
|
+
"Suppressed exception", exc_info=True
|
|
123
|
+
)
|
|
120
124
|
except OSError:
|
|
121
|
-
|
|
125
|
+
logging.getLogger(__name__).debug("Suppressed exception", exc_info=True)
|
|
122
126
|
|
|
123
127
|
return dists
|
|
124
128
|
|
|
@@ -226,7 +230,7 @@ class DependencyAuditor:
|
|
|
226
230
|
local_mods.add(str(proj_name))
|
|
227
231
|
local_mods.add(str(proj_name).replace("-", "_"))
|
|
228
232
|
except Exception:
|
|
229
|
-
|
|
233
|
+
logging.getLogger(__name__).debug("Suppressed exception", exc_info=True)
|
|
230
234
|
|
|
231
235
|
# 3. Discover local .py files and subpackages (including PEP 420 namespace packages)
|
|
232
236
|
search_dirs = [self.root_dir]
|
|
@@ -331,7 +335,7 @@ class DependencyAuditor:
|
|
|
331
335
|
declared[canon] = dep
|
|
332
336
|
optional_pkgs.add(canon)
|
|
333
337
|
except Exception:
|
|
334
|
-
|
|
338
|
+
logging.getLogger(__name__).debug("Suppressed exception", exc_info=True)
|
|
335
339
|
return declared, optional_pkgs
|
|
336
340
|
|
|
337
341
|
def read_declared_dependencies(self) -> dict[str, str]:
|
|
@@ -374,7 +378,10 @@ class DependencyAuditor:
|
|
|
374
378
|
|
|
375
379
|
@staticmethod
|
|
376
380
|
def _build_audit_details(
|
|
377
|
-
missing: set[str],
|
|
381
|
+
missing: set[str],
|
|
382
|
+
unused: set[str],
|
|
383
|
+
fixed_reqs: bool,
|
|
384
|
+
fixed_pyproj: bool = False,
|
|
378
385
|
) -> list[str]:
|
|
379
386
|
details: list[str] = []
|
|
380
387
|
if missing:
|
|
@@ -385,10 +392,14 @@ class DependencyAuditor:
|
|
|
385
392
|
details.append(
|
|
386
393
|
f"Unused dependencies (declared but not imported): {', '.join(sorted(unused))}"
|
|
387
394
|
)
|
|
388
|
-
if
|
|
395
|
+
if fixed_reqs:
|
|
389
396
|
details.append(
|
|
390
397
|
"Updated requirements.txt successfully (backup saved as requirements.txt.bak)"
|
|
391
398
|
)
|
|
399
|
+
if fixed_pyproj:
|
|
400
|
+
details.append(
|
|
401
|
+
"Updated pyproject.toml successfully (backup saved as pyproject.toml.bak)"
|
|
402
|
+
)
|
|
392
403
|
return details
|
|
393
404
|
|
|
394
405
|
def audit(
|
|
@@ -397,7 +408,7 @@ class DependencyAuditor:
|
|
|
397
408
|
prune_unused: bool = False,
|
|
398
409
|
target_files: Sequence[Path] | None = None,
|
|
399
410
|
) -> DependencyAuditReport:
|
|
400
|
-
"""Audit dependencies and optionally update requirements.txt."""
|
|
411
|
+
"""Audit dependencies and optionally update requirements.txt and pyproject.toml."""
|
|
401
412
|
all_imports = self.scan_codebase_imports(target_files=target_files)
|
|
402
413
|
local_mods = self.identify_local_modules()
|
|
403
414
|
|
|
@@ -414,13 +425,18 @@ class DependencyAuditor:
|
|
|
414
425
|
)
|
|
415
426
|
|
|
416
427
|
fixed_reqs = False
|
|
428
|
+
fixed_pyproj = False
|
|
417
429
|
if fix and (missing_packages or (prune_unused and unused_packages)):
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
|
|
430
|
+
to_remove = unused_packages if prune_unused else set()
|
|
431
|
+
if (self.root_dir / "pyproject.toml").is_file():
|
|
432
|
+
fixed_pyproj = self._update_pyproject_toml(missing_packages, to_remove)
|
|
433
|
+
if (self.root_dir / "requirements.txt").is_file():
|
|
434
|
+
fixed_reqs = self._update_requirements(missing_packages, to_remove)
|
|
435
|
+
elif not fixed_pyproj and missing_packages:
|
|
436
|
+
fixed_reqs = self._update_requirements(missing_packages, set())
|
|
421
437
|
|
|
422
438
|
details = self._build_audit_details(
|
|
423
|
-
missing_packages, unused_packages, fixed_reqs
|
|
439
|
+
missing_packages, unused_packages, fixed_reqs, fixed_pyproj
|
|
424
440
|
)
|
|
425
441
|
|
|
426
442
|
return DependencyAuditReport(
|
|
@@ -429,10 +445,109 @@ class DependencyAuditor:
|
|
|
429
445
|
required_packages=set(declared.keys()),
|
|
430
446
|
missing_packages=missing_packages,
|
|
431
447
|
unused_packages=unused_packages,
|
|
432
|
-
fixed_requirements=fixed_reqs,
|
|
448
|
+
fixed_requirements=fixed_reqs or fixed_pyproj,
|
|
449
|
+
fixed_pyproject=fixed_pyproj,
|
|
433
450
|
details=details,
|
|
434
451
|
)
|
|
435
452
|
|
|
453
|
+
def _update_pyproject_toml(
|
|
454
|
+
self, missing: set[str], unused_to_remove: set[str]
|
|
455
|
+
) -> bool:
|
|
456
|
+
"""Prune unused dependencies and append missing ones in pyproject.toml with safety checks."""
|
|
457
|
+
pyproject_file = self.root_dir / "pyproject.toml"
|
|
458
|
+
if not pyproject_file.is_file():
|
|
459
|
+
return False
|
|
460
|
+
|
|
461
|
+
content = pyproject_file.read_text(encoding="utf-8", errors="ignore")
|
|
462
|
+
remove_canons = {
|
|
463
|
+
self.canonicalize_name(m.group(1))
|
|
464
|
+
for u in unused_to_remove
|
|
465
|
+
if (m := re.match(r"^([a-zA-Z0-9_.-]+)", u))
|
|
466
|
+
}
|
|
467
|
+
local_canons = {
|
|
468
|
+
self.canonicalize_name(m) for m in self.identify_local_modules()
|
|
469
|
+
}
|
|
470
|
+
safe_missing = {
|
|
471
|
+
pkg for pkg in missing if self.canonicalize_name(pkg) not in local_canons
|
|
472
|
+
}
|
|
473
|
+
|
|
474
|
+
if not remove_canons and not safe_missing:
|
|
475
|
+
return False
|
|
476
|
+
|
|
477
|
+
lines = content.splitlines(keepends=True)
|
|
478
|
+
new_lines: list[str] = []
|
|
479
|
+
in_dependencies_array = False
|
|
480
|
+
modified = False
|
|
481
|
+
|
|
482
|
+
for line in lines:
|
|
483
|
+
stripped = line.strip()
|
|
484
|
+
|
|
485
|
+
if re.match(r"^dependencies\s*=\s*\[", stripped):
|
|
486
|
+
if "]" in stripped:
|
|
487
|
+
bracket_content = stripped[
|
|
488
|
+
stripped.find("[") + 1 : stripped.rfind("]")
|
|
489
|
+
]
|
|
490
|
+
items = [
|
|
491
|
+
it.strip() for it in bracket_content.split(",") if it.strip()
|
|
492
|
+
]
|
|
493
|
+
kept_items: list[str] = []
|
|
494
|
+
for item in items:
|
|
495
|
+
m = re.search(r'["\']([a-zA-Z0-9_.-]+)', item)
|
|
496
|
+
if m and self.canonicalize_name(m.group(1)) in remove_canons:
|
|
497
|
+
modified = True
|
|
498
|
+
continue
|
|
499
|
+
kept_items.append(item)
|
|
500
|
+
for sm in sorted(safe_missing):
|
|
501
|
+
kept_items.append(f'"{sm}"')
|
|
502
|
+
modified = True
|
|
503
|
+
indent = line[: len(line) - len(line.lstrip())]
|
|
504
|
+
new_lines.append(
|
|
505
|
+
f"{indent}dependencies = [{', '.join(kept_items)}]\n"
|
|
506
|
+
)
|
|
507
|
+
continue
|
|
508
|
+
else:
|
|
509
|
+
in_dependencies_array = True
|
|
510
|
+
new_lines.append(line)
|
|
511
|
+
continue
|
|
512
|
+
|
|
513
|
+
if in_dependencies_array:
|
|
514
|
+
if "]" in stripped:
|
|
515
|
+
in_dependencies_array = False
|
|
516
|
+
for sm in sorted(safe_missing):
|
|
517
|
+
indent = " "
|
|
518
|
+
new_lines.append(f'{indent}"{sm}",\n')
|
|
519
|
+
modified = True
|
|
520
|
+
new_lines.append(line)
|
|
521
|
+
continue
|
|
522
|
+
|
|
523
|
+
pkg_match = re.search(r'["\']([a-zA-Z0-9_.-]+)', stripped)
|
|
524
|
+
if pkg_match:
|
|
525
|
+
canon = self.canonicalize_name(pkg_match.group(1))
|
|
526
|
+
if canon in remove_canons:
|
|
527
|
+
modified = True
|
|
528
|
+
continue
|
|
529
|
+
new_lines.append(line)
|
|
530
|
+
continue
|
|
531
|
+
|
|
532
|
+
poetry_pkg_match = re.match(r"^([a-zA-Z0-9_.-]+)\s*=", stripped)
|
|
533
|
+
if poetry_pkg_match:
|
|
534
|
+
canon = self.canonicalize_name(poetry_pkg_match.group(1))
|
|
535
|
+
if canon in remove_canons and canon != "python":
|
|
536
|
+
modified = True
|
|
537
|
+
continue
|
|
538
|
+
|
|
539
|
+
new_lines.append(line)
|
|
540
|
+
|
|
541
|
+
if modified:
|
|
542
|
+
backup_file = pyproject_file.with_suffix(".toml.bak")
|
|
543
|
+
try:
|
|
544
|
+
shutil.copy2(pyproject_file, backup_file)
|
|
545
|
+
except OSError:
|
|
546
|
+
logging.getLogger(__name__).debug("Suppressed exception", exc_info=True)
|
|
547
|
+
pyproject_file.write_text("".join(new_lines), encoding="utf-8")
|
|
548
|
+
return True
|
|
549
|
+
return False
|
|
550
|
+
|
|
436
551
|
def _update_requirements(
|
|
437
552
|
self, missing: set[str], unused_to_remove: set[str]
|
|
438
553
|
) -> bool:
|
|
@@ -445,7 +560,7 @@ class DependencyAuditor:
|
|
|
445
560
|
try:
|
|
446
561
|
shutil.copy2(req_file, backup_file)
|
|
447
562
|
except OSError:
|
|
448
|
-
|
|
563
|
+
logging.getLogger(__name__).debug("Suppressed exception", exc_info=True)
|
|
449
564
|
existing_lines = req_file.read_text(
|
|
450
565
|
encoding="utf-8", errors="ignore"
|
|
451
566
|
).splitlines()
|
pycleaner/linter_formatter.py
CHANGED
|
@@ -9,6 +9,7 @@ fallbacks via autoflake, isort, black, and built-in pure-Python AST pruning.
|
|
|
9
9
|
from __future__ import annotations
|
|
10
10
|
|
|
11
11
|
import ast
|
|
12
|
+
import logging
|
|
12
13
|
import re
|
|
13
14
|
import shutil
|
|
14
15
|
import subprocess
|
|
@@ -169,7 +170,7 @@ class LinterFormatter:
|
|
|
169
170
|
return output, True
|
|
170
171
|
except OSError:
|
|
171
172
|
# Fall back to pure-Python import pruning if autoflake CLI fails
|
|
172
|
-
|
|
173
|
+
logging.getLogger(__name__).debug("Suppressed exception", exc_info=True)
|
|
173
174
|
return source, False
|
|
174
175
|
|
|
175
176
|
def _isort_cli_sort(self, code: str) -> str | None:
|
|
@@ -187,7 +188,7 @@ class LinterFormatter:
|
|
|
187
188
|
return res if res != code else None
|
|
188
189
|
except OSError:
|
|
189
190
|
# Fall back to isort Python module or pure-Python import sorter
|
|
190
|
-
|
|
191
|
+
logging.getLogger(__name__).debug("Suppressed exception", exc_info=True)
|
|
191
192
|
return None
|
|
192
193
|
|
|
193
194
|
def _isort_module_sort(self, code: str) -> str | None:
|
|
@@ -269,7 +270,7 @@ class LinterFormatter:
|
|
|
269
270
|
return res, res != source
|
|
270
271
|
except OSError:
|
|
271
272
|
# Fall back to Black or pure-Python formatter if ruff CLI execution fails
|
|
272
|
-
|
|
273
|
+
logging.getLogger(__name__).debug("Suppressed exception", exc_info=True)
|
|
273
274
|
return None
|
|
274
275
|
|
|
275
276
|
def _format_with_black(self, source: str) -> tuple[str, bool, list[str]] | None:
|
|
@@ -286,7 +287,7 @@ class LinterFormatter:
|
|
|
286
287
|
return res, res != source, ["Formatted with black CLI fallback"]
|
|
287
288
|
except OSError:
|
|
288
289
|
# Fall back to black module or pure-Python formatter
|
|
289
|
-
|
|
290
|
+
logging.getLogger(__name__).debug("Suppressed exception", exc_info=True)
|
|
290
291
|
try:
|
|
291
292
|
import black # type: ignore
|
|
292
293
|
|
|
@@ -443,7 +444,7 @@ class LinterFormatter:
|
|
|
443
444
|
)
|
|
444
445
|
except SyntaxError:
|
|
445
446
|
# Return unmodified source if candidate code has syntax issues
|
|
446
|
-
|
|
447
|
+
logging.getLogger(__name__).debug("Suppressed exception", exc_info=True)
|
|
447
448
|
return source, False, []
|
|
448
449
|
|
|
449
450
|
def _pure_python_sort_imports(self, source: str) -> tuple[str, bool, list[str]]:
|
pycleaner/modernizer.py
CHANGED
|
@@ -14,6 +14,7 @@ from __future__ import annotations
|
|
|
14
14
|
|
|
15
15
|
import ast
|
|
16
16
|
import io
|
|
17
|
+
import logging
|
|
17
18
|
import re
|
|
18
19
|
import tokenize
|
|
19
20
|
from dataclasses import dataclass, field
|
|
@@ -414,7 +415,7 @@ class Modernizer:
|
|
|
414
415
|
):
|
|
415
416
|
insert_idx = getattr(tree.body[0], "end_lineno", 1)
|
|
416
417
|
except SyntaxError:
|
|
417
|
-
|
|
418
|
+
logging.getLogger(__name__).debug("Suppressed exception", exc_info=True)
|
|
418
419
|
|
|
419
420
|
future_stmt = "from __future__ import annotations\n\n"
|
|
420
421
|
if insert_idx < len(lines):
|
pycleaner/security_scanner.py
CHANGED
|
@@ -9,6 +9,7 @@ and assert statements used for input validation.
|
|
|
9
9
|
from __future__ import annotations
|
|
10
10
|
|
|
11
11
|
import ast
|
|
12
|
+
import logging
|
|
12
13
|
import re
|
|
13
14
|
from collections.abc import Sequence
|
|
14
15
|
from dataclasses import dataclass, field
|
|
@@ -524,7 +525,7 @@ class SecurityScanner:
|
|
|
524
525
|
findings.extend(detector.findings)
|
|
525
526
|
except SyntaxError:
|
|
526
527
|
# Code with syntax errors cannot be AST-parsed; regex checks still run
|
|
527
|
-
|
|
528
|
+
logging.getLogger(__name__).debug("Suppressed exception", exc_info=True)
|
|
528
529
|
|
|
529
530
|
findings.extend(self._detect_secrets(source, filename))
|
|
530
531
|
|
pycleaner/syntax_healer.py
CHANGED
|
@@ -9,6 +9,7 @@ from __future__ import annotations
|
|
|
9
9
|
|
|
10
10
|
import ast
|
|
11
11
|
import io
|
|
12
|
+
import logging
|
|
12
13
|
import re
|
|
13
14
|
import tokenize
|
|
14
15
|
from collections.abc import Sequence
|
|
@@ -695,7 +696,7 @@ class SyntaxHealer:
|
|
|
695
696
|
stack.pop()
|
|
696
697
|
except tokenize.TokenError:
|
|
697
698
|
# Incomplete token stream will be healed by delimiter reconstruction
|
|
698
|
-
|
|
699
|
+
logging.getLogger(__name__).debug("Suppressed exception", exc_info=True)
|
|
699
700
|
return stack
|
|
700
701
|
|
|
701
702
|
@staticmethod
|
pycleaner/verifier.py
CHANGED
|
@@ -15,6 +15,7 @@ import ast
|
|
|
15
15
|
import dataclasses
|
|
16
16
|
import enum
|
|
17
17
|
import inspect
|
|
18
|
+
import logging
|
|
18
19
|
import multiprocessing as mp
|
|
19
20
|
import random
|
|
20
21
|
import sys
|
|
@@ -211,12 +212,12 @@ def _worker_differential_fuzz(
|
|
|
211
212
|
}
|
|
212
213
|
)
|
|
213
214
|
except Exception:
|
|
214
|
-
|
|
215
|
+
logging.getLogger(__name__).debug("Suppressed exception", exc_info=True)
|
|
215
216
|
finally:
|
|
216
217
|
try:
|
|
217
218
|
conn.close()
|
|
218
219
|
except Exception:
|
|
219
|
-
|
|
220
|
+
logging.getLogger(__name__).debug("Suppressed exception", exc_info=True)
|
|
220
221
|
|
|
221
222
|
|
|
222
223
|
class DeterministicInputSynthesizer:
|
|
File without changes
|
|
File without changes
|
|
File without changes
|