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
pycleaner/pipeline.py
ADDED
|
@@ -0,0 +1,349 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Cleanup pipeline coordinating syntax healing, import resolution, linting, and formatting.
|
|
3
|
+
"""
|
|
4
|
+
|
|
5
|
+
from __future__ import annotations
|
|
6
|
+
|
|
7
|
+
import ast
|
|
8
|
+
import difflib
|
|
9
|
+
import shutil
|
|
10
|
+
from concurrent.futures import ProcessPoolExecutor, as_completed
|
|
11
|
+
from dataclasses import dataclass, field
|
|
12
|
+
from pathlib import Path
|
|
13
|
+
from typing import TYPE_CHECKING, Any
|
|
14
|
+
|
|
15
|
+
from pycleaner.import_resolver import ImportResolver
|
|
16
|
+
from pycleaner.linter_formatter import LinterFormatter
|
|
17
|
+
from pycleaner.syntax_healer import SyntaxHealer
|
|
18
|
+
|
|
19
|
+
if TYPE_CHECKING:
|
|
20
|
+
from pycleaner.config import PyCleanerConfig
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
@dataclass(slots=True)
|
|
24
|
+
class CleanResult:
|
|
25
|
+
"""Detailed result of cleaning a single Python file."""
|
|
26
|
+
|
|
27
|
+
path: Path
|
|
28
|
+
original_code: str
|
|
29
|
+
cleaned_code: str
|
|
30
|
+
changed: bool
|
|
31
|
+
is_valid_python: bool
|
|
32
|
+
syntax_repairs: list[str] = field(default_factory=list)
|
|
33
|
+
resolved_imports: list[str] = field(default_factory=list)
|
|
34
|
+
unresolved_symbols: list[str] = field(default_factory=list)
|
|
35
|
+
lint_changed: bool = False
|
|
36
|
+
format_changed: bool = False
|
|
37
|
+
error: str | None = None
|
|
38
|
+
diagnostics: list[dict[str, str]] = field(default_factory=list)
|
|
39
|
+
|
|
40
|
+
@property
|
|
41
|
+
def diff(self) -> str:
|
|
42
|
+
"""Produce unified diff string comparing original and cleaned code."""
|
|
43
|
+
if not self.changed:
|
|
44
|
+
return ""
|
|
45
|
+
orig_lines = self.original_code.splitlines(keepends=True)
|
|
46
|
+
clean_lines = self.cleaned_code.splitlines(keepends=True)
|
|
47
|
+
filename = str(self.path)
|
|
48
|
+
diff_lines = difflib.unified_diff(
|
|
49
|
+
orig_lines,
|
|
50
|
+
clean_lines,
|
|
51
|
+
fromfile=f"a/{filename}",
|
|
52
|
+
tofile=f"b/{filename}",
|
|
53
|
+
)
|
|
54
|
+
return "".join(diff_lines)
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
@dataclass(slots=True)
|
|
58
|
+
class PipelineOptions:
|
|
59
|
+
"""Configurable feature flags and mappings for CleanPipeline."""
|
|
60
|
+
|
|
61
|
+
enable_syntax_healing: bool = True
|
|
62
|
+
enable_import_resolution: bool = True
|
|
63
|
+
enable_lint_fixing: bool = True
|
|
64
|
+
enable_formatting: bool = True
|
|
65
|
+
custom_import_map: dict[str, str] | None = None
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
class CleanPipeline:
|
|
69
|
+
"""Orchestrates all static cleanup passes for Python source files."""
|
|
70
|
+
|
|
71
|
+
def __init__(
|
|
72
|
+
self,
|
|
73
|
+
options: PipelineOptions | None = None,
|
|
74
|
+
config: PyCleanerConfig | Any | None = None,
|
|
75
|
+
**kwargs: Any,
|
|
76
|
+
) -> None:
|
|
77
|
+
opts = options or PipelineOptions(
|
|
78
|
+
enable_syntax_healing=kwargs.get("enable_syntax_healing", True),
|
|
79
|
+
enable_import_resolution=kwargs.get("enable_import_resolution", True),
|
|
80
|
+
enable_lint_fixing=kwargs.get("enable_lint_fixing", True),
|
|
81
|
+
enable_formatting=kwargs.get("enable_formatting", True),
|
|
82
|
+
custom_import_map=kwargs.get("custom_import_map"),
|
|
83
|
+
)
|
|
84
|
+
self.enable_syntax_healing = opts.enable_syntax_healing
|
|
85
|
+
self.enable_import_resolution = opts.enable_import_resolution
|
|
86
|
+
self.enable_lint_fixing = opts.enable_lint_fixing
|
|
87
|
+
self.enable_formatting = opts.enable_formatting
|
|
88
|
+
self.config = config
|
|
89
|
+
|
|
90
|
+
import_map = dict(opts.custom_import_map or {})
|
|
91
|
+
custom_map = getattr(config, "custom_import_map", None)
|
|
92
|
+
if custom_map:
|
|
93
|
+
import_map.update(custom_map)
|
|
94
|
+
|
|
95
|
+
self.syntax_healer = SyntaxHealer()
|
|
96
|
+
self.import_resolver = ImportResolver(custom_import_map=import_map)
|
|
97
|
+
self.linter_formatter = LinterFormatter()
|
|
98
|
+
|
|
99
|
+
def _stage_heal(
|
|
100
|
+
self, current_code: str, filename: str, syntax_repairs: list[str]
|
|
101
|
+
) -> tuple[str, str | None]:
|
|
102
|
+
"""Execute Stage 1: Syntax Healing."""
|
|
103
|
+
if not self.enable_syntax_healing:
|
|
104
|
+
return current_code, None
|
|
105
|
+
|
|
106
|
+
heal_res = self.syntax_healer.heal(current_code, filename=filename)
|
|
107
|
+
if heal_res.repairs:
|
|
108
|
+
syntax_repairs.extend(heal_res.repairs)
|
|
109
|
+
current_code = heal_res.code
|
|
110
|
+
|
|
111
|
+
if not heal_res.is_valid:
|
|
112
|
+
col_info = (
|
|
113
|
+
f":{heal_res.error_offset}" if heal_res.error_offset is not None else ""
|
|
114
|
+
)
|
|
115
|
+
return (
|
|
116
|
+
current_code,
|
|
117
|
+
f"SyntaxError at line {heal_res.error_lineno}{col_info}: {heal_res.error_message}",
|
|
118
|
+
)
|
|
119
|
+
return current_code, None
|
|
120
|
+
|
|
121
|
+
def _stage_resolve_imports(
|
|
122
|
+
self,
|
|
123
|
+
current_code: str,
|
|
124
|
+
filename: str,
|
|
125
|
+
resolved_imports: list[str],
|
|
126
|
+
unresolved_symbols: list[str],
|
|
127
|
+
diagnostics: list[dict[str, str]],
|
|
128
|
+
) -> str:
|
|
129
|
+
"""Execute Stage 2: Missing Import Resolution."""
|
|
130
|
+
if self.enable_import_resolution:
|
|
131
|
+
import_res = self.import_resolver.resolve(current_code, filename=filename)
|
|
132
|
+
if import_res.resolved_imports:
|
|
133
|
+
resolved_imports.extend(import_res.resolved_imports)
|
|
134
|
+
current_code = import_res.code
|
|
135
|
+
if import_res.unresolved_symbols:
|
|
136
|
+
unresolved_symbols.extend(import_res.unresolved_symbols)
|
|
137
|
+
for d in import_res.diagnostics:
|
|
138
|
+
diag_dict = d.to_dict()
|
|
139
|
+
diag_dict["file"] = filename
|
|
140
|
+
diagnostics.append(diag_dict)
|
|
141
|
+
else:
|
|
142
|
+
missing_syms = self.import_resolver.find_undefined(
|
|
143
|
+
current_code, filename=filename
|
|
144
|
+
)
|
|
145
|
+
if missing_syms:
|
|
146
|
+
unresolved_symbols.extend(missing_syms)
|
|
147
|
+
return current_code
|
|
148
|
+
|
|
149
|
+
def _stage_lint_format(
|
|
150
|
+
self, current_code: str, filename: str
|
|
151
|
+
) -> tuple[str, bool, bool]:
|
|
152
|
+
"""Execute Stage 3 & 4: Lint Auto-fixing and Formatting."""
|
|
153
|
+
if not (self.enable_lint_fixing or self.enable_formatting):
|
|
154
|
+
return current_code, False, False
|
|
155
|
+
|
|
156
|
+
lf_res = self.linter_formatter.fix_and_format(
|
|
157
|
+
current_code,
|
|
158
|
+
filename=filename,
|
|
159
|
+
do_lint_fix=self.enable_lint_fixing,
|
|
160
|
+
do_format=self.enable_formatting,
|
|
161
|
+
)
|
|
162
|
+
return lf_res.code, lf_res.lint_changed, lf_res.format_changed
|
|
163
|
+
|
|
164
|
+
@staticmethod
|
|
165
|
+
def _validate_syntax(
|
|
166
|
+
current_code: str, filename: str, error_msg: str | None
|
|
167
|
+
) -> tuple[bool, str | None]:
|
|
168
|
+
try:
|
|
169
|
+
ast.parse(current_code, filename=filename)
|
|
170
|
+
return True, error_msg
|
|
171
|
+
except SyntaxError as err:
|
|
172
|
+
msg = error_msg or f"SyntaxError at line {err.lineno}: {err.msg}"
|
|
173
|
+
return False, msg
|
|
174
|
+
|
|
175
|
+
def process_source(self, source: str, filename: str = "<stdin>") -> CleanResult:
|
|
176
|
+
"""Process in-memory Python source code through the pipeline."""
|
|
177
|
+
current_code = source
|
|
178
|
+
syntax_repairs: list[str] = []
|
|
179
|
+
resolved_imports: list[str] = []
|
|
180
|
+
unresolved_symbols: list[str] = []
|
|
181
|
+
lint_changed = False
|
|
182
|
+
format_changed = False
|
|
183
|
+
error_msg: str | None = None
|
|
184
|
+
diagnostics: list[dict[str, str]] = []
|
|
185
|
+
|
|
186
|
+
for _ in range(2):
|
|
187
|
+
prev_code = current_code
|
|
188
|
+
current_code, error_msg = self._stage_heal(
|
|
189
|
+
current_code, filename, syntax_repairs
|
|
190
|
+
)
|
|
191
|
+
if error_msg is not None:
|
|
192
|
+
break
|
|
193
|
+
current_code = self._stage_resolve_imports(
|
|
194
|
+
current_code,
|
|
195
|
+
filename,
|
|
196
|
+
resolved_imports,
|
|
197
|
+
unresolved_symbols,
|
|
198
|
+
diagnostics,
|
|
199
|
+
)
|
|
200
|
+
current_code, l_chg, f_chg = self._stage_lint_format(current_code, filename)
|
|
201
|
+
lint_changed = lint_changed or l_chg
|
|
202
|
+
format_changed = format_changed or f_chg
|
|
203
|
+
if current_code == prev_code:
|
|
204
|
+
break
|
|
205
|
+
|
|
206
|
+
is_valid, final_error = self._validate_syntax(current_code, filename, error_msg)
|
|
207
|
+
return CleanResult(
|
|
208
|
+
path=Path(filename),
|
|
209
|
+
original_code=source,
|
|
210
|
+
cleaned_code=current_code,
|
|
211
|
+
changed=current_code != source,
|
|
212
|
+
is_valid_python=is_valid,
|
|
213
|
+
syntax_repairs=syntax_repairs,
|
|
214
|
+
resolved_imports=resolved_imports,
|
|
215
|
+
unresolved_symbols=unresolved_symbols,
|
|
216
|
+
lint_changed=lint_changed,
|
|
217
|
+
format_changed=format_changed,
|
|
218
|
+
error=final_error,
|
|
219
|
+
diagnostics=diagnostics,
|
|
220
|
+
)
|
|
221
|
+
|
|
222
|
+
def process_file(
|
|
223
|
+
self,
|
|
224
|
+
filepath: Path | str,
|
|
225
|
+
apply_changes: bool = True,
|
|
226
|
+
backup: bool = False,
|
|
227
|
+
) -> CleanResult:
|
|
228
|
+
"""Process a single file on disk and optionally write back updates.
|
|
229
|
+
|
|
230
|
+
Args:
|
|
231
|
+
filepath: Path to the Python file to process.
|
|
232
|
+
apply_changes: If True, write cleaned code back to disk.
|
|
233
|
+
backup: If True, create a .pycleaner.bak file before overwriting.
|
|
234
|
+
"""
|
|
235
|
+
path = Path(filepath).resolve()
|
|
236
|
+
content = path.read_text(encoding="utf-8", errors="replace")
|
|
237
|
+
result = self.process_source(content, filename=str(path))
|
|
238
|
+
|
|
239
|
+
if apply_changes and result.changed and result.is_valid_python:
|
|
240
|
+
if backup:
|
|
241
|
+
bak_path = path.with_name(path.name + ".pycleaner.bak")
|
|
242
|
+
shutil.copy2(path, bak_path)
|
|
243
|
+
path.write_text(result.cleaned_code, encoding="utf-8")
|
|
244
|
+
|
|
245
|
+
return result
|
|
246
|
+
|
|
247
|
+
def process_files(
|
|
248
|
+
self,
|
|
249
|
+
filepaths: list[Path],
|
|
250
|
+
apply_changes: bool = True,
|
|
251
|
+
backup: bool = False,
|
|
252
|
+
max_workers: int | None = None,
|
|
253
|
+
) -> list[CleanResult]:
|
|
254
|
+
"""Process multiple files, optionally in parallel.
|
|
255
|
+
|
|
256
|
+
Args:
|
|
257
|
+
filepaths: List of Python file paths.
|
|
258
|
+
apply_changes: Write cleaned code back to disk.
|
|
259
|
+
backup: Create .pycleaner.bak before overwriting.
|
|
260
|
+
max_workers: Max parallel workers. None = sequential. 1+ = parallel.
|
|
261
|
+
"""
|
|
262
|
+
if max_workers is not None and max_workers > 1 and len(filepaths) > 1:
|
|
263
|
+
return self._process_parallel(filepaths, apply_changes, backup, max_workers)
|
|
264
|
+
return [
|
|
265
|
+
self.process_file(fp, apply_changes=apply_changes, backup=backup)
|
|
266
|
+
for fp in filepaths
|
|
267
|
+
]
|
|
268
|
+
|
|
269
|
+
@staticmethod
|
|
270
|
+
def _collect_future_result(future: Any, fpath: Path) -> CleanResult:
|
|
271
|
+
if future.cancelled():
|
|
272
|
+
return CleanResult(
|
|
273
|
+
path=fpath,
|
|
274
|
+
original_code="",
|
|
275
|
+
cleaned_code="",
|
|
276
|
+
changed=False,
|
|
277
|
+
is_valid_python=False,
|
|
278
|
+
error="Processing error: Task was cancelled",
|
|
279
|
+
)
|
|
280
|
+
exc = future.exception()
|
|
281
|
+
if exc is not None:
|
|
282
|
+
return CleanResult(
|
|
283
|
+
path=fpath,
|
|
284
|
+
original_code="",
|
|
285
|
+
cleaned_code="",
|
|
286
|
+
changed=False,
|
|
287
|
+
is_valid_python=False,
|
|
288
|
+
error=f"Processing error: {exc}",
|
|
289
|
+
)
|
|
290
|
+
return future.result()
|
|
291
|
+
|
|
292
|
+
def _process_parallel(
|
|
293
|
+
self,
|
|
294
|
+
filepaths: list[Path],
|
|
295
|
+
apply_changes: bool,
|
|
296
|
+
backup: bool,
|
|
297
|
+
max_workers: int,
|
|
298
|
+
) -> list[CleanResult]:
|
|
299
|
+
"""Process files in parallel using ProcessPoolExecutor."""
|
|
300
|
+
results: dict[Path, CleanResult] = {}
|
|
301
|
+
with ProcessPoolExecutor(max_workers=max_workers) as executor:
|
|
302
|
+
future_to_path = {
|
|
303
|
+
executor.submit(
|
|
304
|
+
_process_file_standalone,
|
|
305
|
+
_StandaloneWorkerTask(
|
|
306
|
+
filepath=fp,
|
|
307
|
+
apply_changes=apply_changes,
|
|
308
|
+
backup=backup,
|
|
309
|
+
enable_syntax=self.enable_syntax_healing,
|
|
310
|
+
enable_imports=self.enable_import_resolution,
|
|
311
|
+
enable_lint=self.enable_lint_fixing,
|
|
312
|
+
enable_format=self.enable_formatting,
|
|
313
|
+
custom_import_map=self.import_resolver.custom_import_map,
|
|
314
|
+
),
|
|
315
|
+
): fp
|
|
316
|
+
for fp in filepaths
|
|
317
|
+
}
|
|
318
|
+
for future in as_completed(future_to_path):
|
|
319
|
+
fpath = future_to_path[future]
|
|
320
|
+
results[fpath] = self._collect_future_result(future, fpath)
|
|
321
|
+
return [results[fp] for fp in filepaths]
|
|
322
|
+
|
|
323
|
+
|
|
324
|
+
@dataclass(slots=True)
|
|
325
|
+
class _StandaloneWorkerTask:
|
|
326
|
+
"""Encapsulates arguments for parallel worker tasks."""
|
|
327
|
+
|
|
328
|
+
filepath: Path
|
|
329
|
+
apply_changes: bool
|
|
330
|
+
backup: bool
|
|
331
|
+
enable_syntax: bool
|
|
332
|
+
enable_imports: bool
|
|
333
|
+
enable_lint: bool
|
|
334
|
+
enable_format: bool
|
|
335
|
+
custom_import_map: dict[str, str] | None = None
|
|
336
|
+
|
|
337
|
+
|
|
338
|
+
def _process_file_standalone(task: _StandaloneWorkerTask) -> CleanResult:
|
|
339
|
+
"""Standalone function for ProcessPoolExecutor (must be module-level and picklable)."""
|
|
340
|
+
pipeline = CleanPipeline(
|
|
341
|
+
enable_syntax_healing=task.enable_syntax,
|
|
342
|
+
enable_import_resolution=task.enable_imports,
|
|
343
|
+
enable_lint_fixing=task.enable_lint,
|
|
344
|
+
enable_formatting=task.enable_format,
|
|
345
|
+
custom_import_map=task.custom_import_map,
|
|
346
|
+
)
|
|
347
|
+
return pipeline.process_file(
|
|
348
|
+
task.filepath, apply_changes=task.apply_changes, backup=task.backup
|
|
349
|
+
)
|