ballpython 2.0.5__py3-none-any.whl → 2.0.7__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/__init__.py +7 -3
- {ballpython-2.0.5.dist-info → ballpython-2.0.7.dist-info}/METADATA +1 -1
- ballpython-2.0.7.dist-info/RECORD +34 -0
- pycleaner/__init__.py +5 -0
- pycleaner/baseline.py +1 -1
- pycleaner/cli.py +22 -23
- pycleaner/complexity_analyzer.py +1 -1
- pycleaner/dead_code_detector.py +9 -1
- pycleaner/dependency_auditor.py +64 -9
- pycleaner/frameworks/__init__.py +1 -1
- pycleaner/frameworks/plugins.py +0 -1
- pycleaner/import_resolver.py +32 -14
- pycleaner/linter_formatter.py +10 -9
- pycleaner/modernizer.py +84 -8
- pycleaner/security_scanner.py +3 -1
- pycleaner/syntax_healer.py +22 -9
- pycleaner/taint_engine.py +16 -5
- pycleaner/test_generator.py +1 -1
- pycleaner/type_checker.py +116 -19
- pycleaner/verifier.py +41 -15
- ballpython-2.0.5.dist-info/RECORD +0 -34
- {ballpython-2.0.5.dist-info → ballpython-2.0.7.dist-info}/WHEEL +0 -0
- {ballpython-2.0.5.dist-info → ballpython-2.0.7.dist-info}/entry_points.txt +0 -0
- {ballpython-2.0.5.dist-info → ballpython-2.0.7.dist-info}/top_level.txt +0 -0
ballpython/__init__.py
CHANGED
|
@@ -2,8 +2,12 @@
|
|
|
2
2
|
|
|
3
3
|
from __future__ import annotations
|
|
4
4
|
|
|
5
|
+
from typing import Any
|
|
6
|
+
|
|
5
7
|
import pycleaner
|
|
6
|
-
from pycleaner import
|
|
8
|
+
from pycleaner import __all__ as __all__
|
|
9
|
+
from pycleaner import __version__ as __version__
|
|
10
|
+
|
|
7
11
|
|
|
8
|
-
|
|
9
|
-
|
|
12
|
+
def __getattr__(name: str) -> Any:
|
|
13
|
+
return getattr(pycleaner, name)
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
ballpython/__init__.py,sha256=GSB-LHdou1Un6DS6Zmh8tv_zrwh6HQHO3Py2z8GO39o,304
|
|
2
|
+
ballpython/__main__.py,sha256=aCRr0vnZfKYHDrPHBsYSjX5xTxtFJpTTSPVUSZTBK-I,136
|
|
3
|
+
ballpython/cli.py,sha256=LllCLRAQQ-fRR_lIXwmd2e3Q7DLCIvf-5jHBXsAhJWs,125
|
|
4
|
+
ballpython/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
5
|
+
pycleaner/__init__.py,sha256=VzmbjUip6i0wpKh-s6XBwgBkIeUgI1duGyKDIiv7Pls,3042
|
|
6
|
+
pycleaner/__main__.py,sha256=8QBFrPyxId563F6cipPMNVEBTG8TIZUgxQfp8gk1VSI,135
|
|
7
|
+
pycleaner/baseline.py,sha256=ljED3x3akie0SB3fzhVno_1UHlI2id-9vta0NIGC0mw,3857
|
|
8
|
+
pycleaner/cache.py,sha256=WhCiMkuL6ryWVr3l3XrYLLoI7A3bRF8jNxpOyedNW8M,10761
|
|
9
|
+
pycleaner/cli.py,sha256=ELGV5hzerbER4uSNmioKCRDpMUYWd6HjJnwVgUyjfE0,72953
|
|
10
|
+
pycleaner/complexity_analyzer.py,sha256=j2ktzgR-isWE4xWMe56N3JFLLuYCi-S84BIlRBe0v-I,14975
|
|
11
|
+
pycleaner/config.py,sha256=XhloyaY_9pdwbQuc3QaAPCerLeXz-i1da4xT50h-pjY,8514
|
|
12
|
+
pycleaner/dead_code_detector.py,sha256=4JhZujswvPIcYbF_is3_umTGf6q5Iu6iDWbmRQsDy7s,26984
|
|
13
|
+
pycleaner/dependency_auditor.py,sha256=CsNMboUdB5Ug-xaeajAykMBCpUzCXQDCCbwwVjt5awk,18456
|
|
14
|
+
pycleaner/discovery.py,sha256=dt3OmHg886hy_Z0p80ccMyDkITQxz4biaJY_zB3MU_g,6151
|
|
15
|
+
pycleaner/explanations.py,sha256=2OOU_l3YL5o_Br_6fP6MF8HlY75-1JRmw3eTYT2lu8M,14219
|
|
16
|
+
pycleaner/import_resolver.py,sha256=Wgrch335AQVsnDv5-7F0l4Zm2oQPkNebPEgTRa7ayAg,32855
|
|
17
|
+
pycleaner/linter_formatter.py,sha256=gGl4sLTC5CcuTVHC2QkrkqGaqS_HJ9uUa1TFS5qkmoU,21001
|
|
18
|
+
pycleaner/modernizer.py,sha256=WpOLNYPDxKCAe3fj5tF4iHliR-8nzvjHU79SVZYWepk,16797
|
|
19
|
+
pycleaner/pipeline.py,sha256=ZG7_9PNz0jBadFrZoiI7juLtUJXglGQhLgQhrLJqEac,20612
|
|
20
|
+
pycleaner/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
21
|
+
pycleaner/security_scanner.py,sha256=ik485wW_aDbjV2X7HVPQy8uArkj406Jnn_Me78Og_n8,23444
|
|
22
|
+
pycleaner/syntax_healer.py,sha256=RLuUjxMLqgHYzllX_juriJxIgaQv8e0cQIryVvzZeOE,27325
|
|
23
|
+
pycleaner/taint_engine.py,sha256=-bBq38YoQgbO2FHCDxDctZpXWQTqwuS56CEWFNOY000,26870
|
|
24
|
+
pycleaner/test_generator.py,sha256=MM3t0Dx5lyJB5s144IKXq2tXrE8ohKcSvicPR3c9MlU,19680
|
|
25
|
+
pycleaner/type_checker.py,sha256=zkUqi-BNLUzJT0tIECNh7_7GP29Y7_6fMXhVuguwMKg,37881
|
|
26
|
+
pycleaner/typeshed_resolver.py,sha256=N-kWU_CI5DG59lQMkt7wMW9sHYjNCBdfFS42b4y2Kr4,12742
|
|
27
|
+
pycleaner/verifier.py,sha256=IEopcBfPLQXWOJxKV297150lpXBd3uGe7iv6CODJr6k,19305
|
|
28
|
+
pycleaner/frameworks/__init__.py,sha256=SOuSRl39af86eoQx7Qm8rTJGD4jsnEvgyhhQ6cVmRVA,3462
|
|
29
|
+
pycleaner/frameworks/plugins.py,sha256=qunMzS3q7xp1GEGxqkuhrnm-raiSRhYQMzmDxsEhSUg,11452
|
|
30
|
+
ballpython-2.0.7.dist-info/METADATA,sha256=RNLcZfpGvUsfStSio5seuuMfq9p3YGWn86Z0IstIk6o,3466
|
|
31
|
+
ballpython-2.0.7.dist-info/WHEEL,sha256=YVMoNqKzERt-wjUZwJ33xBGAwnFl-4cqbYkTtWa4itE,91
|
|
32
|
+
ballpython-2.0.7.dist-info/entry_points.txt,sha256=6ux-5UxoVSmlK5p9M-GDVt24sCdwTQq9wgRDkpMIYU8,81
|
|
33
|
+
ballpython-2.0.7.dist-info/top_level.txt,sha256=u6lmy8RWZ_saxCZAQTL7EijB4sunegOiVyk5fHjpwLk,21
|
|
34
|
+
ballpython-2.0.7.dist-info/RECORD,,
|
pycleaner/__init__.py
CHANGED
|
@@ -20,6 +20,8 @@ from pycleaner.discovery import (
|
|
|
20
20
|
|
|
21
21
|
__version__ = "2.0.5"
|
|
22
22
|
__all__ = [
|
|
23
|
+
"DEFAULT_IGNORED_DIRS",
|
|
24
|
+
"PROTECTED_FILE_PATTERNS",
|
|
23
25
|
"BaselineFingerprint",
|
|
24
26
|
"BaselineManager",
|
|
25
27
|
"CleanPipeline",
|
|
@@ -55,7 +57,10 @@ __all__ = [
|
|
|
55
57
|
"TypeReport",
|
|
56
58
|
"TypeshedResolver",
|
|
57
59
|
"VerificationTier",
|
|
60
|
+
"collect_project_python_files",
|
|
58
61
|
"get_explanation",
|
|
62
|
+
"is_ignored_directory",
|
|
63
|
+
"is_protected_file",
|
|
59
64
|
"list_rules",
|
|
60
65
|
"load_config",
|
|
61
66
|
]
|
pycleaner/baseline.py
CHANGED
|
@@ -54,7 +54,7 @@ class BaselineManager:
|
|
|
54
54
|
data = json.loads(self.baseline_path.read_text(encoding="utf-8"))
|
|
55
55
|
fingerprints = data.get("fingerprints", [])
|
|
56
56
|
return {
|
|
57
|
-
fp
|
|
57
|
+
str(fp["hash"])
|
|
58
58
|
for fp in fingerprints
|
|
59
59
|
if isinstance(fp, dict) and fp.get("hash")
|
|
60
60
|
}
|
pycleaner/cli.py
CHANGED
|
@@ -40,7 +40,6 @@ from pycleaner.verifier import CounterExample, ProofReceipt, VerificationTier
|
|
|
40
40
|
|
|
41
41
|
try:
|
|
42
42
|
from rich.console import Console
|
|
43
|
-
from rich.panel import Panel
|
|
44
43
|
from rich.progress import (
|
|
45
44
|
BarColumn,
|
|
46
45
|
Progress,
|
|
@@ -714,17 +713,17 @@ def _report_file_modifications(
|
|
|
714
713
|
action = "Cleaned" if apply_changes else "Would modify"
|
|
715
714
|
print_msg(f"[green]{action}:[/green] {py_file.name}")
|
|
716
715
|
for repair in result.syntax_repairs:
|
|
717
|
-
print_msg(f"
|
|
716
|
+
print_msg(f" - Syntax: {repair}", style="cyan")
|
|
718
717
|
for mod in result.modernize_transforms:
|
|
719
|
-
print_msg(f"
|
|
718
|
+
print_msg(f" - Modernize: {mod}", style="green")
|
|
720
719
|
for dc in result.dead_code_pruned:
|
|
721
|
-
print_msg(f"
|
|
720
|
+
print_msg(f" - Dead-code: {dc}", style="yellow")
|
|
722
721
|
for imp in result.resolved_imports:
|
|
723
|
-
print_msg(f"
|
|
722
|
+
print_msg(f" - Import: {imp}", style="magenta")
|
|
724
723
|
if result.lint_changed:
|
|
725
|
-
print_msg("
|
|
724
|
+
print_msg(" - Lint: fixed errors and pruned unused imports", style="blue")
|
|
726
725
|
if result.format_changed:
|
|
727
|
-
print_msg("
|
|
726
|
+
print_msg(" - Format: applied PEP 8 formatting", style="blue")
|
|
728
727
|
|
|
729
728
|
|
|
730
729
|
def _accumulate_result(
|
|
@@ -750,11 +749,11 @@ def _accumulate_result(
|
|
|
750
749
|
elif result.verification_tier == VerificationTier.TIER_C_REFUSED:
|
|
751
750
|
if not is_json:
|
|
752
751
|
print_msg(
|
|
753
|
-
f"[bold red]Refused (Tier C):[/bold red] {py_file.name}
|
|
752
|
+
f"[bold red]Refused (Tier C):[/bold red] {py_file.name} - transformation falsified by differential fuzzing; rolled back!"
|
|
754
753
|
)
|
|
755
754
|
for ce in result.refused_changes:
|
|
756
755
|
print_msg(
|
|
757
|
-
f"
|
|
756
|
+
f" - {ce.callable_name} diverged on args={ce.arguments} kwargs={ce.keyword_arguments}"
|
|
758
757
|
)
|
|
759
758
|
print_msg(
|
|
760
759
|
f" original={ce.original_result or ce.original_error} vs transformed={ce.transformed_result or ce.transformed_error} (seed {ce.seed})"
|
|
@@ -1022,15 +1021,15 @@ def _cmd_fix(
|
|
|
1022
1021
|
if not is_json:
|
|
1023
1022
|
print_msg("\n[bold cyan]Verification Receipts (Trust Ladder):[/bold cyan]")
|
|
1024
1023
|
print_msg(
|
|
1025
|
-
f" [bold green]
|
|
1024
|
+
f" [bold green]- Proven (Tier A):[/bold green] {total_proven_callables} callable(s) invariant-preserving across {pipeline.proof_iterations} input(s)"
|
|
1026
1025
|
)
|
|
1027
1026
|
if state.suggested_count > 0:
|
|
1028
1027
|
print_msg(
|
|
1029
|
-
f" [yellow]
|
|
1028
|
+
f" [yellow]- Suggested (Tier B):[/yellow] {state.suggested_count} module(s) (not isolated for dynamic fuzzing)"
|
|
1030
1029
|
)
|
|
1031
1030
|
if total_refused_callables > 0:
|
|
1032
1031
|
print_msg(
|
|
1033
|
-
f" [bold red]
|
|
1032
|
+
f" [bold red]- Refused (Tier C):[/bold red] {total_refused_callables} transformation(s) diverged; rolled back"
|
|
1034
1033
|
)
|
|
1035
1034
|
|
|
1036
1035
|
output_report_path = getattr(
|
|
@@ -1098,20 +1097,20 @@ def _cmd_fix(
|
|
|
1098
1097
|
)
|
|
1099
1098
|
if tolerated:
|
|
1100
1099
|
print_msg(
|
|
1101
|
-
f"
|
|
1100
|
+
f" - Baseline tolerated: [yellow]{len(tolerated)}[/yellow] existing issue(s)"
|
|
1102
1101
|
)
|
|
1103
1102
|
if new_debt:
|
|
1104
1103
|
print_msg(
|
|
1105
|
-
f"
|
|
1104
|
+
f" - [bold red]Ratchet Violation:[/bold red] {len(new_debt)} new technical debt issue(s) detected!"
|
|
1106
1105
|
)
|
|
1107
1106
|
for nd in new_debt:
|
|
1108
1107
|
print_msg(
|
|
1109
|
-
f" [red]
|
|
1108
|
+
f" [red]- [X] {nd.rule} at {nd.file}:{nd.line} ({nd.symbol})[/red]"
|
|
1110
1109
|
)
|
|
1111
1110
|
return 1
|
|
1112
1111
|
else:
|
|
1113
1112
|
print_msg(
|
|
1114
|
-
"
|
|
1113
|
+
" - [bold green]Ratchet Passed:[/bold green] 0 new technical debt issues introduced."
|
|
1115
1114
|
)
|
|
1116
1115
|
elif new_debt:
|
|
1117
1116
|
return 1
|
|
@@ -1223,9 +1222,9 @@ def _cmd_baseline(
|
|
|
1223
1222
|
manager = BaselineManager(output_path)
|
|
1224
1223
|
saved_file = manager.save_baseline(fingerprints, root_dir)
|
|
1225
1224
|
|
|
1226
|
-
print_msg(
|
|
1227
|
-
print_msg(f"
|
|
1228
|
-
print_msg(f"
|
|
1225
|
+
print_msg("\n[bold green]Baseline successfully recorded![/bold green]")
|
|
1226
|
+
print_msg(f" - Issues snapshotted: [bold yellow]{len(fingerprints)}[/bold yellow]")
|
|
1227
|
+
print_msg(f" - Output file: [bold]{saved_file}[/bold]")
|
|
1229
1228
|
print_msg(
|
|
1230
1229
|
"\n[dim]Ratchet Guarantee: Technical debt in this repository is now locked. Run CI with:[/dim]"
|
|
1231
1230
|
)
|
|
@@ -1501,7 +1500,7 @@ def _render_scan_cli_summary(
|
|
|
1501
1500
|
print_msg(f"\n Findings by category: {cat_summary}")
|
|
1502
1501
|
|
|
1503
1502
|
print_msg(
|
|
1504
|
-
f"\n Total: {report.count} finding(s)
|
|
1503
|
+
f"\n Total: {report.count} finding(s) - "
|
|
1505
1504
|
f"[red]{report.critical_count} critical[/red], [red]{report.high_count} high[/red]"
|
|
1506
1505
|
)
|
|
1507
1506
|
return 1 if report.critical_count > 0 else 0
|
|
@@ -1604,7 +1603,7 @@ def _render_complexity_cli_output(
|
|
|
1604
1603
|
else:
|
|
1605
1604
|
for f in violations:
|
|
1606
1605
|
print_msg(
|
|
1607
|
-
f" {f.qualified_name}
|
|
1606
|
+
f" {f.qualified_name} - CC:{f.cyclomatic} Cog:{f.cognitive} Ln:{f.lines} Args:{f.args}"
|
|
1608
1607
|
)
|
|
1609
1608
|
print_msg(f"\n {len(violations)} function(s) exceed threshold(s).")
|
|
1610
1609
|
return 1
|
|
@@ -1661,7 +1660,7 @@ def _render_dead_code_kind_group(
|
|
|
1661
1660
|
rel_path = _try_relative(item.filepath, target_base)
|
|
1662
1661
|
conf_tag = f" [{item.confidence}]" if item.confidence != "high" else ""
|
|
1663
1662
|
print_msg(
|
|
1664
|
-
f" L{item.lineno} {rel_path}: {item.name}
|
|
1663
|
+
f" L{item.lineno} {rel_path}: {item.name} - {item.reason}{conf_tag}"
|
|
1665
1664
|
)
|
|
1666
1665
|
if len(kind_items) > 20:
|
|
1667
1666
|
print_msg(f" ... and {len(kind_items) - 20} more")
|
|
@@ -1781,7 +1780,7 @@ def _render_types_cli_summary(
|
|
|
1781
1780
|
for f in report.findings:
|
|
1782
1781
|
rel = _try_relative(f.filepath, target_base)
|
|
1783
1782
|
print_msg(
|
|
1784
|
-
f" [{f.severity.upper()}] {rel}:{f.lineno}
|
|
1783
|
+
f" [{f.severity.upper()}] {rel}:{f.lineno} - {f.message} (expected {f.expected_type}, got {f.actual_type})"
|
|
1785
1784
|
)
|
|
1786
1785
|
|
|
1787
1786
|
print_msg(f"\n Total: {report.count} type finding(s) detected.")
|
pycleaner/complexity_analyzer.py
CHANGED
|
@@ -318,7 +318,7 @@ class _NestingDepthCounter(ast.NodeVisitor):
|
|
|
318
318
|
def visit_FunctionDef(self, node: ast.FunctionDef | ast.AsyncFunctionDef) -> None:
|
|
319
319
|
self._function_depth += 1
|
|
320
320
|
if self._function_depth == 1:
|
|
321
|
-
# Entry function being scored
|
|
321
|
+
# Entry function being scored - walk its body normally.
|
|
322
322
|
self.generic_visit(node)
|
|
323
323
|
# A nested def starts counting from its own zero depth in its own
|
|
324
324
|
# report entry; it must not extend the enclosing function's depth.
|
pycleaner/dead_code_detector.py
CHANGED
|
@@ -346,6 +346,12 @@ class _ProjectScanState:
|
|
|
346
346
|
self.exports.update(ref_collector.all_exports)
|
|
347
347
|
self.decorated.update(ref_collector.decorated_names)
|
|
348
348
|
|
|
349
|
+
# In __init__.py files, all module-level non-private definitions represent public package exports
|
|
350
|
+
if py_file.name == "__init__.py":
|
|
351
|
+
for name, kind, lineno, end_lineno, ctx in def_collector.definitions:
|
|
352
|
+
if ctx == "<module>" and not name.startswith("_"):
|
|
353
|
+
self.exports.add(name)
|
|
354
|
+
|
|
349
355
|
unreachable = _UnreachableCodeDetector(filepath_str, content.splitlines())
|
|
350
356
|
unreachable.visit(tree)
|
|
351
357
|
self.unreachable.extend(unreachable.items)
|
|
@@ -532,7 +538,9 @@ class DeadCodeDetector:
|
|
|
532
538
|
def _is_name_exempt(self, name: str) -> bool:
|
|
533
539
|
if name in self.PROTECTED_NAMES:
|
|
534
540
|
return True
|
|
535
|
-
|
|
541
|
+
if name == "_" or (name.startswith("__") and name.endswith("__")):
|
|
542
|
+
return True
|
|
543
|
+
exempt_prefixes = ("test_", "Test", "visit_")
|
|
536
544
|
return name.startswith(exempt_prefixes) or name == "generic_visit"
|
|
537
545
|
|
|
538
546
|
def _should_skip(
|
pycleaner/dependency_auditor.py
CHANGED
|
@@ -59,7 +59,6 @@ class DependencyAuditor:
|
|
|
59
59
|
"Bio": "biopython",
|
|
60
60
|
"OpenGL": "PyOpenGL",
|
|
61
61
|
"attr": "attrs",
|
|
62
|
-
"google": "protobuf",
|
|
63
62
|
# High-frequency PyPI packages
|
|
64
63
|
"nmap": "python-nmap",
|
|
65
64
|
"dns": "dnspython",
|
|
@@ -77,6 +76,52 @@ class DependencyAuditor:
|
|
|
77
76
|
"playwright": "playwright",
|
|
78
77
|
}
|
|
79
78
|
|
|
79
|
+
def _find_venv_site_packages(self) -> Path | None:
|
|
80
|
+
"""Locate site-packages directory inside project virtual environment."""
|
|
81
|
+
for venv_name in (".venv", "venv", "env"):
|
|
82
|
+
candidate = self.root_dir / venv_name
|
|
83
|
+
if not candidate.is_dir():
|
|
84
|
+
continue
|
|
85
|
+
win_site = candidate / "Lib" / "site-packages"
|
|
86
|
+
if win_site.is_dir():
|
|
87
|
+
return win_site
|
|
88
|
+
lib_dir = candidate / "lib"
|
|
89
|
+
if lib_dir.is_dir():
|
|
90
|
+
for py_dir in lib_dir.glob("python*"):
|
|
91
|
+
site = py_dir / "site-packages"
|
|
92
|
+
if site.is_dir():
|
|
93
|
+
return site
|
|
94
|
+
return None
|
|
95
|
+
|
|
96
|
+
def _load_distributions(self) -> dict[str, list[str]]:
|
|
97
|
+
"""Load distribution mappings from project virtual environment or host environment."""
|
|
98
|
+
dists: dict[str, list[str]] = {}
|
|
99
|
+
try:
|
|
100
|
+
dists.update(importlib.metadata.packages_distributions())
|
|
101
|
+
except AttributeError:
|
|
102
|
+
pass
|
|
103
|
+
|
|
104
|
+
site_packages = self._find_venv_site_packages()
|
|
105
|
+
if site_packages and site_packages.is_dir():
|
|
106
|
+
try:
|
|
107
|
+
for dist_info in site_packages.glob("*.dist-info"):
|
|
108
|
+
dist_name = dist_info.name.split("-")[0]
|
|
109
|
+
top_level_file = dist_info / "top_level.txt"
|
|
110
|
+
if top_level_file.is_file():
|
|
111
|
+
try:
|
|
112
|
+
for top_mod in top_level_file.read_text(
|
|
113
|
+
encoding="utf-8", errors="ignore"
|
|
114
|
+
).splitlines():
|
|
115
|
+
top_mod = top_mod.strip()
|
|
116
|
+
if top_mod:
|
|
117
|
+
dists.setdefault(top_mod, []).insert(0, dist_name)
|
|
118
|
+
except OSError:
|
|
119
|
+
pass
|
|
120
|
+
except OSError:
|
|
121
|
+
pass
|
|
122
|
+
|
|
123
|
+
return dists
|
|
124
|
+
|
|
80
125
|
def __init__(
|
|
81
126
|
self, root_dir: str | Path, exclude_patterns: Sequence[str] = ()
|
|
82
127
|
) -> None:
|
|
@@ -84,10 +129,7 @@ class DependencyAuditor:
|
|
|
84
129
|
self.exclude_patterns = tuple(exclude_patterns)
|
|
85
130
|
self.stdlib_names = set(sys.stdlib_module_names)
|
|
86
131
|
self._last_optional_packages: set[str] = set()
|
|
87
|
-
|
|
88
|
-
self.dist_map = importlib.metadata.packages_distributions()
|
|
89
|
-
except AttributeError:
|
|
90
|
-
self.dist_map = {}
|
|
132
|
+
self.dist_map = self._load_distributions()
|
|
91
133
|
|
|
92
134
|
_KNOWN_DEV_TOOLS: ClassVar[frozenset[str]] = frozenset(
|
|
93
135
|
{
|
|
@@ -127,12 +169,18 @@ class DependencyAuditor:
|
|
|
127
169
|
for node in ast.walk(tree):
|
|
128
170
|
if isinstance(node, ast.Import):
|
|
129
171
|
for alias in node.names:
|
|
130
|
-
|
|
131
|
-
|
|
172
|
+
parts = alias.name.split(".")
|
|
173
|
+
if len(parts) >= 2 and parts[0] in ("google", "azure"):
|
|
174
|
+
imports.add(f"{parts[0]}.{parts[1]}")
|
|
175
|
+
else:
|
|
176
|
+
imports.add(parts[0])
|
|
132
177
|
elif isinstance(node, ast.ImportFrom):
|
|
133
178
|
if node.level == 0 and node.module:
|
|
134
|
-
|
|
135
|
-
|
|
179
|
+
parts = node.module.split(".")
|
|
180
|
+
if len(parts) >= 2 and parts[0] in ("google", "azure"):
|
|
181
|
+
imports.add(f"{parts[0]}.{parts[1]}")
|
|
182
|
+
else:
|
|
183
|
+
imports.add(parts[0])
|
|
136
184
|
return imports
|
|
137
185
|
|
|
138
186
|
def scan_codebase_imports(
|
|
@@ -206,6 +254,13 @@ class DependencyAuditor:
|
|
|
206
254
|
|
|
207
255
|
def module_to_distribution(self, module_name: str) -> str:
|
|
208
256
|
"""Map a Python import module name to its PyPI distribution package name."""
|
|
257
|
+
if module_name.startswith("google."):
|
|
258
|
+
sub = module_name.split(".")[1]
|
|
259
|
+
return f"google-{sub}"
|
|
260
|
+
if module_name.startswith("azure."):
|
|
261
|
+
sub = module_name.split(".")[1]
|
|
262
|
+
return f"azure-{sub}"
|
|
263
|
+
|
|
209
264
|
if module_name in self._KNOWN_IMPORT_TO_DIST:
|
|
210
265
|
return self._KNOWN_IMPORT_TO_DIST[module_name]
|
|
211
266
|
|
pycleaner/frameworks/__init__.py
CHANGED
pycleaner/frameworks/plugins.py
CHANGED
pycleaner/import_resolver.py
CHANGED
|
@@ -82,7 +82,10 @@ class UndefinedSymbolFinder(ast.NodeVisitor):
|
|
|
82
82
|
def _is_defined(self, name: str) -> bool:
|
|
83
83
|
if name in self.builtin_names:
|
|
84
84
|
return True
|
|
85
|
-
for
|
|
85
|
+
in_function = any(kind == "function" for kind in self.scope_kinds)
|
|
86
|
+
for scope, kind in zip(reversed(self.scopes), reversed(self.scope_kinds)):
|
|
87
|
+
if in_function and kind == "class":
|
|
88
|
+
continue
|
|
86
89
|
if name in scope:
|
|
87
90
|
return True
|
|
88
91
|
return False
|
|
@@ -167,6 +170,13 @@ class UndefinedSymbolFinder(ast.NodeVisitor):
|
|
|
167
170
|
finally:
|
|
168
171
|
self._in_annotation = False
|
|
169
172
|
|
|
173
|
+
# Defaults are evaluated in enclosing scope at definition time
|
|
174
|
+
for default in node.args.defaults:
|
|
175
|
+
self.visit(default)
|
|
176
|
+
for kw_default in node.args.kw_defaults:
|
|
177
|
+
if kw_default is not None:
|
|
178
|
+
self.visit(kw_default)
|
|
179
|
+
|
|
170
180
|
self.scopes.append(set())
|
|
171
181
|
self.scope_kinds.append("function")
|
|
172
182
|
|
|
@@ -180,22 +190,16 @@ class UndefinedSymbolFinder(ast.NodeVisitor):
|
|
|
180
190
|
|
|
181
191
|
visit_AsyncFunctionDef = visit_FunctionDef
|
|
182
192
|
|
|
183
|
-
def visit_AnnAssign(self, node: ast.AnnAssign) -> None:
|
|
184
|
-
if node.value:
|
|
185
|
-
self.visit(node.value)
|
|
186
|
-
if isinstance(node.target, ast.Name):
|
|
187
|
-
self._current_scope().add(node.target.id)
|
|
188
|
-
else:
|
|
189
|
-
self.visit(node.target)
|
|
190
|
-
self._in_annotation = True
|
|
191
|
-
try:
|
|
192
|
-
self.visit(node.annotation)
|
|
193
|
-
finally:
|
|
194
|
-
self._in_annotation = False
|
|
195
|
-
|
|
196
193
|
def visit_Lambda(self, node: ast.Lambda) -> None:
|
|
194
|
+
for default in node.args.defaults:
|
|
195
|
+
self.visit(default)
|
|
196
|
+
for kw_default in node.args.kw_defaults:
|
|
197
|
+
if kw_default is not None:
|
|
198
|
+
self.visit(kw_default)
|
|
199
|
+
|
|
197
200
|
self.scopes.append(set())
|
|
198
201
|
self.scope_kinds.append("function")
|
|
202
|
+
|
|
199
203
|
all_args = node.args.posonlyargs + node.args.args + node.args.kwonlyargs
|
|
200
204
|
for arg in all_args:
|
|
201
205
|
self._current_scope().add(arg.arg)
|
|
@@ -205,9 +209,23 @@ class UndefinedSymbolFinder(ast.NodeVisitor):
|
|
|
205
209
|
self._current_scope().add(node.args.kwarg.arg)
|
|
206
210
|
|
|
207
211
|
self.visit(node.body)
|
|
212
|
+
|
|
208
213
|
self.scopes.pop()
|
|
209
214
|
self.scope_kinds.pop()
|
|
210
215
|
|
|
216
|
+
def visit_AnnAssign(self, node: ast.AnnAssign) -> None:
|
|
217
|
+
if node.value:
|
|
218
|
+
self.visit(node.value)
|
|
219
|
+
if isinstance(node.target, ast.Name):
|
|
220
|
+
self._current_scope().add(node.target.id)
|
|
221
|
+
else:
|
|
222
|
+
self.visit(node.target)
|
|
223
|
+
self._in_annotation = True
|
|
224
|
+
try:
|
|
225
|
+
self.visit(node.annotation)
|
|
226
|
+
finally:
|
|
227
|
+
self._in_annotation = False
|
|
228
|
+
|
|
211
229
|
def visit_NamedExpr(self, node: ast.NamedExpr) -> None:
|
|
212
230
|
# PEP 572: an assignment expression's target binds in the nearest
|
|
213
231
|
# enclosing scope that is a function or module scope, explicitly
|
pycleaner/linter_formatter.py
CHANGED
|
@@ -49,9 +49,10 @@ class _UsageCollector(ast.NodeVisitor):
|
|
|
49
49
|
|
|
50
50
|
def visit_Constant(self, node: ast.Constant) -> None:
|
|
51
51
|
if isinstance(node.value, str):
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
52
|
+
val = node.value.strip()
|
|
53
|
+
# Only treat single valid Python identifiers as used names (e.g. forward references like 'Card' or __all__ = ['Card'])
|
|
54
|
+
if re.fullmatch(r"[a-zA-Z_]\w*", val):
|
|
55
|
+
self.used_names.add(val)
|
|
55
56
|
self.generic_visit(node)
|
|
56
57
|
|
|
57
58
|
|
|
@@ -134,8 +135,8 @@ class LinterFormatter:
|
|
|
134
135
|
proc = subprocess.run(
|
|
135
136
|
cmd, input=source.encode("utf-8"), capture_output=True, check=False
|
|
136
137
|
)
|
|
137
|
-
output = proc.stdout.decode("utf-8")
|
|
138
|
-
stderr = proc.stderr.decode("utf-8").strip()
|
|
138
|
+
output = proc.stdout.decode("utf-8", errors="replace")
|
|
139
|
+
stderr = proc.stderr.decode("utf-8", errors="replace").strip()
|
|
139
140
|
diag = [stderr] if stderr else []
|
|
140
141
|
if proc.returncode in (0, 1) and output:
|
|
141
142
|
return output, output != source, diag
|
|
@@ -162,7 +163,7 @@ class LinterFormatter:
|
|
|
162
163
|
cmd, input=source.encode("utf-8"), capture_output=True, check=False
|
|
163
164
|
)
|
|
164
165
|
if proc.returncode == 0 and proc.stdout:
|
|
165
|
-
output = proc.stdout.decode("utf-8")
|
|
166
|
+
output = proc.stdout.decode("utf-8", errors="replace")
|
|
166
167
|
if output != source:
|
|
167
168
|
diagnostics.append("Pruned unused imports using autoflake fallback")
|
|
168
169
|
return output, True
|
|
@@ -182,7 +183,7 @@ class LinterFormatter:
|
|
|
182
183
|
check=False,
|
|
183
184
|
)
|
|
184
185
|
if proc.returncode == 0 and proc.stdout:
|
|
185
|
-
res = proc.stdout.decode("utf-8")
|
|
186
|
+
res = proc.stdout.decode("utf-8", errors="replace")
|
|
186
187
|
return res if res != code else None
|
|
187
188
|
except OSError:
|
|
188
189
|
# Fall back to isort Python module or pure-Python import sorter
|
|
@@ -264,7 +265,7 @@ class LinterFormatter:
|
|
|
264
265
|
cmd, input=source.encode("utf-8"), capture_output=True, check=False
|
|
265
266
|
)
|
|
266
267
|
if proc.returncode == 0:
|
|
267
|
-
res = proc.stdout.decode("utf-8")
|
|
268
|
+
res = proc.stdout.decode("utf-8", errors="replace")
|
|
268
269
|
return res, res != source
|
|
269
270
|
except OSError:
|
|
270
271
|
# Fall back to Black or pure-Python formatter if ruff CLI execution fails
|
|
@@ -281,7 +282,7 @@ class LinterFormatter:
|
|
|
281
282
|
check=False,
|
|
282
283
|
)
|
|
283
284
|
if proc.returncode == 0 and proc.stdout:
|
|
284
|
-
res = proc.stdout.decode("utf-8")
|
|
285
|
+
res = proc.stdout.decode("utf-8", errors="replace")
|
|
285
286
|
return res, res != source, ["Formatted with black CLI fallback"]
|
|
286
287
|
except OSError:
|
|
287
288
|
# Fall back to black module or pure-Python formatter
|
pycleaner/modernizer.py
CHANGED
|
@@ -40,15 +40,18 @@ class _AnnotationTransformer(ast.NodeTransformer):
|
|
|
40
40
|
"Type": "type",
|
|
41
41
|
}
|
|
42
42
|
|
|
43
|
-
def __init__(self) -> None:
|
|
43
|
+
def __init__(self, typing_imports: set[str] | None = None) -> None:
|
|
44
44
|
self.changed = False
|
|
45
45
|
self.transformations: list[str] = []
|
|
46
|
+
self.typing_imports = typing_imports if typing_imports is not None else set()
|
|
46
47
|
|
|
47
48
|
def visit_Subscript(self, node: ast.Subscript) -> ast.AST:
|
|
48
49
|
self.generic_visit(node)
|
|
49
50
|
name: str | None = None
|
|
50
51
|
if isinstance(node.value, ast.Name):
|
|
51
|
-
|
|
52
|
+
# Only rewrite bare names if they were imported from typing or if typing imports exist
|
|
53
|
+
if not self.typing_imports or node.value.id in self.typing_imports:
|
|
54
|
+
name = node.value.id
|
|
52
55
|
elif (
|
|
53
56
|
isinstance(node.value, ast.Attribute)
|
|
54
57
|
and isinstance(node.value.value, ast.Name)
|
|
@@ -188,12 +191,60 @@ class Modernizer:
|
|
|
188
191
|
isinstance(node, ast.Compare)
|
|
189
192
|
and len(node.ops) == 1
|
|
190
193
|
and len(node.comparators) == 1
|
|
194
|
+
and node.end_lineno is not None
|
|
195
|
+
and node.end_col_offset is not None
|
|
191
196
|
):
|
|
192
197
|
op = node.ops[0]
|
|
193
198
|
comparator = node.comparators[0]
|
|
194
|
-
|
|
195
|
-
|
|
199
|
+
# Only modernize explicit None comparisons (never bool comparisons, which break vectorized/ORM masks)
|
|
200
|
+
if isinstance(comparator, ast.Constant) and comparator.value is None:
|
|
201
|
+
if isinstance(op, ast.Eq):
|
|
202
|
+
replacement_op = "is"
|
|
203
|
+
transforms.append("Modernized '== None' to 'is None'")
|
|
204
|
+
elif isinstance(op, ast.NotEq):
|
|
205
|
+
replacement_op = "is not"
|
|
206
|
+
transforms.append("Modernized '!= None' to 'is not None'")
|
|
207
|
+
else:
|
|
208
|
+
continue
|
|
209
|
+
|
|
210
|
+
left_unparsed = ast.unparse(node.left)
|
|
211
|
+
new_expr = f"{left_unparsed} {replacement_op} None"
|
|
212
|
+
edits.append(
|
|
213
|
+
(
|
|
214
|
+
node.lineno,
|
|
215
|
+
node.col_offset,
|
|
216
|
+
node.end_lineno,
|
|
217
|
+
node.end_col_offset,
|
|
218
|
+
new_expr,
|
|
219
|
+
)
|
|
220
|
+
)
|
|
221
|
+
elif isinstance(node.left, ast.Constant) and node.left.value is None:
|
|
222
|
+
if isinstance(op, ast.Eq):
|
|
223
|
+
replacement_op = "is"
|
|
224
|
+
transforms.append("Modernized 'None ==' to 'is None'")
|
|
225
|
+
elif isinstance(op, ast.NotEq):
|
|
226
|
+
replacement_op = "is not"
|
|
227
|
+
transforms.append("Modernized 'None !=' to 'is not None'")
|
|
228
|
+
else:
|
|
229
|
+
continue
|
|
230
|
+
|
|
231
|
+
comp_unparsed = ast.unparse(comparator)
|
|
232
|
+
new_expr = f"{comp_unparsed} {replacement_op} None"
|
|
233
|
+
edits.append(
|
|
234
|
+
(
|
|
235
|
+
node.lineno,
|
|
236
|
+
node.col_offset,
|
|
237
|
+
node.end_lineno,
|
|
238
|
+
node.end_col_offset,
|
|
239
|
+
new_expr,
|
|
240
|
+
)
|
|
241
|
+
)
|
|
242
|
+
elif (
|
|
243
|
+
isinstance(comparator, ast.Constant)
|
|
244
|
+
and isinstance(comparator.value, bool)
|
|
245
|
+
and isinstance(node.left, ast.Name)
|
|
196
246
|
):
|
|
247
|
+
# Only modernize simple scalar identifier bool comparisons (never subscripts, attributes, or calls)
|
|
197
248
|
val = comparator.value
|
|
198
249
|
if isinstance(op, ast.Eq):
|
|
199
250
|
replacement_op = "is"
|
|
@@ -225,6 +276,12 @@ class Modernizer:
|
|
|
225
276
|
idx = lineno - 1
|
|
226
277
|
line = lines[idx]
|
|
227
278
|
lines[idx] = line[:col_offset] + new_text + line[end_col_offset:]
|
|
279
|
+
elif lineno < end_lineno:
|
|
280
|
+
start_idx = lineno - 1
|
|
281
|
+
end_idx = end_lineno - 1
|
|
282
|
+
prefix = lines[start_idx][:col_offset]
|
|
283
|
+
suffix = lines[end_idx][end_col_offset:]
|
|
284
|
+
lines[start_idx : end_idx + 1] = [prefix + new_text + suffix]
|
|
228
285
|
|
|
229
286
|
return "".join(lines), transforms
|
|
230
287
|
|
|
@@ -267,14 +324,27 @@ class Modernizer:
|
|
|
267
324
|
|
|
268
325
|
lines = source.splitlines(keepends=True)
|
|
269
326
|
edits: list[tuple[int, int, int, int, str]] = []
|
|
270
|
-
|
|
327
|
+
|
|
328
|
+
typing_imports: set[str] = set()
|
|
329
|
+
for node in ast.walk(tree):
|
|
330
|
+
if isinstance(node, ast.ImportFrom) and node.module == "typing":
|
|
331
|
+
for alias in node.names:
|
|
332
|
+
if alias.name == "*":
|
|
333
|
+
typing_imports.update(
|
|
334
|
+
_AnnotationTransformer._PEP_585_MAP.keys()
|
|
335
|
+
)
|
|
336
|
+
typing_imports.update({"Optional", "Union"})
|
|
337
|
+
else:
|
|
338
|
+
typing_imports.add(alias.asname or alias.name)
|
|
339
|
+
|
|
340
|
+
transformer = _AnnotationTransformer(typing_imports=typing_imports)
|
|
271
341
|
|
|
272
342
|
def process_annotation(node: ast.AST | None) -> None:
|
|
273
|
-
if node
|
|
343
|
+
if not isinstance(node, ast.expr):
|
|
274
344
|
return
|
|
275
|
-
if
|
|
345
|
+
if node.end_lineno is None or node.end_col_offset is None:
|
|
276
346
|
return
|
|
277
|
-
sub = _AnnotationTransformer()
|
|
347
|
+
sub = _AnnotationTransformer(typing_imports=typing_imports)
|
|
278
348
|
new_node = sub.visit(node)
|
|
279
349
|
if sub.changed:
|
|
280
350
|
transformer.changed = True
|
|
@@ -313,6 +383,12 @@ class Modernizer:
|
|
|
313
383
|
idx = lineno - 1
|
|
314
384
|
line = lines[idx]
|
|
315
385
|
lines[idx] = line[:col_offset] + new_text + line[end_col_offset:]
|
|
386
|
+
elif lineno < end_lineno:
|
|
387
|
+
start_idx = lineno - 1
|
|
388
|
+
end_idx = end_lineno - 1
|
|
389
|
+
prefix = lines[start_idx][:col_offset]
|
|
390
|
+
suffix = lines[end_idx][end_col_offset:]
|
|
391
|
+
lines[start_idx : end_idx + 1] = [prefix + new_text + suffix]
|
|
316
392
|
|
|
317
393
|
res = "".join(lines)
|
|
318
394
|
|
pycleaner/security_scanner.py
CHANGED
|
@@ -245,7 +245,9 @@ class _DangerousCallDetector(ast.NodeVisitor):
|
|
|
245
245
|
return False
|
|
246
246
|
|
|
247
247
|
def _add_finding(self, finding: SecurityFinding) -> None:
|
|
248
|
-
if not self._is_suppressed(
|
|
248
|
+
if not self._is_suppressed(
|
|
249
|
+
finding.lineno, finding.category, finding.end_lineno
|
|
250
|
+
):
|
|
249
251
|
self.findings.append(finding)
|
|
250
252
|
|
|
251
253
|
def visit_Call(self, node: ast.Call) -> None:
|
pycleaner/syntax_healer.py
CHANGED
|
@@ -327,7 +327,7 @@ class SyntaxHealer:
|
|
|
327
327
|
pointer_indent = " " * max(0, offset - 1)
|
|
328
328
|
|
|
329
329
|
diagnostic_lines = [
|
|
330
|
-
f"
|
|
330
|
+
f"[!] SyntaxError in {filename}:{lineno}:{offset}",
|
|
331
331
|
f" --> {filename}:{lineno}:{offset}",
|
|
332
332
|
" |",
|
|
333
333
|
f"{lineno:3d} | {error_line}",
|
|
@@ -525,6 +525,7 @@ class SyntaxHealer:
|
|
|
525
525
|
|
|
526
526
|
def _fix_missing_colons(self, code: str) -> tuple[str, int]:
|
|
527
527
|
"""Detect compound statement headers without trailing colons and append them."""
|
|
528
|
+
tokens: list[tokenize.TokenInfo] | None
|
|
528
529
|
try:
|
|
529
530
|
tokens = list(tokenize.generate_tokens(io.StringIO(code).readline))
|
|
530
531
|
except tokenize.TokenError:
|
|
@@ -561,8 +562,8 @@ class SyntaxHealer:
|
|
|
561
562
|
return None
|
|
562
563
|
|
|
563
564
|
@staticmethod
|
|
564
|
-
def _is_standalone_assignment(chars: Sequence[str], i: int,
|
|
565
|
-
if chars[i] != "=" or
|
|
565
|
+
def _is_standalone_assignment(chars: Sequence[str], i: int, in_call: bool) -> bool:
|
|
566
|
+
if chars[i] != "=" or in_call:
|
|
566
567
|
return False
|
|
567
568
|
prev_c = chars[i - 1] if i > 0 else ""
|
|
568
569
|
next_c = chars[i + 1] if i + 1 < len(chars) else ""
|
|
@@ -581,7 +582,7 @@ class SyntaxHealer:
|
|
|
581
582
|
@classmethod
|
|
582
583
|
def _replace_condition_equals(cls, condition: str) -> tuple[str, int]:
|
|
583
584
|
chars = list(condition)
|
|
584
|
-
|
|
585
|
+
call_bracket_stack: list[bool] = []
|
|
585
586
|
in_s = False
|
|
586
587
|
in_d = False
|
|
587
588
|
escaped = False
|
|
@@ -596,12 +597,24 @@ class SyntaxHealer:
|
|
|
596
597
|
elif in_s or in_d or c in ("'", '"'):
|
|
597
598
|
in_s, in_d = cls._update_quote_flag(c, in_s, in_d)
|
|
598
599
|
elif c in "([{":
|
|
599
|
-
|
|
600
|
+
prev_non_ws = ""
|
|
601
|
+
for j in range(i - 1, -1, -1):
|
|
602
|
+
if not chars[j].isspace():
|
|
603
|
+
prev_non_ws = chars[j]
|
|
604
|
+
break
|
|
605
|
+
is_call_or_subscript = bool(
|
|
606
|
+
prev_non_ws
|
|
607
|
+
and (prev_non_ws.isalnum() or prev_non_ws in ("_", ")", "]"))
|
|
608
|
+
)
|
|
609
|
+
call_bracket_stack.append(is_call_or_subscript)
|
|
600
610
|
elif c in ")]}":
|
|
601
|
-
|
|
602
|
-
|
|
603
|
-
|
|
604
|
-
|
|
611
|
+
if call_bracket_stack:
|
|
612
|
+
call_bracket_stack.pop()
|
|
613
|
+
else:
|
|
614
|
+
in_call = any(call_bracket_stack)
|
|
615
|
+
if cls._is_standalone_assignment(chars, i, in_call):
|
|
616
|
+
chars[i] = "=="
|
|
617
|
+
count += 1
|
|
605
618
|
i += 1
|
|
606
619
|
return "".join(chars), count
|
|
607
620
|
|
pycleaner/taint_engine.py
CHANGED
|
@@ -323,8 +323,13 @@ class TaintEngine:
|
|
|
323
323
|
if not spec:
|
|
324
324
|
return
|
|
325
325
|
for arg in child.args:
|
|
326
|
-
|
|
327
|
-
|
|
326
|
+
referenced_params = [
|
|
327
|
+
n.id
|
|
328
|
+
for n in ast.walk(arg)
|
|
329
|
+
if isinstance(n, ast.Name) and n.id in param_names
|
|
330
|
+
]
|
|
331
|
+
for p in referenced_params:
|
|
332
|
+
summary.param_to_sink[p] = (
|
|
328
333
|
spec[0],
|
|
329
334
|
spec[1],
|
|
330
335
|
getattr(child, "lineno", default_lineno),
|
|
@@ -361,10 +366,16 @@ class TaintEngine:
|
|
|
361
366
|
"""Check if an expression immediately calls or accesses a known untrusted source."""
|
|
362
367
|
if isinstance(node, ast.Call):
|
|
363
368
|
call_name = self._resolve_call_name(node.func)
|
|
364
|
-
if
|
|
365
|
-
call_name
|
|
369
|
+
if (
|
|
370
|
+
call_name in self.KNOWN_SOURCES
|
|
371
|
+
or any(call_name.startswith(src) for src in self.KNOWN_SOURCES)
|
|
372
|
+
or (
|
|
373
|
+
isinstance(node.func, ast.Attribute)
|
|
374
|
+
and node.func.attr in ("read", "readline", "readlines")
|
|
375
|
+
)
|
|
366
376
|
):
|
|
367
|
-
|
|
377
|
+
source_name = call_name or "file.read"
|
|
378
|
+
return source_name, getattr(node, "lineno", 0)
|
|
368
379
|
elif isinstance(node, ast.Subscript):
|
|
369
380
|
# e.g., sys.argv[1], request.args['q']
|
|
370
381
|
val_name = self._resolve_call_name(node.value)
|
pycleaner/test_generator.py
CHANGED
pycleaner/type_checker.py
CHANGED
|
@@ -24,40 +24,74 @@ from pycleaner.typeshed_resolver import TypeshedResolver
|
|
|
24
24
|
class PyType:
|
|
25
25
|
"""Base algebraic type representation."""
|
|
26
26
|
|
|
27
|
-
def is_assignable_to(
|
|
27
|
+
def is_assignable_to(
|
|
28
|
+
self,
|
|
29
|
+
target: PyType,
|
|
30
|
+
class_hierarchy: dict[str, list[str]] | None = None,
|
|
31
|
+
) -> bool:
|
|
28
32
|
"""Check if this type can be assigned to target type."""
|
|
29
33
|
if isinstance(target, AnyType) or isinstance(self, AnyType) or target == self:
|
|
30
34
|
return True
|
|
35
|
+
if (
|
|
36
|
+
isinstance(self, CustomClassType)
|
|
37
|
+
and isinstance(target, CustomClassType)
|
|
38
|
+
and class_hierarchy
|
|
39
|
+
and self._is_subclass(self.name, target.name, class_hierarchy)
|
|
40
|
+
):
|
|
41
|
+
return True
|
|
31
42
|
if isinstance(target, UnionType):
|
|
32
|
-
return self._assignable_to_union(target)
|
|
43
|
+
return self._assignable_to_union(target, class_hierarchy)
|
|
33
44
|
if isinstance(self, UnionType):
|
|
34
|
-
return self._union_assignable_to(target)
|
|
45
|
+
return self._union_assignable_to(target, class_hierarchy)
|
|
35
46
|
if isinstance(target, CustomClassType) and self._check_custom_target(target):
|
|
36
47
|
return True
|
|
37
48
|
if isinstance(self, CustomClassType) and self._check_custom_source(target):
|
|
38
49
|
return True
|
|
39
50
|
return self._check_numeric_promotions(target)
|
|
40
51
|
|
|
41
|
-
|
|
52
|
+
@staticmethod
|
|
53
|
+
def _is_subclass(
|
|
54
|
+
child: str,
|
|
55
|
+
parent: str,
|
|
56
|
+
class_hierarchy: dict[str, list[str]],
|
|
57
|
+
visited: set[str] | None = None,
|
|
58
|
+
) -> bool:
|
|
59
|
+
if child == parent:
|
|
60
|
+
return True
|
|
61
|
+
if visited is None:
|
|
62
|
+
visited = set()
|
|
63
|
+
if child in visited:
|
|
64
|
+
return False
|
|
65
|
+
visited.add(child)
|
|
66
|
+
for base in class_hierarchy.get(child, []):
|
|
67
|
+
if PyType._is_subclass(base, parent, class_hierarchy, visited):
|
|
68
|
+
return True
|
|
69
|
+
return False
|
|
70
|
+
|
|
71
|
+
def _assignable_to_union(
|
|
72
|
+
self, target: PyType, class_hierarchy: dict[str, list[str]] | None = None
|
|
73
|
+
) -> bool:
|
|
42
74
|
types = getattr(target, "types", [])
|
|
43
75
|
for u in types:
|
|
44
|
-
if self.is_assignable_to(u):
|
|
76
|
+
if self.is_assignable_to(u, class_hierarchy):
|
|
45
77
|
return True
|
|
46
78
|
return False
|
|
47
79
|
|
|
48
|
-
def _union_assignable_to(
|
|
80
|
+
def _union_assignable_to(
|
|
81
|
+
self, target: PyType, class_hierarchy: dict[str, list[str]] | None = None
|
|
82
|
+
) -> bool:
|
|
49
83
|
types = getattr(self, "types", [])
|
|
50
84
|
non_none = [u for u in types if not isinstance(u, NoneType)]
|
|
51
85
|
if non_none:
|
|
52
86
|
all_match = True
|
|
53
87
|
for u in non_none:
|
|
54
|
-
if not u.is_assignable_to(target):
|
|
88
|
+
if not u.is_assignable_to(target, class_hierarchy):
|
|
55
89
|
all_match = False
|
|
56
90
|
break
|
|
57
91
|
if all_match:
|
|
58
92
|
return True
|
|
59
93
|
for u in types:
|
|
60
|
-
if not u.is_assignable_to(target):
|
|
94
|
+
if not u.is_assignable_to(target, class_hierarchy):
|
|
61
95
|
return False
|
|
62
96
|
return True
|
|
63
97
|
|
|
@@ -183,10 +217,12 @@ class ListType(PyType):
|
|
|
183
217
|
def __init__(self, item_type: PyType) -> None:
|
|
184
218
|
self.item_type = item_type
|
|
185
219
|
|
|
186
|
-
def is_assignable_to(
|
|
220
|
+
def is_assignable_to(
|
|
221
|
+
self, target: PyType, class_hierarchy: dict[str, list[str]] | None = None
|
|
222
|
+
) -> bool:
|
|
187
223
|
if _is_collection_assignable(self.item_type, target):
|
|
188
224
|
return True
|
|
189
|
-
return super().is_assignable_to(target)
|
|
225
|
+
return super().is_assignable_to(target, class_hierarchy)
|
|
190
226
|
|
|
191
227
|
def __eq__(self, other: object) -> bool:
|
|
192
228
|
return isinstance(other, ListType) and self.item_type == other.item_type
|
|
@@ -199,10 +235,12 @@ class SetType(PyType):
|
|
|
199
235
|
def __init__(self, item_type: PyType) -> None:
|
|
200
236
|
self.item_type = item_type
|
|
201
237
|
|
|
202
|
-
def is_assignable_to(
|
|
238
|
+
def is_assignable_to(
|
|
239
|
+
self, target: PyType, class_hierarchy: dict[str, list[str]] | None = None
|
|
240
|
+
) -> bool:
|
|
203
241
|
if _is_collection_assignable(self.item_type, target):
|
|
204
242
|
return True
|
|
205
|
-
return super().is_assignable_to(target)
|
|
243
|
+
return super().is_assignable_to(target, class_hierarchy)
|
|
206
244
|
|
|
207
245
|
def __eq__(self, other: object) -> bool:
|
|
208
246
|
return isinstance(other, SetType) and self.item_type == other.item_type
|
|
@@ -216,20 +254,22 @@ class DictType(PyType):
|
|
|
216
254
|
self.key_type = key_type
|
|
217
255
|
self.value_type = value_type
|
|
218
256
|
|
|
219
|
-
def is_assignable_to(
|
|
257
|
+
def is_assignable_to(
|
|
258
|
+
self, target: PyType, class_hierarchy: dict[str, list[str]] | None = None
|
|
259
|
+
) -> bool:
|
|
220
260
|
if isinstance(target, AnyType):
|
|
221
261
|
return True
|
|
222
262
|
if isinstance(target, DictType):
|
|
223
263
|
return self.key_type.is_assignable_to(
|
|
224
|
-
target.key_type
|
|
225
|
-
) and self.value_type.is_assignable_to(target.value_type)
|
|
264
|
+
target.key_type, class_hierarchy
|
|
265
|
+
) and self.value_type.is_assignable_to(target.value_type, class_hierarchy)
|
|
226
266
|
if isinstance(target, CustomClassType) and target.name in (
|
|
227
267
|
"dict",
|
|
228
268
|
"Dict",
|
|
229
269
|
"Mapping",
|
|
230
270
|
):
|
|
231
271
|
return True
|
|
232
|
-
return super().is_assignable_to(target)
|
|
272
|
+
return super().is_assignable_to(target, class_hierarchy)
|
|
233
273
|
|
|
234
274
|
def __eq__(self, other: object) -> bool:
|
|
235
275
|
return (
|
|
@@ -418,6 +458,7 @@ class _FileContext:
|
|
|
418
458
|
local_functions: dict[str, tuple[dict[str, PyType], PyType]] = field(
|
|
419
459
|
default_factory=dict
|
|
420
460
|
)
|
|
461
|
+
class_hierarchy: dict[str, list[str]] = field(default_factory=dict)
|
|
421
462
|
|
|
422
463
|
|
|
423
464
|
class TypeChecker:
|
|
@@ -440,6 +481,20 @@ class TypeChecker:
|
|
|
440
481
|
|
|
441
482
|
return self.check_ast(tree, filepath=str(path), source=content)
|
|
442
483
|
|
|
484
|
+
@staticmethod
|
|
485
|
+
def _collect_class_hierarchy(tree: ast.Module) -> dict[str, list[str]]:
|
|
486
|
+
hierarchy: dict[str, list[str]] = {}
|
|
487
|
+
for node in ast.walk(tree):
|
|
488
|
+
if isinstance(node, ast.ClassDef):
|
|
489
|
+
bases: list[str] = []
|
|
490
|
+
for b in node.bases:
|
|
491
|
+
if isinstance(b, ast.Name):
|
|
492
|
+
bases.append(b.id)
|
|
493
|
+
elif isinstance(b, ast.Attribute):
|
|
494
|
+
bases.append(b.attr)
|
|
495
|
+
hierarchy[node.name] = bases
|
|
496
|
+
return hierarchy
|
|
497
|
+
|
|
443
498
|
@staticmethod
|
|
444
499
|
def _collect_local_signatures(
|
|
445
500
|
tree: ast.Module,
|
|
@@ -473,6 +528,7 @@ class TypeChecker:
|
|
|
473
528
|
filepath=filepath,
|
|
474
529
|
source_lines=source.splitlines() if source else [],
|
|
475
530
|
local_functions=self._collect_local_signatures(tree),
|
|
531
|
+
class_hierarchy=self._collect_class_hierarchy(tree),
|
|
476
532
|
)
|
|
477
533
|
|
|
478
534
|
for node in ast.walk(tree):
|
|
@@ -548,14 +604,16 @@ class TypeChecker:
|
|
|
548
604
|
return []
|
|
549
605
|
|
|
550
606
|
findings: list[TypeFinding] = []
|
|
607
|
+
has_return = False
|
|
551
608
|
for child in ast.walk(func):
|
|
552
609
|
if not isinstance(child, ast.Return):
|
|
553
610
|
continue
|
|
554
611
|
if self._is_nested_in_other_func(child, func):
|
|
555
612
|
continue
|
|
556
613
|
|
|
614
|
+
has_return = True
|
|
557
615
|
actual_type = self._infer_expr_type(child.value, scope, ctx.local_functions)
|
|
558
|
-
if not actual_type.is_assignable_to(declared_return):
|
|
616
|
+
if not actual_type.is_assignable_to(declared_return, ctx.class_hierarchy):
|
|
559
617
|
snippet = (
|
|
560
618
|
ctx.source_lines[child.lineno - 1]
|
|
561
619
|
if 0 <= child.lineno - 1 < len(ctx.source_lines)
|
|
@@ -578,6 +636,43 @@ class TypeChecker:
|
|
|
578
636
|
)
|
|
579
637
|
)
|
|
580
638
|
|
|
639
|
+
# Check for missing return paths if declared return is non-None/non-Any
|
|
640
|
+
if (
|
|
641
|
+
has_return
|
|
642
|
+
and not isinstance(declared_return, (AnyType, NoneType))
|
|
643
|
+
and not (
|
|
644
|
+
isinstance(declared_return, UnionType)
|
|
645
|
+
and any(
|
|
646
|
+
isinstance(t, NoneType)
|
|
647
|
+
for t in getattr(declared_return, "types", [])
|
|
648
|
+
)
|
|
649
|
+
)
|
|
650
|
+
):
|
|
651
|
+
last_stmt = func.body[-1] if func.body else None
|
|
652
|
+
ends_with_terminal = isinstance(last_stmt, (ast.Return, ast.Raise))
|
|
653
|
+
if not ends_with_terminal:
|
|
654
|
+
snippet = (
|
|
655
|
+
ctx.source_lines[func.lineno - 1]
|
|
656
|
+
if 0 <= func.lineno - 1 < len(ctx.source_lines)
|
|
657
|
+
else ""
|
|
658
|
+
)
|
|
659
|
+
findings.append(
|
|
660
|
+
TypeFinding(
|
|
661
|
+
filepath=ctx.filepath,
|
|
662
|
+
lineno=func.lineno,
|
|
663
|
+
column=func.col_offset,
|
|
664
|
+
symbol=func.name,
|
|
665
|
+
expected_type=str(declared_return),
|
|
666
|
+
actual_type="None",
|
|
667
|
+
message=(
|
|
668
|
+
f"Missing return path: function '{func.name}' declared to return "
|
|
669
|
+
f"'{declared_return}' can fall through without returning a value"
|
|
670
|
+
),
|
|
671
|
+
severity="ERROR",
|
|
672
|
+
code_snippet=snippet.strip(),
|
|
673
|
+
)
|
|
674
|
+
)
|
|
675
|
+
|
|
581
676
|
return findings
|
|
582
677
|
|
|
583
678
|
def _check_function(
|
|
@@ -613,7 +708,7 @@ class TypeChecker:
|
|
|
613
708
|
|
|
614
709
|
actual_type = self._infer_expr_type(node.value, {}, ctx.local_functions)
|
|
615
710
|
if isinstance(actual_type, AnyType) or actual_type.is_assignable_to(
|
|
616
|
-
declared_type
|
|
711
|
+
declared_type, ctx.class_hierarchy
|
|
617
712
|
):
|
|
618
713
|
return []
|
|
619
714
|
|
|
@@ -768,7 +863,9 @@ class TypeChecker:
|
|
|
768
863
|
continue
|
|
769
864
|
|
|
770
865
|
actual = self._infer_expr_type(arg_expr, scope, ctx.local_functions)
|
|
771
|
-
if isinstance(actual, AnyType) or actual.is_assignable_to(
|
|
866
|
+
if isinstance(actual, AnyType) or actual.is_assignable_to(
|
|
867
|
+
expected, ctx.class_hierarchy
|
|
868
|
+
):
|
|
772
869
|
continue
|
|
773
870
|
|
|
774
871
|
findings.append(
|
pycleaner/verifier.py
CHANGED
|
@@ -17,7 +17,9 @@ import enum
|
|
|
17
17
|
import inspect
|
|
18
18
|
import multiprocessing as mp
|
|
19
19
|
import random
|
|
20
|
+
import sys
|
|
20
21
|
import time
|
|
22
|
+
from pathlib import Path
|
|
21
23
|
from typing import Any
|
|
22
24
|
|
|
23
25
|
|
|
@@ -65,25 +67,39 @@ def _worker_differential_fuzz(
|
|
|
65
67
|
callable_name: str,
|
|
66
68
|
inputs: list[tuple[tuple[Any, ...], dict[str, Any]]],
|
|
67
69
|
conn: Any,
|
|
70
|
+
filepath: str = "<sandbox>",
|
|
68
71
|
) -> None:
|
|
69
72
|
"""Child process worker: executes differential equivalence loop across all synthesized inputs."""
|
|
70
73
|
try:
|
|
74
|
+
file_path_obj = (
|
|
75
|
+
Path(filepath).resolve() if filepath and filepath != "<sandbox>" else None
|
|
76
|
+
)
|
|
77
|
+
package_name = ""
|
|
78
|
+
if file_path_obj and file_path_obj.parent.is_dir():
|
|
79
|
+
parent_dir = file_path_obj.parent
|
|
80
|
+
if (parent_dir / "__init__.py").exists():
|
|
81
|
+
package_name = parent_dir.name
|
|
82
|
+
if str(parent_dir) not in sys.path:
|
|
83
|
+
sys.path.insert(0, str(parent_dir))
|
|
84
|
+
if str(parent_dir.parent) not in sys.path:
|
|
85
|
+
sys.path.insert(0, str(parent_dir.parent))
|
|
86
|
+
|
|
71
87
|
orig_ns: dict[str, Any] = {
|
|
72
88
|
"__name__": "__verifier__",
|
|
89
|
+
"__file__": str(file_path_obj) if file_path_obj else "<orig_sandbox>",
|
|
90
|
+
"__package__": package_name,
|
|
73
91
|
"__builtins__": __builtins__,
|
|
74
92
|
}
|
|
75
93
|
trans_ns: dict[str, Any] = {
|
|
76
94
|
"__name__": "__verifier__",
|
|
95
|
+
"__file__": str(file_path_obj) if file_path_obj else "<trans_sandbox>",
|
|
96
|
+
"__package__": package_name,
|
|
77
97
|
"__builtins__": __builtins__,
|
|
78
98
|
}
|
|
79
99
|
|
|
80
100
|
try:
|
|
81
|
-
orig_code = compile(
|
|
82
|
-
|
|
83
|
-
) # nosec: B102 - intentionally isolated in worker process
|
|
84
|
-
exec(
|
|
85
|
-
orig_code, orig_ns
|
|
86
|
-
) # nosec: B102 - intentionally isolated in worker process
|
|
101
|
+
orig_code = compile(orig_source, "<orig_sandbox>", "exec") # nosec: B102 - intentionally isolated in worker process
|
|
102
|
+
exec(orig_code, orig_ns) # nosec: B102 - intentionally isolated in worker process
|
|
87
103
|
except BaseException as e:
|
|
88
104
|
conn.send(
|
|
89
105
|
{
|
|
@@ -94,12 +110,8 @@ def _worker_differential_fuzz(
|
|
|
94
110
|
return
|
|
95
111
|
|
|
96
112
|
try:
|
|
97
|
-
trans_code = compile(
|
|
98
|
-
|
|
99
|
-
) # nosec: B102 - intentionally isolated in worker process
|
|
100
|
-
exec(
|
|
101
|
-
trans_code, trans_ns
|
|
102
|
-
) # nosec: B102 - intentionally isolated in worker process
|
|
113
|
+
trans_code = compile(trans_source, "<trans_sandbox>", "exec") # nosec: B102 - intentionally isolated in worker process
|
|
114
|
+
exec(trans_code, trans_ns) # nosec: B102 - intentionally isolated in worker process
|
|
103
115
|
except BaseException as e:
|
|
104
116
|
conn.send(
|
|
105
117
|
{
|
|
@@ -109,8 +121,21 @@ def _worker_differential_fuzz(
|
|
|
109
121
|
)
|
|
110
122
|
return
|
|
111
123
|
|
|
112
|
-
|
|
113
|
-
|
|
124
|
+
def _resolve_callable(ns: dict[str, Any], target_name: str) -> Any:
|
|
125
|
+
if "." in target_name:
|
|
126
|
+
parts = target_name.split(".")
|
|
127
|
+
curr: Any = ns.get(parts[0])
|
|
128
|
+
for p in parts[1:]:
|
|
129
|
+
if curr is None:
|
|
130
|
+
return None
|
|
131
|
+
curr = getattr(curr, p, None)
|
|
132
|
+
if isinstance(curr, (staticmethod, classmethod)):
|
|
133
|
+
return curr.__func__
|
|
134
|
+
return curr
|
|
135
|
+
return ns.get(target_name)
|
|
136
|
+
|
|
137
|
+
orig_fn = _resolve_callable(orig_ns, callable_name)
|
|
138
|
+
trans_fn = _resolve_callable(trans_ns, callable_name)
|
|
114
139
|
|
|
115
140
|
if not callable(orig_fn) or not callable(trans_fn):
|
|
116
141
|
conn.send(
|
|
@@ -212,7 +237,7 @@ class DeterministicInputSynthesizer:
|
|
|
212
237
|
)
|
|
213
238
|
if annotation is str or annotation == "str":
|
|
214
239
|
return self._rng.choice(
|
|
215
|
-
["", "test", " ", "a" * 100, "\n\t", "null", "123", "utf8_
|
|
240
|
+
["", "test", " ", "a" * 100, "\n\t", "null", "123", "utf8_\U0001f40d"]
|
|
216
241
|
)
|
|
217
242
|
if annotation is bool or annotation == "bool":
|
|
218
243
|
return self._rng.choice([True, False])
|
|
@@ -407,6 +432,7 @@ class IsolatedDifferentialVerifier:
|
|
|
407
432
|
target_callable,
|
|
408
433
|
inputs,
|
|
409
434
|
child_conn,
|
|
435
|
+
filepath,
|
|
410
436
|
),
|
|
411
437
|
)
|
|
412
438
|
process.daemon = True
|
|
@@ -1,34 +0,0 @@
|
|
|
1
|
-
ballpython/__init__.py,sha256=d8ONxgaz9jml_fHPVvg867Vt5jGRhGTqzWPtux3WJOc,206
|
|
2
|
-
ballpython/__main__.py,sha256=aCRr0vnZfKYHDrPHBsYSjX5xTxtFJpTTSPVUSZTBK-I,136
|
|
3
|
-
ballpython/cli.py,sha256=LllCLRAQQ-fRR_lIXwmd2e3Q7DLCIvf-5jHBXsAhJWs,125
|
|
4
|
-
ballpython/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
5
|
-
pycleaner/__init__.py,sha256=G8pQ2M9iH32OK4bKf5Koao554EdGiiHue5rdZ5AS7sM,2894
|
|
6
|
-
pycleaner/__main__.py,sha256=8QBFrPyxId563F6cipPMNVEBTG8TIZUgxQfp8gk1VSI,135
|
|
7
|
-
pycleaner/baseline.py,sha256=DS32C8_G8khYYNJo42FVv6Cc58lJj7NUmPOzWNfQwlU,3856
|
|
8
|
-
pycleaner/cache.py,sha256=WhCiMkuL6ryWVr3l3XrYLLoI7A3bRF8jNxpOyedNW8M,10761
|
|
9
|
-
pycleaner/cli.py,sha256=MMK0U57pam-FuvB1UL7jwVk7m9Z_Cosuwuu5FIfrPp0,73029
|
|
10
|
-
pycleaner/complexity_analyzer.py,sha256=uGC40cHCcr1tshzfIUw56sodEDySMFR3kGmjo7nq_1c,14977
|
|
11
|
-
pycleaner/config.py,sha256=XhloyaY_9pdwbQuc3QaAPCerLeXz-i1da4xT50h-pjY,8514
|
|
12
|
-
pycleaner/dead_code_detector.py,sha256=1DwtBSZL1ug1OI9-6jcJyxkhqHmJ0F8ZR19POI2zO3c,26549
|
|
13
|
-
pycleaner/dependency_auditor.py,sha256=yQhpN_nfJhEFaBPKVTzndpGas_U2OHyKP_Pifa5YrWA,16070
|
|
14
|
-
pycleaner/discovery.py,sha256=dt3OmHg886hy_Z0p80ccMyDkITQxz4biaJY_zB3MU_g,6151
|
|
15
|
-
pycleaner/explanations.py,sha256=2OOU_l3YL5o_Br_6fP6MF8HlY75-1JRmw3eTYT2lu8M,14219
|
|
16
|
-
pycleaner/import_resolver.py,sha256=JnAysZeYtXlmmM0LEnb9HBdkHnEsNwwp4QlxebRxGNE,32189
|
|
17
|
-
pycleaner/linter_formatter.py,sha256=LB5j66OCASU8WG8QgRFpSX7C2c7_UNMBgkJh2hSWxAY,20857
|
|
18
|
-
pycleaner/modernizer.py,sha256=L9hxXXfdyTwf8itDO58y6bW5LobGo6dg8q537QdiYbM,12946
|
|
19
|
-
pycleaner/pipeline.py,sha256=ZG7_9PNz0jBadFrZoiI7juLtUJXglGQhLgQhrLJqEac,20612
|
|
20
|
-
pycleaner/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
21
|
-
pycleaner/security_scanner.py,sha256=cWf4cAGQrgwsGslSQDfZv0XUN3sWvlcOIQaogpmv6C0,23422
|
|
22
|
-
pycleaner/syntax_healer.py,sha256=TlIMKIJhaX9GED3e2JNzNMJ5aXmL9eLJMCu7xKSjdv8,26711
|
|
23
|
-
pycleaner/taint_engine.py,sha256=96cZdDpSPbo5b13zIZY18f_4GJm_tw0clqCZMBtjXMQ,26481
|
|
24
|
-
pycleaner/test_generator.py,sha256=TJsNJSojc6eIsaKZkwMlpnb-MKHuDpNLZCPnMlO-a8M,19663
|
|
25
|
-
pycleaner/type_checker.py,sha256=h09QI_8MRHv83s23myjSYFdMTsWoQ900ffBJHtRuhmE,34087
|
|
26
|
-
pycleaner/typeshed_resolver.py,sha256=N-kWU_CI5DG59lQMkt7wMW9sHYjNCBdfFS42b4y2Kr4,12742
|
|
27
|
-
pycleaner/verifier.py,sha256=pOfSLRNHe4qDUYJG8mxDxjY6Ph8QKBdJZmqBtoJX87w,17937
|
|
28
|
-
pycleaner/frameworks/__init__.py,sha256=-W24zqvug8eUkkwqla5oh7rFOqQR-WI4TohQhbXGYoo,3472
|
|
29
|
-
pycleaner/frameworks/plugins.py,sha256=sMhP9mZ82sx10OI3opgXIEPXNAWFZRe2I__6yh2Uq_E,11453
|
|
30
|
-
ballpython-2.0.5.dist-info/METADATA,sha256=LqjBIVBjanDL5rdXJ7gES5vF4ezSqbG2Y1SKMG6rXx4,3466
|
|
31
|
-
ballpython-2.0.5.dist-info/WHEEL,sha256=YVMoNqKzERt-wjUZwJ33xBGAwnFl-4cqbYkTtWa4itE,91
|
|
32
|
-
ballpython-2.0.5.dist-info/entry_points.txt,sha256=6ux-5UxoVSmlK5p9M-GDVt24sCdwTQq9wgRDkpMIYU8,81
|
|
33
|
-
ballpython-2.0.5.dist-info/top_level.txt,sha256=u6lmy8RWZ_saxCZAQTL7EijB4sunegOiVyk5fHjpwLk,21
|
|
34
|
-
ballpython-2.0.5.dist-info/RECORD,,
|
|
File without changes
|
|
File without changes
|
|
File without changes
|