ballpython 2.0.4__tar.gz → 2.0.7__tar.gz
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.4 → ballpython-2.0.7}/PKG-INFO +2 -2
- {ballpython-2.0.4 → ballpython-2.0.7}/README.md +1 -1
- ballpython-2.0.7/ballpython/__init__.py +13 -0
- {ballpython-2.0.4 → ballpython-2.0.7}/ballpython.egg-info/PKG-INFO +2 -2
- {ballpython-2.0.4 → ballpython-2.0.7}/pycleaner/__init__.py +6 -1
- {ballpython-2.0.4 → ballpython-2.0.7}/pycleaner/baseline.py +1 -1
- {ballpython-2.0.4 → ballpython-2.0.7}/pycleaner/cli.py +22 -23
- {ballpython-2.0.4 → ballpython-2.0.7}/pycleaner/complexity_analyzer.py +1 -1
- {ballpython-2.0.4 → ballpython-2.0.7}/pycleaner/dead_code_detector.py +9 -1
- {ballpython-2.0.4 → ballpython-2.0.7}/pycleaner/dependency_auditor.py +64 -9
- {ballpython-2.0.4 → ballpython-2.0.7}/pycleaner/frameworks/__init__.py +1 -1
- {ballpython-2.0.4 → ballpython-2.0.7}/pycleaner/frameworks/plugins.py +0 -1
- {ballpython-2.0.4 → ballpython-2.0.7}/pycleaner/import_resolver.py +32 -14
- {ballpython-2.0.4 → ballpython-2.0.7}/pycleaner/linter_formatter.py +10 -9
- {ballpython-2.0.4 → ballpython-2.0.7}/pycleaner/modernizer.py +84 -8
- {ballpython-2.0.4 → ballpython-2.0.7}/pycleaner/security_scanner.py +40 -3
- {ballpython-2.0.4 → ballpython-2.0.7}/pycleaner/syntax_healer.py +22 -9
- {ballpython-2.0.4 → ballpython-2.0.7}/pycleaner/taint_engine.py +16 -5
- {ballpython-2.0.4 → ballpython-2.0.7}/pycleaner/test_generator.py +1 -1
- {ballpython-2.0.4 → ballpython-2.0.7}/pycleaner/type_checker.py +116 -19
- {ballpython-2.0.4 → ballpython-2.0.7}/pycleaner/verifier.py +41 -15
- {ballpython-2.0.4 → ballpython-2.0.7}/pyproject.toml +15 -2
- {ballpython-2.0.4 → ballpython-2.0.7}/tests/test_cache.py +0 -5
- {ballpython-2.0.4 → ballpython-2.0.7}/tests/test_cli.py +0 -1
- {ballpython-2.0.4 → ballpython-2.0.7}/tests/test_dead_code_fixer.py +2 -2
- {ballpython-2.0.4 → ballpython-2.0.7}/tests/test_discovery.py +0 -3
- {ballpython-2.0.4 → ballpython-2.0.7}/tests/test_frameworks.py +0 -3
- {ballpython-2.0.4 → ballpython-2.0.7}/tests/test_linter_formatter.py +3 -3
- {ballpython-2.0.4 → ballpython-2.0.7}/tests/test_modernizer.py +2 -7
- {ballpython-2.0.4 → ballpython-2.0.7}/tests/test_mutation_test_generator.py +0 -2
- {ballpython-2.0.4 → ballpython-2.0.7}/tests/test_security_scanner.py +25 -0
- {ballpython-2.0.4 → ballpython-2.0.7}/tests/test_verifier.py +0 -2
- {ballpython-2.0.4 → ballpython-2.0.7}/tests/test_verifier_pipeline.py +0 -3
- ballpython-2.0.4/ballpython/__init__.py +0 -9
- {ballpython-2.0.4 → ballpython-2.0.7}/ballpython/__main__.py +0 -0
- {ballpython-2.0.4 → ballpython-2.0.7}/ballpython/cli.py +0 -0
- {ballpython-2.0.4 → ballpython-2.0.7}/ballpython/py.typed +0 -0
- {ballpython-2.0.4 → ballpython-2.0.7}/ballpython.egg-info/SOURCES.txt +0 -0
- {ballpython-2.0.4 → ballpython-2.0.7}/ballpython.egg-info/dependency_links.txt +0 -0
- {ballpython-2.0.4 → ballpython-2.0.7}/ballpython.egg-info/entry_points.txt +0 -0
- {ballpython-2.0.4 → ballpython-2.0.7}/ballpython.egg-info/requires.txt +0 -0
- {ballpython-2.0.4 → ballpython-2.0.7}/ballpython.egg-info/top_level.txt +0 -0
- {ballpython-2.0.4 → ballpython-2.0.7}/pycleaner/__main__.py +0 -0
- {ballpython-2.0.4 → ballpython-2.0.7}/pycleaner/cache.py +0 -0
- {ballpython-2.0.4 → ballpython-2.0.7}/pycleaner/config.py +0 -0
- {ballpython-2.0.4 → ballpython-2.0.7}/pycleaner/discovery.py +0 -0
- {ballpython-2.0.4 → ballpython-2.0.7}/pycleaner/explanations.py +0 -0
- {ballpython-2.0.4 → ballpython-2.0.7}/pycleaner/pipeline.py +0 -0
- {ballpython-2.0.4 → ballpython-2.0.7}/pycleaner/py.typed +0 -0
- {ballpython-2.0.4 → ballpython-2.0.7}/pycleaner/typeshed_resolver.py +0 -0
- {ballpython-2.0.4 → ballpython-2.0.7}/setup.cfg +0 -0
- {ballpython-2.0.4 → ballpython-2.0.7}/tests/test_baseline_and_explain.py +0 -0
- {ballpython-2.0.4 → ballpython-2.0.7}/tests/test_cli_prove.py +0 -0
- {ballpython-2.0.4 → ballpython-2.0.7}/tests/test_complexity_analyzer.py +0 -0
- {ballpython-2.0.4 → ballpython-2.0.7}/tests/test_config.py +0 -0
- {ballpython-2.0.4 → ballpython-2.0.7}/tests/test_dead_code_detector.py +0 -0
- {ballpython-2.0.4 → ballpython-2.0.7}/tests/test_dependency_auditor.py +0 -0
- {ballpython-2.0.4 → ballpython-2.0.7}/tests/test_import_resolver.py +0 -0
- {ballpython-2.0.4 → ballpython-2.0.7}/tests/test_pipeline.py +0 -0
- {ballpython-2.0.4 → ballpython-2.0.7}/tests/test_syntax_healer.py +0 -0
- {ballpython-2.0.4 → ballpython-2.0.7}/tests/test_taint_engine.py +0 -0
- {ballpython-2.0.4 → ballpython-2.0.7}/tests/test_test_generator.py +0 -0
- {ballpython-2.0.4 → ballpython-2.0.7}/tests/test_type_checker.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: ballpython
|
|
3
|
-
Version: 2.0.
|
|
3
|
+
Version: 2.0.7
|
|
4
4
|
Summary: The Ultimate Static Python Intelligence, Healing, Type Verification, and Security Suite
|
|
5
5
|
Author: Developer
|
|
6
6
|
License-Expression: MIT
|
|
@@ -78,7 +78,7 @@ Add `ballpython` to your `.pre-commit-config.yaml`:
|
|
|
78
78
|
```yaml
|
|
79
79
|
repos:
|
|
80
80
|
- repo: https://github.com/moonrox420/ball-python
|
|
81
|
-
rev: v2.0.
|
|
81
|
+
rev: v2.0.5
|
|
82
82
|
hooks:
|
|
83
83
|
- id: pycleaner
|
|
84
84
|
args: ["check"]
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
"""ballpython - alias and entrypoint wrapper for pycleaner."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from typing import Any
|
|
6
|
+
|
|
7
|
+
import pycleaner
|
|
8
|
+
from pycleaner import __all__ as __all__
|
|
9
|
+
from pycleaner import __version__ as __version__
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
def __getattr__(name: str) -> Any:
|
|
13
|
+
return getattr(pycleaner, name)
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: ballpython
|
|
3
|
-
Version: 2.0.
|
|
3
|
+
Version: 2.0.7
|
|
4
4
|
Summary: The Ultimate Static Python Intelligence, Healing, Type Verification, and Security Suite
|
|
5
5
|
Author: Developer
|
|
6
6
|
License-Expression: MIT
|
|
@@ -78,7 +78,7 @@ Add `ballpython` to your `.pre-commit-config.yaml`:
|
|
|
78
78
|
```yaml
|
|
79
79
|
repos:
|
|
80
80
|
- repo: https://github.com/moonrox420/ball-python
|
|
81
|
-
rev: v2.0.
|
|
81
|
+
rev: v2.0.5
|
|
82
82
|
hooks:
|
|
83
83
|
- id: pycleaner
|
|
84
84
|
args: ["check"]
|
|
@@ -18,8 +18,10 @@ from pycleaner.discovery import (
|
|
|
18
18
|
is_protected_file,
|
|
19
19
|
)
|
|
20
20
|
|
|
21
|
-
__version__ = "2.0.
|
|
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
|
]
|
|
@@ -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
|
}
|
|
@@ -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.")
|
|
@@ -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.
|
|
@@ -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(
|
|
@@ -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
|
|
|
@@ -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
|
|
@@ -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
|