ballpython 2.0.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- ballpython/__init__.py +9 -0
- ballpython/__main__.py +8 -0
- ballpython/cli.py +7 -0
- ballpython-2.0.0.dist-info/METADATA +322 -0
- ballpython-2.0.0.dist-info/RECORD +24 -0
- ballpython-2.0.0.dist-info/WHEEL +5 -0
- ballpython-2.0.0.dist-info/entry_points.txt +3 -0
- ballpython-2.0.0.dist-info/top_level.txt +2 -0
- pycleaner/__init__.py +53 -0
- pycleaner/__main__.py +8 -0
- pycleaner/cli.py +1548 -0
- pycleaner/complexity_analyzer.py +473 -0
- pycleaner/config.py +254 -0
- pycleaner/dead_code_detector.py +515 -0
- pycleaner/dependency_auditor.py +331 -0
- pycleaner/import_resolver.py +832 -0
- pycleaner/linter_formatter.py +590 -0
- pycleaner/pipeline.py +349 -0
- pycleaner/security_scanner.py +563 -0
- pycleaner/syntax_healer.py +577 -0
- pycleaner/taint_engine.py +720 -0
- pycleaner/test_generator.py +444 -0
- pycleaner/type_checker.py +989 -0
- pycleaner/typeshed_resolver.py +395 -0
|
@@ -0,0 +1,590 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Static linter and code formatter engine.
|
|
3
|
+
|
|
4
|
+
Integrates ultra-fast Ruff engine for auto-fixing lint errors, sorting imports,
|
|
5
|
+
pruning unused imports and variables, and formatting code. Provides graceful
|
|
6
|
+
fallbacks via autoflake, isort, black, and built-in pure-Python AST pruning.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import ast
|
|
12
|
+
import re
|
|
13
|
+
import shutil
|
|
14
|
+
import subprocess
|
|
15
|
+
import sys
|
|
16
|
+
from dataclasses import dataclass, field
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
@dataclass(slots=True)
|
|
20
|
+
class LintFormatResult:
|
|
21
|
+
"""Result of running linting fixes and code formatting."""
|
|
22
|
+
|
|
23
|
+
code: str
|
|
24
|
+
lint_changed: bool
|
|
25
|
+
format_changed: bool
|
|
26
|
+
diagnostics: list[str] = field(default_factory=list)
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
class _UsageCollector(ast.NodeVisitor):
|
|
30
|
+
"""Collects loaded identifier names and import nodes across an AST."""
|
|
31
|
+
|
|
32
|
+
def __init__(self) -> None:
|
|
33
|
+
self.used_names: set[str] = set()
|
|
34
|
+
self.import_nodes: list[ast.Import | ast.ImportFrom] = []
|
|
35
|
+
|
|
36
|
+
def visit_Name(self, node: ast.Name) -> None:
|
|
37
|
+
if isinstance(node.ctx, (ast.Load, ast.Del)):
|
|
38
|
+
self.used_names.add(node.id)
|
|
39
|
+
self.generic_visit(node)
|
|
40
|
+
|
|
41
|
+
def visit_Attribute(self, node: ast.Attribute) -> None:
|
|
42
|
+
self.visit(node.value)
|
|
43
|
+
|
|
44
|
+
def visit_Import(self, node: ast.Import) -> None:
|
|
45
|
+
self.import_nodes.append(node)
|
|
46
|
+
|
|
47
|
+
def visit_ImportFrom(self, node: ast.ImportFrom) -> None:
|
|
48
|
+
self.import_nodes.append(node)
|
|
49
|
+
|
|
50
|
+
def visit_Constant(self, node: ast.Constant) -> None:
|
|
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)
|
|
55
|
+
self.generic_visit(node)
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
class LinterFormatter:
|
|
59
|
+
"""Orchestrates static lint autofixes and canonical formatting."""
|
|
60
|
+
|
|
61
|
+
# Default rule selection for safe automated fixes:
|
|
62
|
+
# F401: unused imports
|
|
63
|
+
# F841: unused variables
|
|
64
|
+
# I: isort (import ordering & consolidation)
|
|
65
|
+
# UP: pyupgrade (modern Python syntax)
|
|
66
|
+
# E, W: pycodestyle whitespace/syntax
|
|
67
|
+
# B: flake8-bugbear
|
|
68
|
+
# SIM: flake8-simplify
|
|
69
|
+
# RUF: ruff-specific safe cleanups
|
|
70
|
+
DEFAULT_SELECT_RULES = "F401,F841,I,UP,E,W,B,SIM,RUF"
|
|
71
|
+
|
|
72
|
+
def __init__(self, ruff_path: str | None = None) -> None:
|
|
73
|
+
self.ruff_cmd: str | None = ruff_path or shutil.which("ruff")
|
|
74
|
+
self.autoflake_cmd: str | None = shutil.which("autoflake")
|
|
75
|
+
self.black_cmd: str | None = shutil.which("black")
|
|
76
|
+
self.isort_cmd: str | None = shutil.which("isort")
|
|
77
|
+
|
|
78
|
+
def fix_and_format(
|
|
79
|
+
self,
|
|
80
|
+
source: str,
|
|
81
|
+
filename: str = "<stdin>",
|
|
82
|
+
select_rules: str = DEFAULT_SELECT_RULES,
|
|
83
|
+
do_lint_fix: bool = True,
|
|
84
|
+
do_format: bool = True,
|
|
85
|
+
) -> LintFormatResult:
|
|
86
|
+
"""Run lint fixing and code formatting sequentially on source code."""
|
|
87
|
+
current_code = source
|
|
88
|
+
lint_changed = False
|
|
89
|
+
format_changed = False
|
|
90
|
+
diagnostics: list[str] = []
|
|
91
|
+
|
|
92
|
+
if do_lint_fix:
|
|
93
|
+
fixed_code, changed, diag = self.fix_lint(
|
|
94
|
+
current_code, filename=filename, select_rules=select_rules
|
|
95
|
+
)
|
|
96
|
+
if changed:
|
|
97
|
+
lint_changed = True
|
|
98
|
+
current_code = fixed_code
|
|
99
|
+
if diag:
|
|
100
|
+
diagnostics.extend(diag)
|
|
101
|
+
|
|
102
|
+
if do_format:
|
|
103
|
+
formatted_code, changed, diag = self.format_code(
|
|
104
|
+
current_code, filename=filename
|
|
105
|
+
)
|
|
106
|
+
if changed:
|
|
107
|
+
format_changed = True
|
|
108
|
+
current_code = formatted_code
|
|
109
|
+
if diag:
|
|
110
|
+
diagnostics.extend(diag)
|
|
111
|
+
|
|
112
|
+
return LintFormatResult(
|
|
113
|
+
code=current_code,
|
|
114
|
+
lint_changed=lint_changed,
|
|
115
|
+
format_changed=format_changed,
|
|
116
|
+
diagnostics=diagnostics,
|
|
117
|
+
)
|
|
118
|
+
|
|
119
|
+
def _fix_lint_ruff(
|
|
120
|
+
self, source: str, filename: str, select_rules: str, diagnostics: list[str]
|
|
121
|
+
) -> tuple[str, bool, list[str]] | None:
|
|
122
|
+
if not self.ruff_cmd:
|
|
123
|
+
return None
|
|
124
|
+
cmd = [
|
|
125
|
+
self.ruff_cmd,
|
|
126
|
+
"check",
|
|
127
|
+
"--fix",
|
|
128
|
+
f"--select={select_rules}",
|
|
129
|
+
"--stdin-filename",
|
|
130
|
+
filename,
|
|
131
|
+
"-",
|
|
132
|
+
]
|
|
133
|
+
try:
|
|
134
|
+
proc = subprocess.run(
|
|
135
|
+
cmd, input=source.encode("utf-8"), capture_output=True, check=False
|
|
136
|
+
)
|
|
137
|
+
output = proc.stdout.decode("utf-8")
|
|
138
|
+
stderr = proc.stderr.decode("utf-8").strip()
|
|
139
|
+
diag = [stderr] if stderr else []
|
|
140
|
+
if proc.returncode in (0, 1) and output:
|
|
141
|
+
return output, output != source, diag
|
|
142
|
+
except OSError as err:
|
|
143
|
+
diagnostics.append(
|
|
144
|
+
f"Ruff unavailable ({err}); falling through to fallbacks."
|
|
145
|
+
)
|
|
146
|
+
return None
|
|
147
|
+
|
|
148
|
+
def _fix_lint_autoflake(
|
|
149
|
+
self, source: str, filename: str, diagnostics: list[str]
|
|
150
|
+
) -> tuple[str, bool]:
|
|
151
|
+
if not self.autoflake_cmd:
|
|
152
|
+
return source, False
|
|
153
|
+
cmd = [
|
|
154
|
+
self.autoflake_cmd,
|
|
155
|
+
"--remove-all-unused-imports",
|
|
156
|
+
"--stdin-display-name",
|
|
157
|
+
filename,
|
|
158
|
+
"-",
|
|
159
|
+
]
|
|
160
|
+
try:
|
|
161
|
+
proc = subprocess.run(
|
|
162
|
+
cmd, input=source.encode("utf-8"), capture_output=True, check=False
|
|
163
|
+
)
|
|
164
|
+
if proc.returncode == 0 and proc.stdout:
|
|
165
|
+
output = proc.stdout.decode("utf-8")
|
|
166
|
+
if output != source:
|
|
167
|
+
diagnostics.append("Pruned unused imports using autoflake fallback")
|
|
168
|
+
return output, True
|
|
169
|
+
except OSError:
|
|
170
|
+
# Fall back to pure-Python import pruning if autoflake CLI fails
|
|
171
|
+
pass
|
|
172
|
+
return source, False
|
|
173
|
+
|
|
174
|
+
def _isort_cli_sort(self, code: str) -> str | None:
|
|
175
|
+
if not self.isort_cmd:
|
|
176
|
+
return None
|
|
177
|
+
try:
|
|
178
|
+
proc = subprocess.run(
|
|
179
|
+
[self.isort_cmd, "-"],
|
|
180
|
+
input=code.encode("utf-8"),
|
|
181
|
+
capture_output=True,
|
|
182
|
+
check=False,
|
|
183
|
+
)
|
|
184
|
+
if proc.returncode == 0 and proc.stdout:
|
|
185
|
+
res = proc.stdout.decode("utf-8")
|
|
186
|
+
return res if res != code else None
|
|
187
|
+
except OSError:
|
|
188
|
+
# Fall back to isort Python module or pure-Python import sorter
|
|
189
|
+
pass
|
|
190
|
+
return None
|
|
191
|
+
|
|
192
|
+
def _isort_module_sort(self, code: str) -> str | None:
|
|
193
|
+
try:
|
|
194
|
+
import isort # type: ignore
|
|
195
|
+
|
|
196
|
+
res = isort.code(code)
|
|
197
|
+
return res if res != code else None
|
|
198
|
+
except ImportError:
|
|
199
|
+
return None
|
|
200
|
+
|
|
201
|
+
def _fix_lint_isort(
|
|
202
|
+
self, current_code: str, diagnostics: list[str]
|
|
203
|
+
) -> tuple[str, bool]:
|
|
204
|
+
cli_sorted = self._isort_cli_sort(current_code)
|
|
205
|
+
if cli_sorted is not None:
|
|
206
|
+
diagnostics.append("Sorted imports using isort CLI fallback")
|
|
207
|
+
return cli_sorted, True
|
|
208
|
+
|
|
209
|
+
mod_sorted = self._isort_module_sort(current_code)
|
|
210
|
+
if mod_sorted is not None:
|
|
211
|
+
diagnostics.append("Sorted imports using isort module fallback")
|
|
212
|
+
return mod_sorted, True
|
|
213
|
+
|
|
214
|
+
return current_code, False
|
|
215
|
+
|
|
216
|
+
def fix_lint(
|
|
217
|
+
self,
|
|
218
|
+
source: str,
|
|
219
|
+
filename: str = "<stdin>",
|
|
220
|
+
select_rules: str = DEFAULT_SELECT_RULES,
|
|
221
|
+
) -> tuple[str, bool, list[str]]:
|
|
222
|
+
"""Fix linting errors and prune unused imports."""
|
|
223
|
+
current_code = source
|
|
224
|
+
changed = False
|
|
225
|
+
diagnostics: list[str] = []
|
|
226
|
+
|
|
227
|
+
ruff_result = self._fix_lint_ruff(source, filename, select_rules, diagnostics)
|
|
228
|
+
if ruff_result is not None:
|
|
229
|
+
return ruff_result
|
|
230
|
+
|
|
231
|
+
current_code, af_changed = self._fix_lint_autoflake(
|
|
232
|
+
current_code, filename, diagnostics
|
|
233
|
+
)
|
|
234
|
+
changed = changed or af_changed
|
|
235
|
+
|
|
236
|
+
if current_code == source:
|
|
237
|
+
pruned_code, pruned_changed, prune_diags = (
|
|
238
|
+
self._pure_python_prune_unused_imports(current_code)
|
|
239
|
+
)
|
|
240
|
+
if pruned_changed:
|
|
241
|
+
current_code = pruned_code
|
|
242
|
+
changed = True
|
|
243
|
+
diagnostics.extend(prune_diags)
|
|
244
|
+
|
|
245
|
+
current_code, isort_changed = self._fix_lint_isort(current_code, diagnostics)
|
|
246
|
+
changed = changed or isort_changed
|
|
247
|
+
|
|
248
|
+
sorted_code, sort_changed, sort_diags = self._pure_python_sort_imports(
|
|
249
|
+
current_code
|
|
250
|
+
)
|
|
251
|
+
if sort_changed:
|
|
252
|
+
current_code = sorted_code
|
|
253
|
+
changed = True
|
|
254
|
+
diagnostics.extend(sort_diags)
|
|
255
|
+
|
|
256
|
+
return current_code, changed, diagnostics
|
|
257
|
+
|
|
258
|
+
def _format_with_ruff(self, source: str, filename: str) -> tuple[str, bool] | None:
|
|
259
|
+
if not self.ruff_cmd:
|
|
260
|
+
return None
|
|
261
|
+
cmd = [self.ruff_cmd, "format", "--stdin-filename", filename, "-"]
|
|
262
|
+
try:
|
|
263
|
+
proc = subprocess.run(
|
|
264
|
+
cmd, input=source.encode("utf-8"), capture_output=True, check=False
|
|
265
|
+
)
|
|
266
|
+
if proc.returncode == 0:
|
|
267
|
+
res = proc.stdout.decode("utf-8")
|
|
268
|
+
return res, res != source
|
|
269
|
+
except OSError:
|
|
270
|
+
# Fall back to Black or pure-Python formatter if ruff CLI execution fails
|
|
271
|
+
pass
|
|
272
|
+
return None
|
|
273
|
+
|
|
274
|
+
def _format_with_black(self, source: str) -> tuple[str, bool, list[str]] | None:
|
|
275
|
+
if self.black_cmd:
|
|
276
|
+
try:
|
|
277
|
+
proc = subprocess.run(
|
|
278
|
+
[self.black_cmd, "-"],
|
|
279
|
+
input=source.encode("utf-8"),
|
|
280
|
+
capture_output=True,
|
|
281
|
+
check=False,
|
|
282
|
+
)
|
|
283
|
+
if proc.returncode == 0 and proc.stdout:
|
|
284
|
+
res = proc.stdout.decode("utf-8")
|
|
285
|
+
return res, res != source, ["Formatted with black CLI fallback"]
|
|
286
|
+
except OSError:
|
|
287
|
+
# Fall back to black module or pure-Python formatter
|
|
288
|
+
pass
|
|
289
|
+
try:
|
|
290
|
+
import black # type: ignore
|
|
291
|
+
|
|
292
|
+
formatted = black.format_str(source, mode=black.Mode())
|
|
293
|
+
return formatted, formatted != source, ["Formatted with black fallback"]
|
|
294
|
+
except ImportError:
|
|
295
|
+
return None
|
|
296
|
+
|
|
297
|
+
def format_code(
|
|
298
|
+
self,
|
|
299
|
+
source: str,
|
|
300
|
+
filename: str = "<stdin>",
|
|
301
|
+
) -> tuple[str, bool, list[str]]:
|
|
302
|
+
"""Format source code deterministically."""
|
|
303
|
+
ruff_res = self._format_with_ruff(source, filename)
|
|
304
|
+
if ruff_res is not None:
|
|
305
|
+
return ruff_res[0], ruff_res[1], []
|
|
306
|
+
|
|
307
|
+
black_res = self._format_with_black(source)
|
|
308
|
+
if black_res is not None:
|
|
309
|
+
return black_res
|
|
310
|
+
|
|
311
|
+
formatted = self._pure_python_format(source)
|
|
312
|
+
if formatted != source:
|
|
313
|
+
return formatted, True, ["Applied pure-Python whitespace canonicalization"]
|
|
314
|
+
return source, False, []
|
|
315
|
+
|
|
316
|
+
def _pure_python_format(self, source: str, line_length: int = 88) -> str:
|
|
317
|
+
"""Strip trailing whitespace, collapse excessive blank lines, wrap long imports, and ensure a trailing newline."""
|
|
318
|
+
lines = [line.rstrip() for line in source.splitlines()]
|
|
319
|
+
cleaned = "\n".join(lines).strip() + "\n" if lines else ""
|
|
320
|
+
cleaned = re.sub(r"\n{3,}", "\n\n", cleaned)
|
|
321
|
+
cleaned = self._wrap_long_imports(cleaned, line_length=line_length)
|
|
322
|
+
return cleaned
|
|
323
|
+
|
|
324
|
+
@staticmethod
|
|
325
|
+
def _wrap_single_line(line: str, line_length: int) -> str | None:
|
|
326
|
+
stripped = line.strip()
|
|
327
|
+
if (
|
|
328
|
+
len(line.rstrip("\r\n")) <= line_length
|
|
329
|
+
or not stripped.startswith("from ")
|
|
330
|
+
or " import " not in stripped
|
|
331
|
+
or "(" in stripped
|
|
332
|
+
):
|
|
333
|
+
return None
|
|
334
|
+
parts = stripped.split(" import ", 1)
|
|
335
|
+
prefix = parts[0]
|
|
336
|
+
names = [n.strip() for n in parts[1].split(",") if n.strip()]
|
|
337
|
+
if len(names) <= 1:
|
|
338
|
+
return None
|
|
339
|
+
indent_match = re.match(r"^([ \t]*)", line)
|
|
340
|
+
indent = indent_match.group(1) if indent_match else ""
|
|
341
|
+
wrapped = f"{indent}{prefix} import (\n"
|
|
342
|
+
for name in names:
|
|
343
|
+
wrapped += f"{indent} {name},\n"
|
|
344
|
+
wrapped += f"{indent})\n"
|
|
345
|
+
return wrapped
|
|
346
|
+
|
|
347
|
+
def _wrap_long_imports(self, source: str, line_length: int = 88) -> str:
|
|
348
|
+
"""Wrap import statements exceeding line_length onto multiple lines."""
|
|
349
|
+
lines = source.splitlines(keepends=True)
|
|
350
|
+
changed = False
|
|
351
|
+
|
|
352
|
+
for i, line in enumerate(lines):
|
|
353
|
+
wrapped = self._wrap_single_line(line, line_length)
|
|
354
|
+
if wrapped is not None:
|
|
355
|
+
lines[i] = wrapped
|
|
356
|
+
changed = True
|
|
357
|
+
|
|
358
|
+
if changed:
|
|
359
|
+
candidate = "".join(lines)
|
|
360
|
+
try:
|
|
361
|
+
ast.parse(candidate)
|
|
362
|
+
return candidate
|
|
363
|
+
except SyntaxError:
|
|
364
|
+
return source
|
|
365
|
+
return source
|
|
366
|
+
|
|
367
|
+
@staticmethod
|
|
368
|
+
def _categorize_import(
|
|
369
|
+
node: ast.Import | ast.ImportFrom,
|
|
370
|
+
stmt_str: str,
|
|
371
|
+
stdlib_names: frozenset[str] | set[str],
|
|
372
|
+
) -> tuple[str, str]:
|
|
373
|
+
"""Return (group_name, stmt_str) where group_name is future/local/stdlib/third_party."""
|
|
374
|
+
if isinstance(node, ast.ImportFrom) and node.module == "__future__":
|
|
375
|
+
return "future", stmt_str
|
|
376
|
+
if isinstance(node, ast.ImportFrom) and (node.level and node.level > 0):
|
|
377
|
+
return "local", stmt_str
|
|
378
|
+
root_mod = (
|
|
379
|
+
(node.module or "").split(".")[0]
|
|
380
|
+
if isinstance(node, ast.ImportFrom)
|
|
381
|
+
else node.names[0].name.split(".")[0]
|
|
382
|
+
)
|
|
383
|
+
if root_mod in stdlib_names:
|
|
384
|
+
return "stdlib", stmt_str
|
|
385
|
+
return "third_party", stmt_str
|
|
386
|
+
|
|
387
|
+
@staticmethod
|
|
388
|
+
def _assemble_sorted_import_block(groups_dict: dict[str, list[str]]) -> str:
|
|
389
|
+
groups: list[str] = []
|
|
390
|
+
combined_stdlib = sorted(groups_dict["future"]) + sorted(groups_dict["stdlib"])
|
|
391
|
+
if combined_stdlib:
|
|
392
|
+
groups.append("\n".join(combined_stdlib))
|
|
393
|
+
if groups_dict["third_party"]:
|
|
394
|
+
groups.append("\n".join(sorted(groups_dict["third_party"])))
|
|
395
|
+
if groups_dict["local"]:
|
|
396
|
+
groups.append("\n".join(sorted(groups_dict["local"])))
|
|
397
|
+
return "\n\n".join(groups) + "\n"
|
|
398
|
+
|
|
399
|
+
@staticmethod
|
|
400
|
+
def _has_interspersed_non_imports(
|
|
401
|
+
lines: list[str], import_line_set: set[int], min_line: int, max_line: int
|
|
402
|
+
) -> bool:
|
|
403
|
+
for lno in range(min_line, max_line + 1):
|
|
404
|
+
if lno not in import_line_set:
|
|
405
|
+
txt = lines[lno - 1].strip()
|
|
406
|
+
if txt and not txt.startswith("#"):
|
|
407
|
+
return True
|
|
408
|
+
return False
|
|
409
|
+
|
|
410
|
+
@staticmethod
|
|
411
|
+
def _group_import_nodes(
|
|
412
|
+
import_nodes: list[ast.Import | ast.ImportFrom],
|
|
413
|
+
lines: list[str],
|
|
414
|
+
stdlib_names: frozenset[str] | set[str],
|
|
415
|
+
) -> dict[str, list[str]]:
|
|
416
|
+
groups_dict: dict[str, list[str]] = {
|
|
417
|
+
"future": [],
|
|
418
|
+
"stdlib": [],
|
|
419
|
+
"third_party": [],
|
|
420
|
+
"local": [],
|
|
421
|
+
}
|
|
422
|
+
for node in import_nodes:
|
|
423
|
+
stmt_lines = lines[
|
|
424
|
+
node.lineno - 1 : getattr(node, "end_lineno", node.lineno)
|
|
425
|
+
]
|
|
426
|
+
stmt_str = "".join(stmt_lines).rstrip("\r\n")
|
|
427
|
+
grp, text = LinterFormatter._categorize_import(node, stmt_str, stdlib_names)
|
|
428
|
+
groups_dict[grp].append(text)
|
|
429
|
+
return groups_dict
|
|
430
|
+
|
|
431
|
+
@staticmethod
|
|
432
|
+
def _validate_sorted_candidate(
|
|
433
|
+
candidate: str, source: str
|
|
434
|
+
) -> tuple[str, bool, list[str]]:
|
|
435
|
+
try:
|
|
436
|
+
ast.parse(candidate)
|
|
437
|
+
if candidate != source:
|
|
438
|
+
return (
|
|
439
|
+
candidate,
|
|
440
|
+
True,
|
|
441
|
+
["Sorted imports into stdlib, third-party, and local groups"],
|
|
442
|
+
)
|
|
443
|
+
except SyntaxError:
|
|
444
|
+
# Return unmodified source if candidate code has syntax issues
|
|
445
|
+
pass
|
|
446
|
+
return source, False, []
|
|
447
|
+
|
|
448
|
+
def _pure_python_sort_imports(self, source: str) -> tuple[str, bool, list[str]]:
|
|
449
|
+
"""Sort imports into 3 groups (stdlib -> third-party -> local) when ruff/isort unavailable."""
|
|
450
|
+
try:
|
|
451
|
+
tree = ast.parse(source)
|
|
452
|
+
except SyntaxError:
|
|
453
|
+
return source, False, []
|
|
454
|
+
|
|
455
|
+
import_nodes = [
|
|
456
|
+
n for n in tree.body if isinstance(n, (ast.Import, ast.ImportFrom))
|
|
457
|
+
]
|
|
458
|
+
if len(import_nodes) < 2:
|
|
459
|
+
return source, False, []
|
|
460
|
+
|
|
461
|
+
min_line = min(node.lineno for node in import_nodes)
|
|
462
|
+
max_line = max(
|
|
463
|
+
getattr(node, "end_lineno", node.lineno) for node in import_nodes
|
|
464
|
+
)
|
|
465
|
+
lines = source.splitlines(keepends=True)
|
|
466
|
+
|
|
467
|
+
import_line_set = {
|
|
468
|
+
lno
|
|
469
|
+
for node in import_nodes
|
|
470
|
+
for lno in range(node.lineno, getattr(node, "end_lineno", node.lineno) + 1)
|
|
471
|
+
}
|
|
472
|
+
if self._has_interspersed_non_imports(
|
|
473
|
+
lines, import_line_set, min_line, max_line
|
|
474
|
+
):
|
|
475
|
+
return source, False, []
|
|
476
|
+
|
|
477
|
+
stdlib_names: frozenset[str] | set[str] = getattr(
|
|
478
|
+
sys, "stdlib_module_names", set()
|
|
479
|
+
)
|
|
480
|
+
groups_dict = self._group_import_nodes(import_nodes, lines, stdlib_names)
|
|
481
|
+
new_block = self._assemble_sorted_import_block(groups_dict)
|
|
482
|
+
candidate = "".join(lines[: min_line - 1] + [new_block] + lines[max_line:])
|
|
483
|
+
return self._validate_sorted_candidate(candidate, source)
|
|
484
|
+
|
|
485
|
+
@staticmethod
|
|
486
|
+
def _is_suppressed_import(stmt_lines: list[str]) -> bool:
|
|
487
|
+
joined = "".join(stmt_lines)
|
|
488
|
+
return "# noqa" in joined or "# type: ignore" in joined
|
|
489
|
+
|
|
490
|
+
@staticmethod
|
|
491
|
+
def _format_pruned_stmt(node: ast.AST, kept_aliases: list[ast.alias]) -> str:
|
|
492
|
+
indent = " " * getattr(node, "col_offset", 0)
|
|
493
|
+
alias_strs = [
|
|
494
|
+
f"{a.name} as {a.asname}" if a.asname else a.name for a in kept_aliases
|
|
495
|
+
]
|
|
496
|
+
if isinstance(node, ast.ImportFrom):
|
|
497
|
+
dots = "." * (node.level or 0)
|
|
498
|
+
mod = node.module or ""
|
|
499
|
+
return f"{indent}from {dots}{mod} import {', '.join(alias_strs)}\n"
|
|
500
|
+
return f"{indent}import {', '.join(alias_strs)}\n"
|
|
501
|
+
|
|
502
|
+
@staticmethod
|
|
503
|
+
def _partition_aliases(
|
|
504
|
+
node: ast.AST, used_names: set[str]
|
|
505
|
+
) -> tuple[list[ast.alias], list[str]]:
|
|
506
|
+
kept_aliases: list[ast.alias] = []
|
|
507
|
+
unused_aliases: list[str] = []
|
|
508
|
+
for alias in getattr(node, "names", []):
|
|
509
|
+
bound = alias.asname or (
|
|
510
|
+
alias.name.split(".")[0] if isinstance(node, ast.Import) else alias.name
|
|
511
|
+
)
|
|
512
|
+
if bound in used_names:
|
|
513
|
+
kept_aliases.append(alias)
|
|
514
|
+
else:
|
|
515
|
+
unused_aliases.append(bound)
|
|
516
|
+
return kept_aliases, unused_aliases
|
|
517
|
+
|
|
518
|
+
def _prune_single_import(
|
|
519
|
+
self,
|
|
520
|
+
node: ast.AST,
|
|
521
|
+
lines: list[str],
|
|
522
|
+
used_names: set[str],
|
|
523
|
+
diagnostics: list[str],
|
|
524
|
+
) -> bool:
|
|
525
|
+
start_line = getattr(node, "lineno", None)
|
|
526
|
+
end_line = getattr(node, "end_lineno", start_line)
|
|
527
|
+
if start_line is None or end_line is None:
|
|
528
|
+
return False
|
|
529
|
+
|
|
530
|
+
if self._is_suppressed_import(lines[start_line - 1 : end_line]):
|
|
531
|
+
return False
|
|
532
|
+
|
|
533
|
+
if isinstance(node, ast.ImportFrom) and any(
|
|
534
|
+
alias.name == "*" for alias in node.names
|
|
535
|
+
):
|
|
536
|
+
return False
|
|
537
|
+
|
|
538
|
+
kept_aliases, unused_aliases = self._partition_aliases(node, used_names)
|
|
539
|
+
if not unused_aliases:
|
|
540
|
+
return False
|
|
541
|
+
|
|
542
|
+
if not kept_aliases:
|
|
543
|
+
del lines[start_line - 1 : end_line]
|
|
544
|
+
else:
|
|
545
|
+
lines[start_line - 1 : end_line] = [
|
|
546
|
+
self._format_pruned_stmt(node, kept_aliases)
|
|
547
|
+
]
|
|
548
|
+
|
|
549
|
+
for unused_name in unused_aliases:
|
|
550
|
+
diagnostics.append(
|
|
551
|
+
f"Pruned unused import '{unused_name}' (pure-Python fallback)"
|
|
552
|
+
)
|
|
553
|
+
return True
|
|
554
|
+
|
|
555
|
+
def _pure_python_prune_unused_imports(
|
|
556
|
+
self, source: str
|
|
557
|
+
) -> tuple[str, bool, list[str]]:
|
|
558
|
+
"""Statically detect and prune unused imports using AST analysis."""
|
|
559
|
+
try:
|
|
560
|
+
tree = ast.parse(source)
|
|
561
|
+
except SyntaxError:
|
|
562
|
+
return source, False, []
|
|
563
|
+
|
|
564
|
+
collector = _UsageCollector()
|
|
565
|
+
collector.visit(tree)
|
|
566
|
+
lines = source.splitlines(keepends=True)
|
|
567
|
+
diagnostics: list[str] = []
|
|
568
|
+
changed = False
|
|
569
|
+
|
|
570
|
+
sorted_imports = sorted(
|
|
571
|
+
collector.import_nodes,
|
|
572
|
+
key=lambda node: getattr(node, "lineno", 0),
|
|
573
|
+
reverse=True,
|
|
574
|
+
)
|
|
575
|
+
|
|
576
|
+
for node in sorted_imports:
|
|
577
|
+
if self._prune_single_import(
|
|
578
|
+
node, lines, collector.used_names, diagnostics
|
|
579
|
+
):
|
|
580
|
+
changed = True
|
|
581
|
+
|
|
582
|
+
if not changed:
|
|
583
|
+
return source, False, []
|
|
584
|
+
|
|
585
|
+
candidate = re.sub(r"\n{3,}", "\n\n", "".join(lines))
|
|
586
|
+
try:
|
|
587
|
+
ast.parse(candidate)
|
|
588
|
+
return candidate, True, diagnostics
|
|
589
|
+
except SyntaxError:
|
|
590
|
+
return source, False, []
|