ballpython 2.0.5__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.
Files changed (63) hide show
  1. {ballpython-2.0.5 → ballpython-2.0.7}/PKG-INFO +1 -1
  2. ballpython-2.0.7/ballpython/__init__.py +13 -0
  3. {ballpython-2.0.5 → ballpython-2.0.7}/ballpython.egg-info/PKG-INFO +1 -1
  4. {ballpython-2.0.5 → ballpython-2.0.7}/pycleaner/__init__.py +5 -0
  5. {ballpython-2.0.5 → ballpython-2.0.7}/pycleaner/baseline.py +1 -1
  6. {ballpython-2.0.5 → ballpython-2.0.7}/pycleaner/cli.py +22 -23
  7. {ballpython-2.0.5 → ballpython-2.0.7}/pycleaner/complexity_analyzer.py +1 -1
  8. {ballpython-2.0.5 → ballpython-2.0.7}/pycleaner/dead_code_detector.py +9 -1
  9. {ballpython-2.0.5 → ballpython-2.0.7}/pycleaner/dependency_auditor.py +64 -9
  10. {ballpython-2.0.5 → ballpython-2.0.7}/pycleaner/frameworks/__init__.py +1 -1
  11. {ballpython-2.0.5 → ballpython-2.0.7}/pycleaner/frameworks/plugins.py +0 -1
  12. {ballpython-2.0.5 → ballpython-2.0.7}/pycleaner/import_resolver.py +32 -14
  13. {ballpython-2.0.5 → ballpython-2.0.7}/pycleaner/linter_formatter.py +10 -9
  14. {ballpython-2.0.5 → ballpython-2.0.7}/pycleaner/modernizer.py +84 -8
  15. {ballpython-2.0.5 → ballpython-2.0.7}/pycleaner/security_scanner.py +3 -1
  16. {ballpython-2.0.5 → ballpython-2.0.7}/pycleaner/syntax_healer.py +22 -9
  17. {ballpython-2.0.5 → ballpython-2.0.7}/pycleaner/taint_engine.py +16 -5
  18. {ballpython-2.0.5 → ballpython-2.0.7}/pycleaner/test_generator.py +1 -1
  19. {ballpython-2.0.5 → ballpython-2.0.7}/pycleaner/type_checker.py +116 -19
  20. {ballpython-2.0.5 → ballpython-2.0.7}/pycleaner/verifier.py +41 -15
  21. {ballpython-2.0.5 → ballpython-2.0.7}/pyproject.toml +15 -2
  22. {ballpython-2.0.5 → ballpython-2.0.7}/tests/test_cache.py +0 -5
  23. {ballpython-2.0.5 → ballpython-2.0.7}/tests/test_cli.py +0 -1
  24. {ballpython-2.0.5 → ballpython-2.0.7}/tests/test_dead_code_fixer.py +2 -2
  25. {ballpython-2.0.5 → ballpython-2.0.7}/tests/test_discovery.py +0 -3
  26. {ballpython-2.0.5 → ballpython-2.0.7}/tests/test_frameworks.py +0 -3
  27. {ballpython-2.0.5 → ballpython-2.0.7}/tests/test_linter_formatter.py +3 -3
  28. {ballpython-2.0.5 → ballpython-2.0.7}/tests/test_modernizer.py +2 -7
  29. {ballpython-2.0.5 → ballpython-2.0.7}/tests/test_mutation_test_generator.py +0 -2
  30. {ballpython-2.0.5 → ballpython-2.0.7}/tests/test_security_scanner.py +0 -1
  31. {ballpython-2.0.5 → ballpython-2.0.7}/tests/test_verifier.py +0 -2
  32. {ballpython-2.0.5 → ballpython-2.0.7}/tests/test_verifier_pipeline.py +0 -3
  33. ballpython-2.0.5/ballpython/__init__.py +0 -9
  34. {ballpython-2.0.5 → ballpython-2.0.7}/README.md +0 -0
  35. {ballpython-2.0.5 → ballpython-2.0.7}/ballpython/__main__.py +0 -0
  36. {ballpython-2.0.5 → ballpython-2.0.7}/ballpython/cli.py +0 -0
  37. {ballpython-2.0.5 → ballpython-2.0.7}/ballpython/py.typed +0 -0
  38. {ballpython-2.0.5 → ballpython-2.0.7}/ballpython.egg-info/SOURCES.txt +0 -0
  39. {ballpython-2.0.5 → ballpython-2.0.7}/ballpython.egg-info/dependency_links.txt +0 -0
  40. {ballpython-2.0.5 → ballpython-2.0.7}/ballpython.egg-info/entry_points.txt +0 -0
  41. {ballpython-2.0.5 → ballpython-2.0.7}/ballpython.egg-info/requires.txt +0 -0
  42. {ballpython-2.0.5 → ballpython-2.0.7}/ballpython.egg-info/top_level.txt +0 -0
  43. {ballpython-2.0.5 → ballpython-2.0.7}/pycleaner/__main__.py +0 -0
  44. {ballpython-2.0.5 → ballpython-2.0.7}/pycleaner/cache.py +0 -0
  45. {ballpython-2.0.5 → ballpython-2.0.7}/pycleaner/config.py +0 -0
  46. {ballpython-2.0.5 → ballpython-2.0.7}/pycleaner/discovery.py +0 -0
  47. {ballpython-2.0.5 → ballpython-2.0.7}/pycleaner/explanations.py +0 -0
  48. {ballpython-2.0.5 → ballpython-2.0.7}/pycleaner/pipeline.py +0 -0
  49. {ballpython-2.0.5 → ballpython-2.0.7}/pycleaner/py.typed +0 -0
  50. {ballpython-2.0.5 → ballpython-2.0.7}/pycleaner/typeshed_resolver.py +0 -0
  51. {ballpython-2.0.5 → ballpython-2.0.7}/setup.cfg +0 -0
  52. {ballpython-2.0.5 → ballpython-2.0.7}/tests/test_baseline_and_explain.py +0 -0
  53. {ballpython-2.0.5 → ballpython-2.0.7}/tests/test_cli_prove.py +0 -0
  54. {ballpython-2.0.5 → ballpython-2.0.7}/tests/test_complexity_analyzer.py +0 -0
  55. {ballpython-2.0.5 → ballpython-2.0.7}/tests/test_config.py +0 -0
  56. {ballpython-2.0.5 → ballpython-2.0.7}/tests/test_dead_code_detector.py +0 -0
  57. {ballpython-2.0.5 → ballpython-2.0.7}/tests/test_dependency_auditor.py +0 -0
  58. {ballpython-2.0.5 → ballpython-2.0.7}/tests/test_import_resolver.py +0 -0
  59. {ballpython-2.0.5 → ballpython-2.0.7}/tests/test_pipeline.py +0 -0
  60. {ballpython-2.0.5 → ballpython-2.0.7}/tests/test_syntax_healer.py +0 -0
  61. {ballpython-2.0.5 → ballpython-2.0.7}/tests/test_taint_engine.py +0 -0
  62. {ballpython-2.0.5 → ballpython-2.0.7}/tests/test_test_generator.py +0 -0
  63. {ballpython-2.0.5 → 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.5
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
@@ -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.5
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
@@ -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
  ]
@@ -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.get("hash")
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" Syntax: {repair}", style="cyan")
716
+ print_msg(f" - Syntax: {repair}", style="cyan")
718
717
  for mod in result.modernize_transforms:
719
- print_msg(f" Modernize: {mod}", style="green")
718
+ print_msg(f" - Modernize: {mod}", style="green")
720
719
  for dc in result.dead_code_pruned:
721
- print_msg(f" Dead-code: {dc}", style="yellow")
720
+ print_msg(f" - Dead-code: {dc}", style="yellow")
722
721
  for imp in result.resolved_imports:
723
- print_msg(f" Import: {imp}", style="magenta")
722
+ print_msg(f" - Import: {imp}", style="magenta")
724
723
  if result.lint_changed:
725
- print_msg(" Lint: fixed errors and pruned unused imports", style="blue")
724
+ print_msg(" - Lint: fixed errors and pruned unused imports", style="blue")
726
725
  if result.format_changed:
727
- print_msg(" Format: applied PEP 8 formatting", style="blue")
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} transformation falsified by differential fuzzing; rolled back!"
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" {ce.callable_name} diverged on args={ce.arguments} kwargs={ce.keyword_arguments}"
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] Proven (Tier A):[/bold green] {total_proven_callables} callable(s) invariant-preserving across {pipeline.proof_iterations} input(s)"
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] Suggested (Tier B):[/yellow] {state.suggested_count} module(s) (not isolated for dynamic fuzzing)"
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] Refused (Tier C):[/bold red] {total_refused_callables} transformation(s) diverged; rolled back"
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" Baseline tolerated: [yellow]{len(tolerated)}[/yellow] existing issue(s)"
1100
+ f" - Baseline tolerated: [yellow]{len(tolerated)}[/yellow] existing issue(s)"
1102
1101
  )
1103
1102
  if new_debt:
1104
1103
  print_msg(
1105
- f" [bold red]Ratchet Violation:[/bold red] {len(new_debt)} new technical debt issue(s) detected!"
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] [X] {nd.rule} at {nd.file}:{nd.line} ({nd.symbol})[/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
- " [bold green]Ratchet Passed:[/bold green] 0 new technical debt issues introduced."
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(f"\n[bold green]Baseline successfully recorded![/bold green]")
1227
- print_msg(f" Issues snapshotted: [bold yellow]{len(fingerprints)}[/bold yellow]")
1228
- print_msg(f" Output file: [bold]{saved_file}[/bold]")
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} CC:{f.cyclomatic} Cog:{f.cognitive} Ln:{f.lines} Args:{f.args}"
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} {item.reason}{conf_tag}"
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} {f.message} (expected {f.expected_type}, got {f.actual_type})"
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 walk its body normally.
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
- exempt_prefixes = ("_", "test_", "Test", "visit_")
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
- try:
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
- root_pkg = alias.name.split(".")[0]
131
- imports.add(root_pkg)
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
- root_pkg = node.module.split(".")[0]
135
- imports.add(root_pkg)
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
 
@@ -13,7 +13,7 @@ from __future__ import annotations
13
13
 
14
14
  import ast
15
15
  from pathlib import Path
16
- from typing import ClassVar, Protocol, runtime_checkable
16
+ from typing import Protocol, runtime_checkable
17
17
 
18
18
 
19
19
  @runtime_checkable
@@ -361,4 +361,3 @@ class PyTorchPlugin:
361
361
  tree: ast.AST,
362
362
  ) -> bool:
363
363
  return name in self._PROTECTED_METHODS
364
-
@@ -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 scope in reversed(self.scopes):
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
- # Extract identifier words from string (e.g. forward references like 'Card' or __all__ = ['Card'])
53
- for ident in re.findall(r"\b[a-zA-Z_]\w*\b", node.value):
54
- self.used_names.add(ident)
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
@@ -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
- name = node.value.id
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
- if isinstance(comparator, ast.Constant) and (
195
- comparator.value is None or isinstance(comparator.value, bool)
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
- transformer = _AnnotationTransformer()
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 is None:
343
+ if not isinstance(node, ast.expr):
274
344
  return
275
- if not hasattr(node, "lineno") or not hasattr(node, "end_lineno"):
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
 
@@ -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(finding.lineno, finding.category, finding.end_lineno):
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: