ballpython 2.1.0__py3-none-any.whl → 2.1.2__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -16,6 +16,48 @@ from typing import ClassVar
16
16
 
17
17
  from pycleaner.discovery import collect_project_python_files
18
18
  from pycleaner.frameworks import FrameworkRegistry, get_default_registry
19
+ from pycleaner.security_scanner import is_secret_ast_node
20
+
21
+ PROTECTED_NAMES: frozenset[str] = frozenset(
22
+ {
23
+ "AWS_KEY",
24
+ "AWS_SECRET",
25
+ "GITHUB_TOKEN",
26
+ "STRIPE_KEY",
27
+ "SLACK_TOKEN",
28
+ "SENDGRID",
29
+ "DSN",
30
+ "DATABASE_URL",
31
+ "PRIVATE_KEY",
32
+ "PEM",
33
+ "SECRET_KEY",
34
+ "API_KEY",
35
+ }
36
+ )
37
+
38
+
39
+ def _is_protected_variable(name: str, value_node: ast.AST | None = None) -> bool:
40
+ """Check if a variable name or assigned value represents a protected secret or config."""
41
+ name_upper = name.upper()
42
+ if name_upper in PROTECTED_NAMES:
43
+ return True
44
+ if any(
45
+ kw in name_upper
46
+ for kw in (
47
+ "SECRET",
48
+ "TOKEN",
49
+ "API_KEY",
50
+ "APIKEY",
51
+ "PASSWORD",
52
+ "PASSWD",
53
+ "PRIVATE_KEY",
54
+ "CREDENTIAL",
55
+ )
56
+ ):
57
+ return True
58
+ if value_node is not None and is_secret_ast_node(value_node):
59
+ return True
60
+ return False
19
61
 
20
62
 
21
63
  @dataclass(slots=True)
@@ -113,7 +155,9 @@ class _DefinitionCollector(ast.NodeVisitor):
113
155
  return
114
156
 
115
157
  # Module-level constants or class attributes (not local function variables)
116
- if not self._scope_stack or len(self._scope_stack) == 1:
158
+ if not self._scope_stack or (
159
+ self._class_stack and len(self._scope_stack) == len(self._class_stack)
160
+ ):
117
161
  for target in node.targets:
118
162
  if isinstance(target, ast.Name):
119
163
  name = target.id
@@ -127,6 +171,8 @@ class _DefinitionCollector(ast.NodeVisitor):
127
171
  name, "variable", node, context, self.tree, self.filepath
128
172
  ):
129
173
  continue
174
+ if _is_protected_variable(name, node.value):
175
+ continue
130
176
  self.definitions.append(
131
177
  (name, "variable", node.lineno, node.end_lineno, context)
132
178
  )
@@ -140,9 +186,12 @@ class _DefinitionCollector(ast.NodeVisitor):
140
186
  self.generic_visit(node)
141
187
  return
142
188
 
143
- if (not self._scope_stack or len(self._scope_stack) == 1) and isinstance(
144
- node.target, ast.Name
145
- ):
189
+ if (
190
+ not self._scope_stack
191
+ or (
192
+ self._class_stack and len(self._scope_stack) == len(self._class_stack)
193
+ )
194
+ ) and isinstance(node.target, ast.Name):
146
195
  name = node.target.id
147
196
  if not name.startswith("__"):
148
197
  context = (
@@ -153,6 +202,9 @@ class _DefinitionCollector(ast.NodeVisitor):
153
202
  ):
154
203
  self.generic_visit(node)
155
204
  return
205
+ if _is_protected_variable(name, node.value):
206
+ self.generic_visit(node)
207
+ return
156
208
  self.definitions.append(
157
209
  (name, "variable", node.lineno, node.end_lineno, context)
158
210
  )
@@ -330,6 +382,7 @@ def _detect_unused_locals_in_function(
330
382
  and name not in explicit_globals
331
383
  and name not in loaded_names
332
384
  and name not in seen_unused
385
+ and not _is_protected_variable(name, stmt.value)
333
386
  ):
334
387
  seen_unused.add(name)
335
388
  items.append(
@@ -353,6 +406,7 @@ def _detect_unused_locals_in_function(
353
406
  and name not in explicit_globals
354
407
  and name not in loaded_names
355
408
  and name not in seen_unused
409
+ and not _is_protected_variable(name, stmt.value)
356
410
  ):
357
411
  seen_unused.add(name)
358
412
  items.append(
@@ -377,6 +431,7 @@ def _detect_unused_locals_in_function(
377
431
  and name not in explicit_globals
378
432
  and name not in loaded_names
379
433
  and name not in seen_unused
434
+ and not _is_protected_variable(name, stmt.value)
380
435
  ):
381
436
  seen_unused.add(name)
382
437
  items.append(
@@ -523,9 +578,23 @@ class _ProjectScanState:
523
578
  def process_file(self, py_file: Path) -> None:
524
579
  try:
525
580
  content = py_file.read_text(encoding="utf-8", errors="replace")
581
+ except OSError:
582
+ return
583
+
584
+ try:
526
585
  tree = ast.parse(content, filename=str(py_file))
527
586
  except SyntaxError:
528
- return
587
+ from pycleaner.syntax_healer import SyntaxHealer
588
+
589
+ heal_res = SyntaxHealer().heal(content, filename=str(py_file))
590
+ if heal_res.is_valid:
591
+ try:
592
+ tree = ast.parse(heal_res.code, filename=str(py_file))
593
+ content = heal_res.code
594
+ except SyntaxError:
595
+ return
596
+ else:
597
+ return
529
598
 
530
599
  filepath_str = str(py_file)
531
600
  def_collector = _DefinitionCollector(
@@ -670,16 +739,13 @@ class DeadCodeDetector:
670
739
  )
671
740
  self.ignore_names = ignore_names or set()
672
741
 
673
- def scan_project(
674
- self, root_dir: Path | str, exclude_patterns: Sequence[str] = ()
675
- ) -> DeadCodeReport:
676
- """Scan an entire project directory for dead code."""
677
- root = Path(root_dir).resolve()
678
- py_files = self._discover_files(root, exclude_patterns=exclude_patterns)
679
-
742
+ def scan_files(self, target_files: Sequence[Path]) -> DeadCodeReport:
743
+ """Scan a specific sequence of Python files for dead code without walking parent directories."""
680
744
  state = _ProjectScanState(framework_registry=self.framework_registry)
681
- for py_file in py_files:
745
+ scanned_count = 0
746
+ for py_file in target_files:
682
747
  state.process_file(py_file)
748
+ scanned_count += 1
683
749
 
684
750
  items: list[DeadCodeItem] = list(state.unreachable)
685
751
  for filepath, name, kind, lineno, end_lineno, _ in state.definitions:
@@ -702,22 +768,41 @@ class DeadCodeDetector:
702
768
  items.sort(key=lambda x: (x.filepath, x.lineno))
703
769
  return DeadCodeReport(
704
770
  items=items,
705
- files_scanned=len(py_files),
771
+ files_scanned=scanned_count,
706
772
  total_definitions=len(state.definitions),
707
773
  )
708
774
 
775
+ def scan_project(
776
+ self, root_dir: Path | str, exclude_patterns: Sequence[str] = ()
777
+ ) -> DeadCodeReport:
778
+ """Scan an entire project directory for dead code."""
779
+ root = Path(root_dir).resolve()
780
+ py_files = self._discover_files(root, exclude_patterns=exclude_patterns)
781
+ return self.scan_files(py_files)
782
+
709
783
  def scan_source(self, source: str, filename: str = "<unknown>") -> DeadCodeReport:
710
- """Scan a single source string for dead code patterns (unreachable/empty only)."""
784
+ """Scan a single source string for dead code patterns (unreachable, empty branches, and local dead variables)."""
711
785
  try:
712
786
  tree = ast.parse(source, filename=filename)
713
787
  except SyntaxError:
714
- return DeadCodeReport()
788
+ from pycleaner.syntax_healer import SyntaxHealer
789
+
790
+ heal_res = SyntaxHealer().heal(source, filename=filename)
791
+ if heal_res.is_valid:
792
+ try:
793
+ tree = ast.parse(heal_res.code, filename=filename)
794
+ except SyntaxError:
795
+ return DeadCodeReport()
796
+ else:
797
+ return DeadCodeReport()
715
798
 
716
799
  unreachable = _UnreachableCodeDetector(filename, source.splitlines())
717
800
  unreachable.visit(tree)
718
801
 
802
+ items = list(unreachable.items)
803
+ items.sort(key=lambda x: (x.filepath, x.lineno))
719
804
  return DeadCodeReport(
720
- items=unreachable.items,
805
+ items=items,
721
806
  files_scanned=1,
722
807
  total_definitions=0,
723
808
  )
@@ -766,26 +851,38 @@ class DeadCodeDetector:
766
851
  def fix_file(
767
852
  self, filepath: Path | str, apply_changes: bool = True
768
853
  ) -> DeadCodeFixResult:
769
- """Surgically fix dead code in a file."""
854
+ """Surgically fix dead code in a file with safe read-only handling."""
770
855
  path = Path(filepath).resolve()
771
- content = path.read_text(encoding="utf-8", errors="replace")
856
+ try:
857
+ content = path.read_text(encoding="utf-8", errors="replace")
858
+ except OSError:
859
+ return DeadCodeFixResult(code="", changed=False, pruned_items=[])
772
860
  res = self.fix_source(content, filename=str(path))
773
861
  if apply_changes and res.changed:
774
- path.write_text(res.code, encoding="utf-8")
862
+ try:
863
+ path.write_text(res.code, encoding="utf-8")
864
+ except OSError:
865
+ return DeadCodeFixResult(code=content, changed=False, pruned_items=[])
775
866
  return res
776
867
 
868
+ def fix_files(
869
+ self, target_files: Sequence[Path], apply_changes: bool = True
870
+ ) -> dict[Path, DeadCodeFixResult]:
871
+ """Surgically fix dead code across a specific sequence of Python files."""
872
+ results: dict[Path, DeadCodeFixResult] = {}
873
+ for pf in target_files:
874
+ res = self.fix_file(pf, apply_changes=apply_changes)
875
+ if res.changed:
876
+ results[pf] = res
877
+ return results
878
+
777
879
  def fix_project(
778
880
  self, root_dir: Path | str, exclude_patterns: Sequence[str] = ()
779
881
  ) -> dict[Path, DeadCodeFixResult]:
780
882
  """Surgically fix dead code across all project Python files."""
781
883
  root = Path(root_dir).resolve()
782
884
  py_files = self._discover_files(root, exclude_patterns=exclude_patterns)
783
- results: dict[Path, DeadCodeFixResult] = {}
784
- for pf in py_files:
785
- res = self.fix_file(pf, apply_changes=True)
786
- if res.changed:
787
- results[pf] = res
788
- return results
885
+ return self.fix_files(py_files, apply_changes=True)
789
886
 
790
887
 
791
888
  @dataclass(slots=True)
@@ -1065,6 +1162,7 @@ class DeadCodeFixer:
1065
1162
  and name not in args_set
1066
1163
  and name not in explicit_globals
1067
1164
  and name not in loaded_names
1165
+ and not _is_protected_variable(name, stmt.value)
1068
1166
  ):
1069
1167
  if _is_pure_expression(stmt.value):
1070
1168
  deletions.append(
@@ -1078,14 +1176,18 @@ class DeadCodeFixer:
1078
1176
  )
1079
1177
  )
1080
1178
  else:
1081
- if hasattr(target, "col_offset") and hasattr(
1082
- target, "end_col_offset"
1179
+ col = getattr(target, "col_offset", None)
1180
+ end_col = getattr(
1181
+ target, "end_col_offset", None
1182
+ )
1183
+ if isinstance(col, int) and isinstance(
1184
+ end_col, int
1083
1185
  ):
1084
1186
  renames.append(
1085
1187
  (
1086
1188
  target.lineno,
1087
- target.col_offset,
1088
- target.end_col_offset,
1189
+ col,
1190
+ end_col,
1089
1191
  f"_{name}",
1090
1192
  name,
1091
1193
  )
@@ -1096,19 +1198,24 @@ class DeadCodeFixer:
1096
1198
  for elt in stmt.targets[0].elts:
1097
1199
  if isinstance(elt, ast.Name):
1098
1200
  name = elt.id
1201
+ col = getattr(elt, "col_offset", None)
1202
+ end_col = getattr(elt, "end_col_offset", None)
1099
1203
  if (
1100
1204
  not name.startswith("_")
1101
1205
  and name not in args_set
1102
1206
  and name not in explicit_globals
1103
1207
  and name not in loaded_names
1104
- and hasattr(elt, "col_offset")
1105
- and hasattr(elt, "end_col_offset")
1208
+ and not _is_protected_variable(
1209
+ name, stmt.value
1210
+ )
1211
+ and isinstance(col, int)
1212
+ and isinstance(end_col, int)
1106
1213
  ):
1107
1214
  renames.append(
1108
1215
  (
1109
1216
  elt.lineno,
1110
- elt.col_offset,
1111
- elt.end_col_offset,
1217
+ col,
1218
+ end_col,
1112
1219
  f"_{name}",
1113
1220
  name,
1114
1221
  )
@@ -1121,6 +1228,7 @@ class DeadCodeFixer:
1121
1228
  and name not in args_set
1122
1229
  and name not in explicit_globals
1123
1230
  and name not in loaded_names
1231
+ and not _is_protected_variable(name, stmt.value)
1124
1232
  ):
1125
1233
  if stmt.value is not None and _is_pure_expression(
1126
1234
  stmt.value
@@ -1137,14 +1245,18 @@ class DeadCodeFixer:
1137
1245
  )
1138
1246
  else:
1139
1247
  target = stmt.target
1140
- if hasattr(target, "col_offset") and hasattr(
1141
- target, "end_col_offset"
1248
+ col = getattr(target, "col_offset", None)
1249
+ end_col = getattr(
1250
+ target, "end_col_offset", None
1251
+ )
1252
+ if isinstance(col, int) and isinstance(
1253
+ end_col, int
1142
1254
  ):
1143
1255
  renames.append(
1144
1256
  (
1145
1257
  target.lineno,
1146
- target.col_offset,
1147
- target.end_col_offset,
1258
+ col,
1259
+ end_col,
1148
1260
  f"_{name}",
1149
1261
  name,
1150
1262
  )
@@ -1184,6 +1296,7 @@ class DeadCodeFixer:
1184
1296
  and name != "__all__"
1185
1297
  and name not in module_loads
1186
1298
  and name not in module_exports
1299
+ and not _is_protected_variable(name, stmt.value)
1187
1300
  ):
1188
1301
  if _is_pure_expression(stmt.value):
1189
1302
  deletions.append(
@@ -1195,14 +1308,14 @@ class DeadCodeFixer:
1195
1308
  )
1196
1309
  )
1197
1310
  else:
1198
- if hasattr(target, "col_offset") and hasattr(
1199
- target, "end_col_offset"
1200
- ):
1311
+ col = getattr(target, "col_offset", None)
1312
+ end_col = getattr(target, "end_col_offset", None)
1313
+ if isinstance(col, int) and isinstance(end_col, int):
1201
1314
  renames.append(
1202
1315
  (
1203
1316
  target.lineno,
1204
- target.col_offset,
1205
- target.end_col_offset,
1317
+ col,
1318
+ end_col,
1206
1319
  f"_{name}",
1207
1320
  name,
1208
1321
  )
@@ -450,6 +450,20 @@ class DependencyAuditor:
450
450
  details=details,
451
451
  )
452
452
 
453
+ @staticmethod
454
+ def _is_array_closing_bracket(line: str) -> bool:
455
+ """Check if ']' appears outside of quoted strings."""
456
+ in_quote: str | None = None
457
+ for char in line:
458
+ if char in ('"', "'"):
459
+ if in_quote is None:
460
+ in_quote = char
461
+ elif in_quote == char:
462
+ in_quote = None
463
+ elif char == "]" and in_quote is None:
464
+ return True
465
+ return False
466
+
453
467
  def _update_pyproject_toml(
454
468
  self, missing: set[str], unused_to_remove: set[str]
455
469
  ) -> bool:
@@ -483,7 +497,7 @@ class DependencyAuditor:
483
497
  stripped = line.strip()
484
498
 
485
499
  if re.match(r"^dependencies\s*=\s*\[", stripped):
486
- if "]" in stripped:
500
+ if self._is_array_closing_bracket(stripped):
487
501
  bracket_content = stripped[
488
502
  stripped.find("[") + 1 : stripped.rfind("]")
489
503
  ]
@@ -511,7 +525,7 @@ class DependencyAuditor:
511
525
  continue
512
526
 
513
527
  if in_dependencies_array:
514
- if "]" in stripped:
528
+ if self._is_array_closing_bracket(stripped):
515
529
  in_dependencies_array = False
516
530
  for sm in sorted(safe_missing):
517
531
  indent = " "
@@ -580,6 +594,7 @@ class DependencyAuditor:
580
594
  }
581
595
 
582
596
  new_lines: list[str] = []
597
+ modified = False
583
598
  for line in existing_lines:
584
599
  stripped = line.strip()
585
600
  if not stripped or stripped.startswith(("#", "-")):
@@ -591,11 +606,17 @@ class DependencyAuditor:
591
606
  pkg_name = pkg_match.group(1)
592
607
  canon = self.canonicalize_name(pkg_name)
593
608
  if canon in remove_canons:
609
+ modified = True
594
610
  continue
595
611
  new_lines.append(line)
596
612
 
597
613
  # Append missing packages
598
- new_lines.extend(sorted(safe_missing))
614
+ for sm in sorted(safe_missing):
615
+ new_lines.append(sm)
616
+ modified = True
617
+
618
+ if modified or (not req_file.exists() and safe_missing):
619
+ req_file.write_text("\n".join(new_lines) + "\n", encoding="utf-8")
620
+ return True
621
+ return False
599
622
 
600
- req_file.write_text("\n".join(new_lines) + "\n", encoding="utf-8")
601
- return True
pycleaner/discovery.py CHANGED
@@ -10,6 +10,7 @@ from __future__ import annotations
10
10
 
11
11
  import fnmatch
12
12
  import os
13
+ import tempfile
13
14
  from collections.abc import Sequence
14
15
  from pathlib import Path
15
16
 
@@ -93,7 +94,23 @@ def find_project_root(path: Path | str | None = None) -> Path:
93
94
  if start.is_file():
94
95
  start = start.parent
95
96
 
97
+ try:
98
+ user_home = Path.home().resolve()
99
+ except Exception:
100
+ user_home = None
101
+
102
+ try:
103
+ temp_dir = Path(tempfile.gettempdir()).resolve()
104
+ except Exception:
105
+ temp_dir = None
106
+
96
107
  for candidate in [start, *start.parents]:
108
+ # Do not climb above temp_dir if start was inside a temporary directory
109
+ if temp_dir and candidate == temp_dir and start != temp_dir:
110
+ break
111
+ # Do not climb into user home directory unless start is user home itself
112
+ if user_home and candidate == user_home and start != user_home:
113
+ break
97
114
  if (
98
115
  (candidate / "pyproject.toml").is_file()
99
116
  or (candidate / "setup.py").is_file()
@@ -55,9 +55,11 @@ class FrameworkRegistry:
55
55
 
56
56
  def __init__(self) -> None:
57
57
  self._plugins: list[FrameworkPlugin] = []
58
+ self._applicable_cache: dict[tuple[int, str], list[FrameworkPlugin]] = {}
58
59
 
59
60
  def register(self, plugin: FrameworkPlugin) -> None:
60
61
  self._plugins.append(plugin)
62
+ self._applicable_cache.clear()
61
63
 
62
64
  @property
63
65
  def plugins(self) -> list[FrameworkPlugin]:
@@ -66,7 +68,12 @@ class FrameworkRegistry:
66
68
  def get_applicable_plugins(
67
69
  self, tree: ast.AST, filepath: Path | str
68
70
  ) -> list[FrameworkPlugin]:
69
- return [p for p in self._plugins if p.is_applicable(tree, filepath)]
71
+ cache_key = (id(tree), str(filepath))
72
+ if cache_key in self._applicable_cache:
73
+ return self._applicable_cache[cache_key]
74
+ applicable = [p for p in self._plugins if p.is_applicable(tree, filepath)]
75
+ self._applicable_cache[cache_key] = applicable
76
+ return applicable
70
77
 
71
78
  def is_protected(
72
79
  self,
@@ -57,6 +57,25 @@ class _UsageCollector(ast.NodeVisitor):
57
57
  self.generic_visit(node)
58
58
 
59
59
 
60
+ def _find_executable(name: str) -> str | None:
61
+ """Find CLI tool executable in PATH, virtual environment Scripts/bin, or python directory."""
62
+ found = shutil.which(name)
63
+ if found:
64
+ return found
65
+ from pathlib import Path
66
+ scripts_dir = Path(sys.prefix) / ("Scripts" if sys.platform == "win32" else "bin")
67
+ for ext in (".exe", ""):
68
+ candidate = scripts_dir / f"{name}{ext}"
69
+ if candidate.is_file():
70
+ return str(candidate)
71
+ parent_dir = Path(sys.executable).parent
72
+ for ext in (".exe", ""):
73
+ candidate = parent_dir / f"{name}{ext}"
74
+ if candidate.is_file():
75
+ return str(candidate)
76
+ return None
77
+
78
+
60
79
  class LinterFormatter:
61
80
  """Orchestrates static lint autofixes and canonical formatting."""
62
81
 
@@ -72,10 +91,10 @@ class LinterFormatter:
72
91
  DEFAULT_SELECT_RULES = "F401,F841,I,UP,E,W,B,SIM,RUF"
73
92
 
74
93
  def __init__(self, ruff_path: str | None = None) -> None:
75
- self.ruff_cmd: str | None = ruff_path or shutil.which("ruff")
76
- self.autoflake_cmd: str | None = shutil.which("autoflake")
77
- self.black_cmd: str | None = shutil.which("black")
78
- self.isort_cmd: str | None = shutil.which("isort")
94
+ self.ruff_cmd: str | None = ruff_path or _find_executable("ruff")
95
+ self.autoflake_cmd: str | None = _find_executable("autoflake")
96
+ self.black_cmd: str | None = _find_executable("black")
97
+ self.isort_cmd: str | None = _find_executable("isort")
79
98
 
80
99
  def fix_and_format(
81
100
  self,