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.
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
+ )