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.
- {ballpython-2.1.0.dist-info → ballpython-2.1.2.dist-info}/METADATA +1 -3
- ballpython-2.1.2.dist-info/RECORD +34 -0
- pycleaner/__init__.py +1 -1
- pycleaner/cli.py +202 -36
- pycleaner/complexity_analyzer.py +11 -8
- pycleaner/dead_code_detector.py +156 -43
- pycleaner/dependency_auditor.py +26 -5
- pycleaner/discovery.py +17 -0
- pycleaner/frameworks/__init__.py +8 -1
- pycleaner/linter_formatter.py +23 -4
- pycleaner/pipeline.py +99 -11
- pycleaner/security_scanner.py +149 -14
- pycleaner/syntax_healer.py +208 -43
- pycleaner/taint_engine.py +19 -15
- pycleaner/type_checker.py +128 -25
- pycleaner/verifier.py +12 -4
- ballpython-2.1.0.dist-info/RECORD +0 -34
- {ballpython-2.1.0.dist-info → ballpython-2.1.2.dist-info}/WHEEL +0 -0
- {ballpython-2.1.0.dist-info → ballpython-2.1.2.dist-info}/entry_points.txt +0 -0
- {ballpython-2.1.0.dist-info → ballpython-2.1.2.dist-info}/top_level.txt +0 -0
pycleaner/dead_code_detector.py
CHANGED
|
@@ -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
|
|
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 (
|
|
144
|
-
|
|
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
|
-
|
|
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
|
|
674
|
-
|
|
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
|
-
|
|
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=
|
|
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
|
|
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
|
-
|
|
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=
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
1082
|
-
|
|
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
|
-
|
|
1088
|
-
|
|
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
|
|
1105
|
-
|
|
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
|
-
|
|
1111
|
-
|
|
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
|
-
|
|
1141
|
-
|
|
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
|
-
|
|
1147
|
-
|
|
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
|
-
|
|
1199
|
-
|
|
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
|
-
|
|
1205
|
-
|
|
1317
|
+
col,
|
|
1318
|
+
end_col,
|
|
1206
1319
|
f"_{name}",
|
|
1207
1320
|
name,
|
|
1208
1321
|
)
|
pycleaner/dependency_auditor.py
CHANGED
|
@@ -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
|
|
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
|
|
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
|
-
|
|
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()
|
pycleaner/frameworks/__init__.py
CHANGED
|
@@ -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
|
-
|
|
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,
|
pycleaner/linter_formatter.py
CHANGED
|
@@ -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
|
|
76
|
-
self.autoflake_cmd: str | None =
|
|
77
|
-
self.black_cmd: str | None =
|
|
78
|
-
self.isort_cmd: str | None =
|
|
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,
|