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