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,577 @@
1
+ """
2
+ Syntax healing engine for statically repairing common Python syntax errors.
3
+
4
+ Handles missing colons, single '=' comparison errors, legacy Python 2 syntax,
5
+ unbalanced parentheses/brackets/braces, and tab/indentation normalization.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import ast
11
+ import io
12
+ import re
13
+ import tokenize
14
+ from collections.abc import Sequence
15
+ from dataclasses import dataclass, field
16
+ from typing import Any
17
+
18
+
19
+ @dataclass(slots=True)
20
+ class SyntaxHealResult:
21
+ """Result of attempting to heal syntax errors in source code."""
22
+
23
+ code: str
24
+ is_valid: bool
25
+ repairs: list[str] = field(default_factory=list)
26
+ error_message: str | None = None
27
+ error_lineno: int | None = None
28
+ error_offset: int | None = None
29
+
30
+
31
+ class SyntaxHealer:
32
+ """Repairs common static syntax errors and validates compilation."""
33
+
34
+ # Headers that must end with a colon
35
+ _COMPOUND_HEADER_PATTERN = re.compile(
36
+ r"^(?P<indent>[ \t]*)"
37
+ r"(?P<header>"
38
+ r"(?:async\s+)?(?:def|class|for|while|with)\b.*"
39
+ r"|if\b.*"
40
+ r"|elif\b.*"
41
+ r"|else\b\s*"
42
+ r"|try\b\s*"
43
+ r"|except\b.*"
44
+ r"|finally\b\s*"
45
+ r"|match\b.*"
46
+ r"|case\b.*"
47
+ r")"
48
+ r"(?P<trailing_comment>\s*#.*)?$",
49
+ re.MULTILINE,
50
+ )
51
+
52
+ # Compound-statement keywords whose header must end in ':'.
53
+ _COMPOUND_KEYWORDS = frozenset(
54
+ {
55
+ "def",
56
+ "class",
57
+ "if",
58
+ "elif",
59
+ "else",
60
+ "for",
61
+ "while",
62
+ "try",
63
+ "except",
64
+ "finally",
65
+ "with",
66
+ "match",
67
+ "case",
68
+ }
69
+ )
70
+
71
+ _NON_HEADER_FOLLOWERS = frozenset(
72
+ {
73
+ "=",
74
+ "+=",
75
+ "-=",
76
+ "*=",
77
+ "/=",
78
+ "//=",
79
+ "%=",
80
+ "**=",
81
+ "&=",
82
+ "|=",
83
+ "^=",
84
+ ">>=",
85
+ "<<=",
86
+ ":=",
87
+ ".",
88
+ ",",
89
+ }
90
+ )
91
+
92
+ # Assignment `=` used instead of `==` inside if/elif/while
93
+ _ASSIGNMENT_IN_CONDITIONAL = re.compile(
94
+ r"^([ \t]*(?:if|elif|while)\s+)(.+?)(:?\s*(?:#.*)?)$",
95
+ re.MULTILINE,
96
+ )
97
+
98
+ # Python 2 print statement without parentheses: print "hello", "world"
99
+ _PY2_PRINT_PATTERN = re.compile(
100
+ r"^([ \t]*)print\s+([\"'a-zA-Z0-9_\(\[\{].*?)$",
101
+ re.MULTILINE,
102
+ )
103
+
104
+ # Python 2 except statement: except Exception, e:
105
+ _PY2_EXCEPT_PATTERN = re.compile(
106
+ r"^([ \t]*except\s+[\w\.\(\)\s]+),\s*([a-zA-Z_]\w*)\s*:(.*)$",
107
+ re.MULTILINE,
108
+ )
109
+
110
+ def __init__(
111
+ self,
112
+ fix_py2_syntax: bool = True,
113
+ fix_conditional_assignments: bool = True,
114
+ ) -> None:
115
+ """
116
+ Args:
117
+ fix_py2_syntax: Enable Python-2-era syntax repairs.
118
+ fix_conditional_assignments: Enable rewriting an accidental '=' to '==' in conditions.
119
+ """
120
+ self.fix_py2_syntax = fix_py2_syntax
121
+ self.fix_conditional_assignments = fix_conditional_assignments
122
+
123
+ def _heal_valid_code(self, source: str, filename: str) -> tuple[str, list[str]]:
124
+ cleaned = source
125
+ repairs: list[str] = []
126
+ if "\t" in source:
127
+ tab_cleaned = source.expandtabs(4)
128
+ if self._check_syntax(tab_cleaned, filename) is None:
129
+ cleaned = tab_cleaned
130
+ repairs.append("Normalized tab characters to 4 spaces")
131
+
132
+ if self.fix_py2_syntax:
133
+ py2_fixed, py2_count = self._fix_py2_except_ast(cleaned, filename)
134
+ if py2_count > 0:
135
+ cleaned = py2_fixed
136
+ repairs.append(
137
+ f"Converted {py2_count} legacy Python 2 except clause(s) to 'as'"
138
+ )
139
+ return cleaned, repairs
140
+
141
+ def _execute_healing_steps(
142
+ self, source: str, filename: str
143
+ ) -> tuple[str, list[str]]:
144
+ code = source
145
+ repairs: list[str] = []
146
+
147
+ def apply_step(fn: Any, msg_fmt: str) -> bool:
148
+ nonlocal code
149
+ new_code, count = fn(code)
150
+ if count > 0:
151
+ code = new_code
152
+ repairs.append(msg_fmt.format(count=count))
153
+ return self._check_syntax(code, filename) is None
154
+ return False
155
+
156
+ if "\t" in code:
157
+ code = code.expandtabs(4)
158
+ repairs.append("Normalized tab characters to 4 spaces")
159
+ if self._check_syntax(code, filename) is None:
160
+ return code, repairs
161
+
162
+ if self.fix_py2_syntax:
163
+ if apply_step(
164
+ self._fix_py2_except,
165
+ "Converted {count} legacy Python 2 except clause(s) to 'as'",
166
+ ):
167
+ return code, repairs
168
+ if apply_step(
169
+ self._fix_py2_print,
170
+ "Converted {count} legacy print statement(s) to print() calls",
171
+ ):
172
+ return code, repairs
173
+
174
+ if self.fix_conditional_assignments and apply_step(
175
+ self._fix_conditional_assignments,
176
+ "Replaced {count} accidental '=' assignment(s) with '==' in conditionals",
177
+ ):
178
+ return code, repairs
179
+
180
+ if apply_step(
181
+ self._fix_missing_colons,
182
+ "Appended missing ':' to {count} compound statement header(s)",
183
+ ):
184
+ return code, repairs
185
+
186
+ code, d_repairs = self._fix_unbalanced_delimiters(code, filename=filename)
187
+ repairs.extend(d_repairs)
188
+ return code, repairs
189
+
190
+ def heal(self, source: str, filename: str = "<unknown>") -> SyntaxHealResult:
191
+ """Attempt to statically repair syntax errors in the given Python source."""
192
+ if self._check_syntax(source, filename) is None:
193
+ cleaned, repairs = self._heal_valid_code(source, filename)
194
+ return SyntaxHealResult(code=cleaned, is_valid=True, repairs=repairs)
195
+
196
+ repaired, repairs = self._execute_healing_steps(source, filename)
197
+ final_error = self._check_syntax(repaired, filename)
198
+ if final_error is None:
199
+ return SyntaxHealResult(code=repaired, is_valid=True, repairs=repairs)
200
+
201
+ err_msg, lineno, offset = final_error
202
+ return SyntaxHealResult(
203
+ code=source,
204
+ is_valid=False,
205
+ repairs=[],
206
+ error_message=err_msg,
207
+ error_lineno=lineno,
208
+ error_offset=offset,
209
+ )
210
+
211
+ def _check_syntax(self, code: str, filename: str) -> tuple[str, int, int] | None:
212
+ """Parse source code into AST. Returns None on success, or (msg, lineno, offset) on error."""
213
+ try:
214
+ ast.parse(code, filename=filename)
215
+ return None
216
+ except SyntaxError as err:
217
+ return (
218
+ err.msg or "SyntaxError",
219
+ err.lineno or 1,
220
+ err.offset or 1,
221
+ )
222
+
223
+ def _inspect_except_handler(
224
+ self, node: ast.ExceptHandler
225
+ ) -> tuple[str, str, int] | None:
226
+ if node.name is not None or not isinstance(node.type, ast.Tuple):
227
+ return None
228
+ elts = node.type.elts
229
+ if len(elts) < 2 or not isinstance(elts[-1], ast.Name):
230
+ return None
231
+
232
+ exc_strs: list[str] = []
233
+ for e in elts[:-1]:
234
+ s = self._ast_name_to_str(e)
235
+ if s == "<unknown>":
236
+ return None
237
+ exc_strs.append(s)
238
+
239
+ if not exc_strs:
240
+ return None
241
+
242
+ exc_str = exc_strs[0] if len(exc_strs) == 1 else f"({', '.join(exc_strs)})"
243
+ return exc_str, elts[-1].id, node.lineno
244
+
245
+ def _format_py2_except_line(
246
+ self, line: str, exc_str: str, var_name: str
247
+ ) -> str | None:
248
+ indent_match = re.match(r"^([ \t]*)", line)
249
+ indent = indent_match.group(1) if indent_match else ""
250
+ pattern = re.compile(r"^([ \t]*except\s+)(.+?)\s*:(.*?)(\s*#.*)?$")
251
+ m = pattern.match(line.rstrip("\r\n"))
252
+ if not m:
253
+ return None
254
+
255
+ trailing = m.group(3).strip()
256
+ comment = m.group(4) or ""
257
+ ending = (
258
+ "\r\n" if line.endswith("\r\n") else ("\n" if line.endswith("\n") else "")
259
+ )
260
+ if trailing:
261
+ return (
262
+ f"{indent}except {exc_str} as {var_name}: {trailing}{comment}{ending}"
263
+ )
264
+ return f"{indent}except {exc_str} as {var_name}:{comment}{ending}"
265
+
266
+ def _collect_py2_except_fixes(
267
+ self, tree: ast.Module, lines: Sequence[str]
268
+ ) -> list[tuple[int, str]]:
269
+ fixes: list[tuple[int, str]] = []
270
+ for node in ast.walk(tree):
271
+ if isinstance(node, ast.ExceptHandler):
272
+ info = self._inspect_except_handler(node)
273
+ if info and 1 <= info[2] <= len(lines):
274
+ exc_str, var_name, lineno = info
275
+ new_line = self._format_py2_except_line(
276
+ lines[lineno - 1], exc_str, var_name
277
+ )
278
+ if new_line:
279
+ fixes.append((lineno, new_line))
280
+ return fixes
281
+
282
+ def _fix_py2_except_ast(
283
+ self, code: str, filename: str = "<unknown>"
284
+ ) -> tuple[str, int]:
285
+ """Detect Python 2 'except X, e:' patterns via AST on syntactically valid code."""
286
+ try:
287
+ tree = ast.parse(code, filename=filename)
288
+ except SyntaxError:
289
+ return code, 0
290
+
291
+ lines = code.splitlines(keepends=True)
292
+ fixes = self._collect_py2_except_fixes(tree, lines)
293
+ if not fixes:
294
+ return code, 0
295
+
296
+ fixes.sort(key=lambda f: f[0], reverse=True)
297
+ for lineno, new_line in fixes:
298
+ lines[lineno - 1] = new_line
299
+
300
+ candidate = "".join(lines)
301
+ if self._check_syntax(candidate, filename) is None:
302
+ return candidate, len(fixes)
303
+
304
+ return code, 0
305
+
306
+ @staticmethod
307
+ def _ast_name_to_str(node: ast.AST) -> str:
308
+ """Convert an ast.Name, ast.Attribute, or ast.Tuple node to a string representation."""
309
+ if isinstance(node, ast.Name):
310
+ return node.id
311
+ if isinstance(node, ast.Attribute):
312
+ value_str = SyntaxHealer._ast_name_to_str(node.value)
313
+ return f"{value_str}.{node.attr}"
314
+ if isinstance(node, ast.Tuple):
315
+ parts = [SyntaxHealer._ast_name_to_str(sub) for sub in node.elts]
316
+ if any(p == "<unknown>" for p in parts):
317
+ return "<unknown>"
318
+ return f"({', '.join(parts)})"
319
+ return "<unknown>"
320
+
321
+ @staticmethod
322
+ def _is_async_prefix(tokens: Sequence[tokenize.TokenInfo]) -> bool:
323
+ if len(tokens) < 2:
324
+ return False
325
+ first = tokens[0]
326
+ return first.type == tokenize.NAME and first.string == "async"
327
+
328
+ def _is_valid_header_token(
329
+ self, tokens: Sequence[tokenize.TokenInfo], idx: int
330
+ ) -> bool:
331
+ if idx >= len(tokens):
332
+ return False
333
+ tok = tokens[idx]
334
+ if tok.type != tokenize.NAME or tok.string not in self._COMPOUND_KEYWORDS:
335
+ return False
336
+ if tok.string in ("match", "case") and idx + 1 < len(tokens):
337
+ nxt = tokens[idx + 1]
338
+ return (
339
+ nxt.type != tokenize.OP or nxt.string not in self._NON_HEADER_FOLLOWERS
340
+ )
341
+ return True
342
+
343
+ def _get_compound_header_idx(
344
+ self, tokens: Sequence[tokenize.TokenInfo]
345
+ ) -> int | None:
346
+ idx = 1 if self._is_async_prefix(tokens) else 0
347
+ return idx if self._is_valid_header_token(tokens, idx) else None
348
+
349
+ @staticmethod
350
+ def _has_colon_at_depth_zero(
351
+ tokens: Sequence[tokenize.TokenInfo], start_idx: int
352
+ ) -> bool:
353
+ depth = 0
354
+ for tok in tokens[start_idx:]:
355
+ if tok.type == tokenize.OP:
356
+ if tok.string in ("(", "[", "{"):
357
+ depth += 1
358
+ elif tok.string in (")", "]", "}"):
359
+ depth = max(0, depth - 1)
360
+ elif tok.string == ":" and depth == 0:
361
+ return True
362
+ return False
363
+
364
+ def _find_missing_colon_insertions(
365
+ self, tokens: Sequence[tokenize.TokenInfo]
366
+ ) -> list[tuple[int, int]]:
367
+ insertions: list[tuple[int, int]] = []
368
+ logical_tokens: list[tokenize.TokenInfo] = []
369
+
370
+ def flush() -> None:
371
+ header_idx = self._get_compound_header_idx(logical_tokens)
372
+ if header_idx is None:
373
+ return
374
+ if self._has_colon_at_depth_zero(logical_tokens, header_idx + 1):
375
+ return
376
+ last = logical_tokens[-1]
377
+ insertions.append((last.end[0] - 1, last.end[1]))
378
+
379
+ skip_types = {
380
+ tokenize.NL,
381
+ tokenize.COMMENT,
382
+ tokenize.INDENT,
383
+ tokenize.DEDENT,
384
+ tokenize.ENCODING,
385
+ tokenize.ENDMARKER,
386
+ }
387
+ for tok in tokens:
388
+ if tok.type in skip_types:
389
+ continue
390
+ if tok.type == tokenize.NEWLINE:
391
+ flush()
392
+ logical_tokens = []
393
+ else:
394
+ logical_tokens.append(tok)
395
+ flush()
396
+ return insertions
397
+
398
+ def _fix_missing_colons(self, code: str) -> tuple[str, int]:
399
+ """Detect compound statement headers without trailing colons and append them."""
400
+ try:
401
+ tokens = list(tokenize.generate_tokens(io.StringIO(code).readline))
402
+ except tokenize.TokenError:
403
+ return code, 0
404
+
405
+ insertions = self._find_missing_colon_insertions(tokens)
406
+ if not insertions:
407
+ return code, 0
408
+
409
+ lines = code.splitlines(keepends=True)
410
+ insertions.sort(key=lambda pos: (pos[0], pos[1]), reverse=True)
411
+ for line_idx, col in insertions:
412
+ if 0 <= line_idx < len(lines):
413
+ target = lines[line_idx]
414
+ lines[line_idx] = target[:col] + ":" + target[col:]
415
+
416
+ return "".join(lines), len(insertions)
417
+
418
+ @staticmethod
419
+ def _is_standalone_assignment(chars: Sequence[str], i: int, depth: int) -> bool:
420
+ if chars[i] != "=" or depth != 0:
421
+ return False
422
+ prev_c = chars[i - 1] if i > 0 else ""
423
+ next_c = chars[i + 1] if i + 1 < len(chars) else ""
424
+ if prev_c in ("=", "!", "<", ">", ":") or next_c == "=":
425
+ return False
426
+ return prev_c not in ("+", "-", "*", "/", "%", "&", "|", "^")
427
+
428
+ @staticmethod
429
+ def _update_quote_flag(c: str, in_s: bool, in_d: bool) -> tuple[bool, bool]:
430
+ if c == "'" and not in_d:
431
+ return not in_s, in_d
432
+ if c == '"' and not in_s:
433
+ return in_s, not in_d
434
+ return in_s, in_d
435
+
436
+ @classmethod
437
+ def _replace_condition_equals(cls, condition: str) -> tuple[str, int]:
438
+ chars = list(condition)
439
+ depth = 0
440
+ in_s = False
441
+ in_d = False
442
+ escaped = False
443
+ count = 0
444
+ i = 0
445
+ while i < len(chars):
446
+ c = chars[i]
447
+ if escaped:
448
+ escaped = False
449
+ elif c == "\\":
450
+ escaped = True
451
+ elif in_s or in_d or c in ("'", '"'):
452
+ in_s, in_d = cls._update_quote_flag(c, in_s, in_d)
453
+ elif c in "([{":
454
+ depth += 1
455
+ elif c in ")]}":
456
+ depth = max(0, depth - 1)
457
+ elif cls._is_standalone_assignment(chars, i, depth):
458
+ chars[i] = "=="
459
+ count += 1
460
+ i += 1
461
+ return "".join(chars), count
462
+
463
+ def _fix_conditional_assignments(self, code: str) -> tuple[str, int]:
464
+ """Replace single '=' with '==' in condition statements."""
465
+ count = 0
466
+
467
+ def replacer(match: re.Match[str]) -> str:
468
+ nonlocal count
469
+ prefix, cond, suffix = match.group(1), match.group(2), match.group(3)
470
+ repaired_cond, local_count = self._replace_condition_equals(cond)
471
+ if local_count == 0:
472
+ return match.group(0)
473
+
474
+ test_code = f"{prefix}{repaired_cond}{suffix}".strip()
475
+ if not test_code.endswith(":"):
476
+ test_code += ":"
477
+ test_code += "\n pass\n"
478
+ try:
479
+ ast.parse(test_code)
480
+ count += local_count
481
+ return f"{prefix}{repaired_cond}{suffix}"
482
+ except SyntaxError:
483
+ return match.group(0)
484
+
485
+ new_code = self._ASSIGNMENT_IN_CONDITIONAL.sub(replacer, code)
486
+ return new_code, count
487
+
488
+ def _fix_py2_print(self, code: str) -> tuple[str, int]:
489
+ """Convert Python 2 print statements to Python 3 function calls."""
490
+ count = 0
491
+
492
+ def replacer(match: re.Match[str]) -> str:
493
+ nonlocal count
494
+ indent = match.group(1)
495
+ content = match.group(2).strip()
496
+
497
+ # Skip if already parenthesized like print(...)
498
+ if content.startswith("(") and content.endswith(")"):
499
+ return match.group(0)
500
+
501
+ # Skip print >> stream redirection or complex prints for safety
502
+ if content.startswith(">>"):
503
+ return match.group(0)
504
+
505
+ count += 1
506
+ return f"{indent}print({content})"
507
+
508
+ new_code = self._PY2_PRINT_PATTERN.sub(replacer, code)
509
+ return new_code, count
510
+
511
+ def _fix_py2_except(self, code: str) -> tuple[str, int]:
512
+ """Convert 'except Error, e:' to 'except Error as e:'."""
513
+ count = 0
514
+
515
+ def replacer(match: re.Match[str]) -> str:
516
+ nonlocal count
517
+ prefix = match.group(1)
518
+ var_name = match.group(2)
519
+ trailing = match.group(3)
520
+ count += 1
521
+ return f"{prefix} as {var_name}:{trailing}"
522
+
523
+ new_code = self._PY2_EXCEPT_PATTERN.sub(replacer, code)
524
+ return new_code, count
525
+
526
+ @staticmethod
527
+ def _scan_unclosed_delimiters(code: str) -> list[str]:
528
+ stack: list[str] = []
529
+ pairs = {"(": ")", "[": "]", "{": "}"}
530
+ closing = {")": "(", "]": "[", "}": "{"}
531
+ try:
532
+ for tok in tokenize.generate_tokens(io.StringIO(code).readline):
533
+ if tok.type == tokenize.OP:
534
+ if tok.string in pairs:
535
+ stack.append(pairs[tok.string])
536
+ elif tok.string in closing and stack and stack[-1] == tok.string:
537
+ stack.pop()
538
+ except tokenize.TokenError:
539
+ # Incomplete token stream will be healed by delimiter reconstruction
540
+ pass
541
+ return stack
542
+
543
+ @staticmethod
544
+ def _append_delimiters_to_last_line(code: str, closing_str: str) -> str:
545
+ lines = code.splitlines(keepends=True)
546
+ if not lines:
547
+ return code.rstrip() + closing_str + "\n"
548
+
549
+ last_line = lines[-1]
550
+ comment_idx = last_line.find("#")
551
+ if comment_idx != -1:
552
+ pre = last_line[:comment_idx].rstrip()
553
+ post = last_line[comment_idx:]
554
+ lines[-1] = f"{pre}{closing_str} {post}"
555
+ else:
556
+ ending = (
557
+ "\r\n"
558
+ if last_line.endswith("\r\n")
559
+ else ("\n" if last_line.endswith("\n") else "")
560
+ )
561
+ lines[-1] = f"{last_line.rstrip()}{closing_str}{ending}"
562
+ return "".join(lines)
563
+
564
+ def _fix_unbalanced_delimiters(
565
+ self, code: str, filename: str = "<unknown>"
566
+ ) -> tuple[str, list[str]]:
567
+ """Identify unclosed delimiters across lines and append closing delimiters if it restores valid syntax."""
568
+ stack = self._scan_unclosed_delimiters(code)
569
+ if not stack:
570
+ return code, []
571
+
572
+ closing_str = "".join(reversed(stack))
573
+ candidate = self._append_delimiters_to_last_line(code, closing_str)
574
+ if self._check_syntax(candidate, filename) is None:
575
+ return candidate, [f"Closed unclosed delimiter(s): {closing_str}"]
576
+
577
+ return code, []