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.
@@ -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, []