pycli-dsl 0.1.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.
- pycli/__init__.py +340 -0
- pycli/highlighter.py +122 -0
- pycli/importer.py +214 -0
- pycli/lexer.py +321 -0
- pycli/parser.py +499 -0
- pycli/repl.py +75 -0
- pycli/runtime.py +1647 -0
- pycli/transformer.py +812 -0
- pycli_dsl-0.1.0.dist-info/METADATA +601 -0
- pycli_dsl-0.1.0.dist-info/RECORD +12 -0
- pycli_dsl-0.1.0.dist-info/WHEEL +4 -0
- pycli_dsl-0.1.0.dist-info/entry_points.txt +4 -0
pycli/transformer.py
ADDED
|
@@ -0,0 +1,812 @@
|
|
|
1
|
+
"""Transformer that converts pycli (.spy) source code into standard Python (.py)."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import ast
|
|
6
|
+
import re
|
|
7
|
+
import sys
|
|
8
|
+
from typing import Set
|
|
9
|
+
|
|
10
|
+
from pycli.lexer import Lexer, Token, TokenType
|
|
11
|
+
from pycli.parser import (
|
|
12
|
+
CommandExpressionNode,
|
|
13
|
+
CommandParser,
|
|
14
|
+
InterpolationNode,
|
|
15
|
+
RedirectionNode,
|
|
16
|
+
SplatNode,
|
|
17
|
+
StringNode,
|
|
18
|
+
SubcommandNode,
|
|
19
|
+
WordNode,
|
|
20
|
+
is_valid_interpolation_expr,
|
|
21
|
+
scan_balanced,
|
|
22
|
+
)
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
class TranspilerError(Exception):
|
|
26
|
+
"""Raised when transpilation produces invalid Python code or unsupportable constructs."""
|
|
27
|
+
|
|
28
|
+
def __init__(self, message: str, line: int | None = None, column: int | None = None) -> None:
|
|
29
|
+
super().__init__(message)
|
|
30
|
+
self.message = message
|
|
31
|
+
self.line = line
|
|
32
|
+
self.column = column
|
|
33
|
+
|
|
34
|
+
def __str__(self) -> str:
|
|
35
|
+
if self.line is not None:
|
|
36
|
+
col_part = f", column {self.column}" if self.column is not None else ""
|
|
37
|
+
return f"Line {self.line}{col_part}: {self.message}"
|
|
38
|
+
return self.message
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
class Transformer:
|
|
42
|
+
"""Transforms .spy source code containing $(...) syntax into pure Python source code."""
|
|
43
|
+
|
|
44
|
+
def __init__(
|
|
45
|
+
self,
|
|
46
|
+
auto_import: bool = True,
|
|
47
|
+
unsafe_interpolation: bool = False,
|
|
48
|
+
target_platform: str | None = None,
|
|
49
|
+
) -> None:
|
|
50
|
+
self.auto_import = auto_import
|
|
51
|
+
self.unsafe_interpolation = unsafe_interpolation
|
|
52
|
+
self.target_platform = target_platform or sys.platform
|
|
53
|
+
self.used_symbols: Set[str] = set()
|
|
54
|
+
self.source_map: dict[int, int] = {}
|
|
55
|
+
|
|
56
|
+
def transform(self, source: str, validate: bool = False) -> str:
|
|
57
|
+
self.used_symbols.clear()
|
|
58
|
+
self.source_map.clear()
|
|
59
|
+
|
|
60
|
+
lexer = Lexer(source)
|
|
61
|
+
tokens = lexer.tokenize()
|
|
62
|
+
|
|
63
|
+
output_chunks: list[str] = []
|
|
64
|
+
token_values: dict[int, str] = {}
|
|
65
|
+
recorded_chunks: list[tuple[str, int, bool]] = []
|
|
66
|
+
|
|
67
|
+
for i, token in enumerate(tokens):
|
|
68
|
+
if token.type == TokenType.PYTHON_CODE:
|
|
69
|
+
val = token_values.get(i, token.value)
|
|
70
|
+
output_chunks.append(val)
|
|
71
|
+
recorded_chunks.append((val, token.line, False))
|
|
72
|
+
elif token.type == TokenType.COMMAND_EXPR:
|
|
73
|
+
# Check if preceded by 'await '
|
|
74
|
+
prev_text = output_chunks[-1] if output_chunks else ""
|
|
75
|
+
is_await = bool(re.search(r"\bawait\s+$", prev_text))
|
|
76
|
+
|
|
77
|
+
# Check if followed by chaining (.tee, .input(...)) without mutating tokens
|
|
78
|
+
is_tee = False
|
|
79
|
+
input_expr = None
|
|
80
|
+
if i + 1 < len(tokens) and tokens[i + 1].type == TokenType.PYTHON_CODE:
|
|
81
|
+
val = token_values.get(i + 1, tokens[i + 1].value)
|
|
82
|
+
while True:
|
|
83
|
+
if val.startswith(".tee") and (len(val) == 4 or not (val[4].isalnum() or val[4] == "_")):
|
|
84
|
+
is_tee = True
|
|
85
|
+
val = val[4:]
|
|
86
|
+
continue
|
|
87
|
+
m_input = re.match(r"^\.input\s*\(", val)
|
|
88
|
+
if m_input:
|
|
89
|
+
p_start = m_input.end()
|
|
90
|
+
try:
|
|
91
|
+
end_paren = scan_balanced(val, p_start, "(", ")")
|
|
92
|
+
input_expr = val[p_start : end_paren - 1].strip()
|
|
93
|
+
val = val[end_paren:]
|
|
94
|
+
continue
|
|
95
|
+
except Exception:
|
|
96
|
+
pass
|
|
97
|
+
break
|
|
98
|
+
token_values[i + 1] = val
|
|
99
|
+
|
|
100
|
+
is_statement = (
|
|
101
|
+
self._is_statement_context(tokens, i, token_values)
|
|
102
|
+
and not is_tee
|
|
103
|
+
and not input_expr
|
|
104
|
+
)
|
|
105
|
+
parser = CommandParser(
|
|
106
|
+
token.value,
|
|
107
|
+
strict=token.strict,
|
|
108
|
+
safe=token.safe,
|
|
109
|
+
background=token.background,
|
|
110
|
+
base_line=token.line,
|
|
111
|
+
base_column=token.column + 2,
|
|
112
|
+
)
|
|
113
|
+
ast_node = parser.parse()
|
|
114
|
+
py_code = self._transform_command_expr(
|
|
115
|
+
ast_node,
|
|
116
|
+
is_statement=is_statement,
|
|
117
|
+
is_async=is_await,
|
|
118
|
+
is_tee=is_tee,
|
|
119
|
+
input_expr=input_expr,
|
|
120
|
+
)
|
|
121
|
+
output_chunks.append(py_code)
|
|
122
|
+
recorded_chunks.append((py_code, token.line, True))
|
|
123
|
+
|
|
124
|
+
# Build initial source map from character-level mapping to exact splitlines
|
|
125
|
+
char_spy_lines: list[int] = []
|
|
126
|
+
for chunk, orig_start_line, is_cmd in recorded_chunks:
|
|
127
|
+
if is_cmd:
|
|
128
|
+
char_spy_lines.extend([orig_start_line] * len(chunk))
|
|
129
|
+
else:
|
|
130
|
+
cur_spy_line = orig_start_line
|
|
131
|
+
for ch in chunk:
|
|
132
|
+
char_spy_lines.append(cur_spy_line)
|
|
133
|
+
if ch == "\n":
|
|
134
|
+
cur_spy_line += 1
|
|
135
|
+
|
|
136
|
+
result_code = "".join(output_chunks)
|
|
137
|
+
|
|
138
|
+
char_idx = 0
|
|
139
|
+
for py_ln, line in enumerate(result_code.splitlines(keepends=True), start=1):
|
|
140
|
+
if char_idx < len(char_spy_lines):
|
|
141
|
+
self.source_map[py_ln] = char_spy_lines[char_idx]
|
|
142
|
+
else:
|
|
143
|
+
self.source_map[py_ln] = 1
|
|
144
|
+
char_idx += len(line)
|
|
145
|
+
|
|
146
|
+
if not self.source_map:
|
|
147
|
+
self.source_map[1] = 1
|
|
148
|
+
|
|
149
|
+
if self.auto_import and self.used_symbols:
|
|
150
|
+
orig_line_count = len(result_code.splitlines(keepends=True))
|
|
151
|
+
result_code, lines_added, insert_idx = self._inject_imports(result_code, self.used_symbols)
|
|
152
|
+
if lines_added > 0:
|
|
153
|
+
shifted_map: dict[int, int] = {}
|
|
154
|
+
for py_ln, spy_ln in self.source_map.items():
|
|
155
|
+
if py_ln <= insert_idx:
|
|
156
|
+
shifted_map[py_ln] = spy_ln
|
|
157
|
+
else:
|
|
158
|
+
shifted_map[py_ln + lines_added] = spy_ln
|
|
159
|
+
for offset in range(lines_added):
|
|
160
|
+
shifted_map[insert_idx + 1 + offset] = 1
|
|
161
|
+
self.source_map = shifted_map
|
|
162
|
+
|
|
163
|
+
if validate:
|
|
164
|
+
try:
|
|
165
|
+
ast.parse(result_code)
|
|
166
|
+
except SyntaxError as e:
|
|
167
|
+
raise TranspilerError(f"Transpiler produced invalid Python: {e}") from e
|
|
168
|
+
|
|
169
|
+
return result_code
|
|
170
|
+
|
|
171
|
+
def _is_statement_context(
|
|
172
|
+
self, tokens: list[Token], index: int, token_values: dict[int, str]
|
|
173
|
+
) -> bool:
|
|
174
|
+
"""Determines if the command expression at tokens[index] is a standalone statement."""
|
|
175
|
+
# 1. Inspect code preceding the command on the current line
|
|
176
|
+
prev_code = token_values.get(index - 1, tokens[index - 1].value) if index > 0 else ""
|
|
177
|
+
last_nl = prev_code.rfind("\n")
|
|
178
|
+
line_before = prev_code[last_nl + 1 :] if last_nl != -1 else prev_code
|
|
179
|
+
|
|
180
|
+
# Check for unclosed brackets/parentheses across all preceding code
|
|
181
|
+
bracket_stack: list[str] = []
|
|
182
|
+
in_quote: str | None = None
|
|
183
|
+
idx_p = 0
|
|
184
|
+
n_prev = len(prev_code)
|
|
185
|
+
while idx_p < n_prev:
|
|
186
|
+
ch = prev_code[idx_p]
|
|
187
|
+
if in_quote:
|
|
188
|
+
if ch == "\\":
|
|
189
|
+
idx_p += 2
|
|
190
|
+
continue
|
|
191
|
+
if prev_code[idx_p : idx_p + len(in_quote)] == in_quote:
|
|
192
|
+
idx_p += len(in_quote)
|
|
193
|
+
in_quote = None
|
|
194
|
+
continue
|
|
195
|
+
else:
|
|
196
|
+
if ch == "#":
|
|
197
|
+
while idx_p < n_prev and prev_code[idx_p] != "\n":
|
|
198
|
+
idx_p += 1
|
|
199
|
+
continue
|
|
200
|
+
if ch in ("'", '"'):
|
|
201
|
+
is_triple = prev_code[idx_p : idx_p + 3] == ch * 3
|
|
202
|
+
in_quote = ch * 3 if is_triple else ch
|
|
203
|
+
idx_p += len(in_quote)
|
|
204
|
+
continue
|
|
205
|
+
if ch in "([{":
|
|
206
|
+
bracket_stack.append(ch)
|
|
207
|
+
elif ch in ")]}":
|
|
208
|
+
if bracket_stack:
|
|
209
|
+
bracket_stack.pop()
|
|
210
|
+
idx_p += 1
|
|
211
|
+
|
|
212
|
+
if bracket_stack:
|
|
213
|
+
return False
|
|
214
|
+
|
|
215
|
+
stripped_before = line_before.strip()
|
|
216
|
+
|
|
217
|
+
# Expression patterns preceding the command
|
|
218
|
+
expression_before_patterns = [
|
|
219
|
+
r"\blambda\b[^:]*:\s*$",
|
|
220
|
+
r"\bif\b.*\belse\b\s*$",
|
|
221
|
+
r"\belse\s*$",
|
|
222
|
+
r"\[.*for\b",
|
|
223
|
+
r"\bfor\b.*\bin\b\s*$",
|
|
224
|
+
r"\breturn\b\s*$",
|
|
225
|
+
r"\byield\b\s*$",
|
|
226
|
+
r"\bassert\b\s*$",
|
|
227
|
+
]
|
|
228
|
+
for pat in expression_before_patterns:
|
|
229
|
+
if re.search(pat, stripped_before):
|
|
230
|
+
return False
|
|
231
|
+
|
|
232
|
+
# Statement must start after indentation, after a semicolon, after 'await', or after a block colon (e.g. if cond: $(cmd))
|
|
233
|
+
is_valid_before = (
|
|
234
|
+
stripped_before in ("", "await")
|
|
235
|
+
or stripped_before.endswith(";")
|
|
236
|
+
or stripped_before.endswith("; await")
|
|
237
|
+
or stripped_before.endswith(": await")
|
|
238
|
+
or (
|
|
239
|
+
stripped_before.endswith(":")
|
|
240
|
+
and any(
|
|
241
|
+
stripped_before.startswith(kw)
|
|
242
|
+
for kw in (
|
|
243
|
+
"if ",
|
|
244
|
+
"elif ",
|
|
245
|
+
"else:",
|
|
246
|
+
"try:",
|
|
247
|
+
"finally:",
|
|
248
|
+
"except",
|
|
249
|
+
"for ",
|
|
250
|
+
"while ",
|
|
251
|
+
"with ",
|
|
252
|
+
"def ",
|
|
253
|
+
)
|
|
254
|
+
)
|
|
255
|
+
)
|
|
256
|
+
)
|
|
257
|
+
if not is_valid_before:
|
|
258
|
+
return False
|
|
259
|
+
|
|
260
|
+
# 2. Inspect code following the command on the current line
|
|
261
|
+
next_code = token_values.get(index + 1, tokens[index + 1].value) if index + 1 < len(tokens) else ""
|
|
262
|
+
first_nl = next_code.find("\n")
|
|
263
|
+
line_after = next_code[:first_nl] if first_nl != -1 else next_code
|
|
264
|
+
stripped_after = line_after.strip()
|
|
265
|
+
|
|
266
|
+
# Expression patterns following the command
|
|
267
|
+
expression_after_patterns = [
|
|
268
|
+
r"^\s*\bif\b",
|
|
269
|
+
r"^\s*\bfor\b",
|
|
270
|
+
r"^\s*(\+|-|\*|/|%|==|!=|<=|>=|<|>|\band\b|\bor\b|\bin\b|\bis\b)",
|
|
271
|
+
r"^\s*(\]|\)|\})",
|
|
272
|
+
]
|
|
273
|
+
for pat in expression_after_patterns:
|
|
274
|
+
if re.search(pat, stripped_after):
|
|
275
|
+
return False
|
|
276
|
+
|
|
277
|
+
# Statement cannot be followed by operators, member access (.json), commas, brackets, etc.
|
|
278
|
+
if stripped_after == "" or stripped_after.startswith("#") or stripped_after.startswith(";"):
|
|
279
|
+
return True
|
|
280
|
+
|
|
281
|
+
return False
|
|
282
|
+
|
|
283
|
+
def _find_subcommand(self, node: CommandExpressionNode) -> SubcommandNode | None:
|
|
284
|
+
for cmd in node.pipeline.commands:
|
|
285
|
+
for part in cmd.parts:
|
|
286
|
+
if isinstance(part, SubcommandNode):
|
|
287
|
+
return part
|
|
288
|
+
return None
|
|
289
|
+
|
|
290
|
+
def _transform_command_expr(
|
|
291
|
+
self,
|
|
292
|
+
node: CommandExpressionNode,
|
|
293
|
+
is_statement: bool = False,
|
|
294
|
+
is_async: bool = False,
|
|
295
|
+
is_tee: bool = False,
|
|
296
|
+
input_expr: str | None = None,
|
|
297
|
+
) -> str:
|
|
298
|
+
subcmd = self._find_subcommand(node)
|
|
299
|
+
if subcmd is not None and self.target_platform == "win32":
|
|
300
|
+
raise TranspilerError(
|
|
301
|
+
"Nested command substitutions $(...) are not supported on Windows (cmd.exe). "
|
|
302
|
+
"Assign the inner command to a variable first and pass its output: "
|
|
303
|
+
"inner = $(...); $(outer {inner.text})",
|
|
304
|
+
line=subcmd.line,
|
|
305
|
+
column=subcmd.column,
|
|
306
|
+
)
|
|
307
|
+
|
|
308
|
+
if node.background:
|
|
309
|
+
if not self.unsafe_interpolation:
|
|
310
|
+
return self._transform_expanded_command(
|
|
311
|
+
node,
|
|
312
|
+
is_statement=is_statement,
|
|
313
|
+
is_tee=is_tee,
|
|
314
|
+
input_expr=input_expr,
|
|
315
|
+
is_background=True,
|
|
316
|
+
)
|
|
317
|
+
else:
|
|
318
|
+
self.used_symbols.add("run_bg")
|
|
319
|
+
raw = node.raw
|
|
320
|
+
cmd_arg = (
|
|
321
|
+
self._format_fstring(raw)
|
|
322
|
+
if node.pipeline.has_interpolation()
|
|
323
|
+
else self._format_literal_string(raw)
|
|
324
|
+
)
|
|
325
|
+
return f"run_bg({cmd_arg})"
|
|
326
|
+
elif is_async:
|
|
327
|
+
if not self.unsafe_interpolation:
|
|
328
|
+
return self._transform_expanded_command(
|
|
329
|
+
node,
|
|
330
|
+
is_statement=is_statement,
|
|
331
|
+
is_tee=is_tee,
|
|
332
|
+
input_expr=input_expr,
|
|
333
|
+
is_async=True,
|
|
334
|
+
)
|
|
335
|
+
else:
|
|
336
|
+
self.used_symbols.add("async_run")
|
|
337
|
+
raw = node.raw
|
|
338
|
+
cmd_arg = (
|
|
339
|
+
self._format_fstring(raw)
|
|
340
|
+
if node.pipeline.has_interpolation()
|
|
341
|
+
else self._format_literal_string(raw)
|
|
342
|
+
)
|
|
343
|
+
kwargs: list[str] = []
|
|
344
|
+
if is_statement and not is_tee:
|
|
345
|
+
kwargs.append("capture=False")
|
|
346
|
+
if is_tee:
|
|
347
|
+
kwargs.append("tee=True")
|
|
348
|
+
if input_expr is not None:
|
|
349
|
+
kwargs.append(f"input={input_expr}")
|
|
350
|
+
if node.strict:
|
|
351
|
+
kwargs.append("check=True")
|
|
352
|
+
if node.safe:
|
|
353
|
+
kwargs.append("suppress_errors=True")
|
|
354
|
+
kw_str = f", {', '.join(kwargs)}" if kwargs else ""
|
|
355
|
+
return f"async_run({cmd_arg}{kw_str})"
|
|
356
|
+
elif node.pipeline.has_splat():
|
|
357
|
+
self.used_symbols.add("run_expanded")
|
|
358
|
+
return self._transform_expanded_command(
|
|
359
|
+
node,
|
|
360
|
+
is_statement=is_statement,
|
|
361
|
+
is_tee=is_tee,
|
|
362
|
+
input_expr=input_expr,
|
|
363
|
+
)
|
|
364
|
+
elif not self.unsafe_interpolation and (
|
|
365
|
+
node.pipeline.has_interpolation()
|
|
366
|
+
or len(node.pipeline.commands) > 1
|
|
367
|
+
or any(
|
|
368
|
+
isinstance(p, RedirectionNode)
|
|
369
|
+
for cmd in node.pipeline.commands
|
|
370
|
+
for p in cmd.parts
|
|
371
|
+
)
|
|
372
|
+
):
|
|
373
|
+
self.used_symbols.add("run_expanded")
|
|
374
|
+
return self._transform_expanded_command(
|
|
375
|
+
node,
|
|
376
|
+
is_statement=is_statement,
|
|
377
|
+
is_tee=is_tee,
|
|
378
|
+
input_expr=input_expr,
|
|
379
|
+
)
|
|
380
|
+
else:
|
|
381
|
+
self.used_symbols.add("run")
|
|
382
|
+
return self._transform_simple_command(
|
|
383
|
+
node,
|
|
384
|
+
is_statement=is_statement,
|
|
385
|
+
is_tee=is_tee,
|
|
386
|
+
input_expr=input_expr,
|
|
387
|
+
)
|
|
388
|
+
|
|
389
|
+
def _transform_simple_command(
|
|
390
|
+
self,
|
|
391
|
+
node: CommandExpressionNode,
|
|
392
|
+
is_statement: bool = False,
|
|
393
|
+
is_tee: bool = False,
|
|
394
|
+
input_expr: str | None = None,
|
|
395
|
+
) -> str:
|
|
396
|
+
raw = node.raw
|
|
397
|
+
has_interpolation = node.pipeline.has_interpolation()
|
|
398
|
+
|
|
399
|
+
if not has_interpolation:
|
|
400
|
+
cmd_arg = self._format_literal_string(raw)
|
|
401
|
+
else:
|
|
402
|
+
cmd_arg = self._format_fstring(raw)
|
|
403
|
+
|
|
404
|
+
kwargs: list[str] = []
|
|
405
|
+
if is_statement and not is_tee:
|
|
406
|
+
kwargs.append("capture=False")
|
|
407
|
+
if is_tee:
|
|
408
|
+
kwargs.append("tee=True")
|
|
409
|
+
if input_expr is not None:
|
|
410
|
+
kwargs.append(f"input={input_expr}")
|
|
411
|
+
if node.strict:
|
|
412
|
+
kwargs.append("check=True")
|
|
413
|
+
if node.safe:
|
|
414
|
+
kwargs.append("suppress_errors=True")
|
|
415
|
+
|
|
416
|
+
if kwargs:
|
|
417
|
+
return f"run({cmd_arg}, {', '.join(kwargs)})"
|
|
418
|
+
return f"run({cmd_arg})"
|
|
419
|
+
|
|
420
|
+
def _transform_expanded_command(
|
|
421
|
+
self,
|
|
422
|
+
node: CommandExpressionNode,
|
|
423
|
+
is_statement: bool = False,
|
|
424
|
+
is_tee: bool = False,
|
|
425
|
+
input_expr: str | None = None,
|
|
426
|
+
is_async: bool = False,
|
|
427
|
+
is_background: bool = False,
|
|
428
|
+
) -> str:
|
|
429
|
+
args: list[str] = []
|
|
430
|
+
|
|
431
|
+
for cmd_idx, cmd in enumerate(node.pipeline.commands):
|
|
432
|
+
if cmd_idx > 0:
|
|
433
|
+
self.used_symbols.add("ShellOp")
|
|
434
|
+
args.append('ShellOp("|")')
|
|
435
|
+
|
|
436
|
+
groups: list[list[CommandPartNode]] = []
|
|
437
|
+
current_group: list[CommandPartNode] = []
|
|
438
|
+
|
|
439
|
+
for part in cmd.parts:
|
|
440
|
+
if isinstance(part, (RedirectionNode, SplatNode, SubcommandNode)):
|
|
441
|
+
if current_group:
|
|
442
|
+
groups.append(current_group)
|
|
443
|
+
current_group = []
|
|
444
|
+
groups.append([part])
|
|
445
|
+
continue
|
|
446
|
+
|
|
447
|
+
if not current_group:
|
|
448
|
+
current_group.append(part)
|
|
449
|
+
else:
|
|
450
|
+
if getattr(part, "has_leading_space", True):
|
|
451
|
+
groups.append(current_group)
|
|
452
|
+
current_group = [part]
|
|
453
|
+
else:
|
|
454
|
+
current_group.append(part)
|
|
455
|
+
|
|
456
|
+
if current_group:
|
|
457
|
+
groups.append(current_group)
|
|
458
|
+
|
|
459
|
+
for group in groups:
|
|
460
|
+
if len(group) == 1:
|
|
461
|
+
part = group[0]
|
|
462
|
+
if isinstance(part, SplatNode):
|
|
463
|
+
args.append(f"*{part.expression}")
|
|
464
|
+
elif isinstance(part, InterpolationNode):
|
|
465
|
+
if part.expression:
|
|
466
|
+
args.append(f"({part.expression})")
|
|
467
|
+
elif isinstance(part, WordNode):
|
|
468
|
+
if "{" in part.value and "}" in part.value:
|
|
469
|
+
args.append(self._format_fstring(part.value, for_argv=True))
|
|
470
|
+
else:
|
|
471
|
+
args.append(self._format_literal_string(part.value))
|
|
472
|
+
elif isinstance(part, StringNode):
|
|
473
|
+
if part.quote == '"' and "{" in part.value and "}" in part.value:
|
|
474
|
+
args.append(self._format_fstring(part.value, for_argv=True))
|
|
475
|
+
else:
|
|
476
|
+
args.append(self._format_literal_string(part.value))
|
|
477
|
+
elif isinstance(part, RedirectionNode):
|
|
478
|
+
target = part.target
|
|
479
|
+
self.used_symbols.add("ShellOp")
|
|
480
|
+
args.append(f'ShellOp("{part.operator}")')
|
|
481
|
+
if "{" in target and "}" in target:
|
|
482
|
+
target_unquoted = target.strip().strip("'\"")
|
|
483
|
+
if (
|
|
484
|
+
target_unquoted.startswith("{")
|
|
485
|
+
and target_unquoted.endswith("}")
|
|
486
|
+
and target_unquoted.count("{") == 1
|
|
487
|
+
and target_unquoted.count("}") == 1
|
|
488
|
+
):
|
|
489
|
+
expr = target_unquoted[1:-1].strip()
|
|
490
|
+
args.append(f"({expr})")
|
|
491
|
+
else:
|
|
492
|
+
args.append(self._format_fstring(target, for_argv=True))
|
|
493
|
+
else:
|
|
494
|
+
args.append(self._format_literal_string(target))
|
|
495
|
+
elif isinstance(part, SubcommandNode):
|
|
496
|
+
self.used_symbols.add("ShellOp")
|
|
497
|
+
raw_sub = part.raw if part.raw else f"$({part.pipeline})"
|
|
498
|
+
if "{" in raw_sub and "}" in raw_sub:
|
|
499
|
+
args.append(f"ShellOp({self._format_fstring(raw_sub, for_argv=True)})")
|
|
500
|
+
else:
|
|
501
|
+
args.append(f"ShellOp({self._format_literal_string(raw_sub)})")
|
|
502
|
+
else:
|
|
503
|
+
fstring_pieces: list[str] = []
|
|
504
|
+
for p in group:
|
|
505
|
+
if isinstance(p, InterpolationNode):
|
|
506
|
+
fstring_pieces.append("{" + p.expression + "}")
|
|
507
|
+
elif isinstance(p, WordNode):
|
|
508
|
+
if "{" in p.value and "}" in p.value:
|
|
509
|
+
fstring_pieces.append(self._escape_non_interpolations(p.value, for_argv=True))
|
|
510
|
+
else:
|
|
511
|
+
escaped = (
|
|
512
|
+
p.value.replace("\\", "\\\\")
|
|
513
|
+
.replace('"', '\\"')
|
|
514
|
+
.replace("{", "{{")
|
|
515
|
+
.replace("}", "}}")
|
|
516
|
+
)
|
|
517
|
+
fstring_pieces.append(escaped)
|
|
518
|
+
elif isinstance(p, StringNode):
|
|
519
|
+
if p.quote == '"' and "{" in p.value and "}" in p.value:
|
|
520
|
+
fstring_pieces.append(self._escape_non_interpolations(p.value, for_argv=True))
|
|
521
|
+
else:
|
|
522
|
+
escaped = (
|
|
523
|
+
p.value.replace("\\", "\\\\")
|
|
524
|
+
.replace('"', '\\"')
|
|
525
|
+
.replace("{", "{{")
|
|
526
|
+
.replace("}", "}}")
|
|
527
|
+
)
|
|
528
|
+
fstring_pieces.append(escaped)
|
|
529
|
+
combined = "".join(fstring_pieces)
|
|
530
|
+
args.append(f'f"{combined}"')
|
|
531
|
+
|
|
532
|
+
if is_background:
|
|
533
|
+
self.used_symbols.add("run_bg")
|
|
534
|
+
return f"run_bg({', '.join(args)})"
|
|
535
|
+
|
|
536
|
+
if is_async:
|
|
537
|
+
fn_name = "async_run"
|
|
538
|
+
self.used_symbols.add("async_run")
|
|
539
|
+
else:
|
|
540
|
+
fn_name = "run_expanded"
|
|
541
|
+
self.used_symbols.add("run_expanded")
|
|
542
|
+
|
|
543
|
+
if is_statement and not is_tee:
|
|
544
|
+
args.append("capture=False")
|
|
545
|
+
if is_tee:
|
|
546
|
+
args.append("tee=True")
|
|
547
|
+
if input_expr is not None:
|
|
548
|
+
args.append(f"input={input_expr}")
|
|
549
|
+
if node.strict:
|
|
550
|
+
args.append("check=True")
|
|
551
|
+
if node.safe:
|
|
552
|
+
args.append("suppress_errors=True")
|
|
553
|
+
|
|
554
|
+
return f"{fn_name}({', '.join(args)})"
|
|
555
|
+
|
|
556
|
+
def _escape_non_interpolations(self, text: str, for_argv: bool = False) -> str:
|
|
557
|
+
"""Escape non-interpolations in f-strings: single-quoted segments, awk/json braces, and invalid expressions."""
|
|
558
|
+
out: list[str] = []
|
|
559
|
+
i = 0
|
|
560
|
+
n = len(text)
|
|
561
|
+
while i < n:
|
|
562
|
+
if text[i] == "'":
|
|
563
|
+
out.append("'")
|
|
564
|
+
i += 1
|
|
565
|
+
while i < n and text[i] != "'":
|
|
566
|
+
if text[i] == "\\":
|
|
567
|
+
out.append(text[i])
|
|
568
|
+
i += 1
|
|
569
|
+
if i < n:
|
|
570
|
+
out.append(text[i])
|
|
571
|
+
i += 1
|
|
572
|
+
continue
|
|
573
|
+
if text[i] == "{":
|
|
574
|
+
out.append("{{")
|
|
575
|
+
elif text[i] == "}":
|
|
576
|
+
out.append("}}")
|
|
577
|
+
else:
|
|
578
|
+
out.append(text[i])
|
|
579
|
+
i += 1
|
|
580
|
+
if i < n:
|
|
581
|
+
out.append("'")
|
|
582
|
+
i += 1
|
|
583
|
+
elif text[i] == '"':
|
|
584
|
+
out.append('"')
|
|
585
|
+
i += 1
|
|
586
|
+
while i < n and text[i] != '"':
|
|
587
|
+
if text[i] == "\\":
|
|
588
|
+
out.append(text[i])
|
|
589
|
+
i += 1
|
|
590
|
+
if i < n:
|
|
591
|
+
out.append(text[i])
|
|
592
|
+
i += 1
|
|
593
|
+
continue
|
|
594
|
+
if text[i] == "{":
|
|
595
|
+
start = i + 1
|
|
596
|
+
depth = 1
|
|
597
|
+
j = start
|
|
598
|
+
while j < n and depth > 0 and text[j] != '"':
|
|
599
|
+
if text[j] == "{":
|
|
600
|
+
depth += 1
|
|
601
|
+
elif text[j] == "}":
|
|
602
|
+
depth -= 1
|
|
603
|
+
j += 1
|
|
604
|
+
if depth == 0:
|
|
605
|
+
inner = text[start : j - 1]
|
|
606
|
+
if is_valid_interpolation_expr(inner):
|
|
607
|
+
if (
|
|
608
|
+
not for_argv
|
|
609
|
+
and not self.unsafe_interpolation
|
|
610
|
+
and not inner.startswith("shell_quote(")
|
|
611
|
+
):
|
|
612
|
+
self.used_symbols.add("shell_quote")
|
|
613
|
+
out.append("{" + f"shell_quote({inner}, in_double_quotes=True)" + "}")
|
|
614
|
+
else:
|
|
615
|
+
out.append("{" + inner + "}")
|
|
616
|
+
else:
|
|
617
|
+
out.append("{{" + inner + "}}")
|
|
618
|
+
i = j
|
|
619
|
+
continue
|
|
620
|
+
else:
|
|
621
|
+
out.append("{{")
|
|
622
|
+
i += 1
|
|
623
|
+
continue
|
|
624
|
+
elif text[i] == "}":
|
|
625
|
+
out.append("}}")
|
|
626
|
+
i += 1
|
|
627
|
+
else:
|
|
628
|
+
out.append(text[i])
|
|
629
|
+
i += 1
|
|
630
|
+
if i < n:
|
|
631
|
+
out.append('"')
|
|
632
|
+
i += 1
|
|
633
|
+
elif text[i] == "{":
|
|
634
|
+
start = i + 1
|
|
635
|
+
depth = 1
|
|
636
|
+
j = start
|
|
637
|
+
while j < n and depth > 0:
|
|
638
|
+
if text[j] == "{":
|
|
639
|
+
depth += 1
|
|
640
|
+
elif text[j] == "}":
|
|
641
|
+
depth -= 1
|
|
642
|
+
j += 1
|
|
643
|
+
if depth == 0:
|
|
644
|
+
inner = text[start : j - 1]
|
|
645
|
+
if is_valid_interpolation_expr(inner):
|
|
646
|
+
if (
|
|
647
|
+
not for_argv
|
|
648
|
+
and not self.unsafe_interpolation
|
|
649
|
+
and not inner.startswith("shell_quote(")
|
|
650
|
+
):
|
|
651
|
+
self.used_symbols.add("shell_quote")
|
|
652
|
+
out.append("{" + f"shell_quote({inner})" + "}")
|
|
653
|
+
else:
|
|
654
|
+
out.append("{" + inner + "}")
|
|
655
|
+
else:
|
|
656
|
+
out.append("{{" + inner + "}}")
|
|
657
|
+
i = j
|
|
658
|
+
continue
|
|
659
|
+
else:
|
|
660
|
+
out.append("{{")
|
|
661
|
+
i += 1
|
|
662
|
+
elif text[i] == "}":
|
|
663
|
+
out.append("}}")
|
|
664
|
+
i += 1
|
|
665
|
+
else:
|
|
666
|
+
out.append(text[i])
|
|
667
|
+
i += 1
|
|
668
|
+
return "".join(out)
|
|
669
|
+
|
|
670
|
+
def _format_literal_string(self, text: str) -> str:
|
|
671
|
+
"""Formats a string literal."""
|
|
672
|
+
if text.endswith('"'):
|
|
673
|
+
if "'''" not in text:
|
|
674
|
+
return f"'''{text}'''"
|
|
675
|
+
escaped = text.replace('"', '\\"')
|
|
676
|
+
return f'"{escaped}"'
|
|
677
|
+
elif text.endswith("'"):
|
|
678
|
+
if '"""' not in text:
|
|
679
|
+
return f'"""{text}"""'
|
|
680
|
+
escaped = text.replace("'", "\\'")
|
|
681
|
+
return f"'{escaped}'"
|
|
682
|
+
elif '"' not in text:
|
|
683
|
+
return f'"{text}"'
|
|
684
|
+
elif "'" not in text:
|
|
685
|
+
return f"'{text}'"
|
|
686
|
+
elif '"""' not in text:
|
|
687
|
+
return f'"""{text}"""'
|
|
688
|
+
elif "'''" not in text:
|
|
689
|
+
return f"'''{text}'''"
|
|
690
|
+
else:
|
|
691
|
+
escaped = text.replace('"', '\\"')
|
|
692
|
+
return f'"{escaped}"'
|
|
693
|
+
|
|
694
|
+
def _format_fstring(self, text: str, for_argv: bool = False) -> str:
|
|
695
|
+
"""Formats a string containing {...} expressions as a valid Python f-string literal."""
|
|
696
|
+
text = self._escape_non_interpolations(text, for_argv=for_argv)
|
|
697
|
+
if text.endswith('"'):
|
|
698
|
+
if "'''" not in text:
|
|
699
|
+
return f"f'''{text}'''"
|
|
700
|
+
escaped = text.replace('"', '\\"')
|
|
701
|
+
return f'f"{escaped}"'
|
|
702
|
+
elif text.endswith("'"):
|
|
703
|
+
if '"""' not in text:
|
|
704
|
+
return f'f"""{text}"""'
|
|
705
|
+
escaped = text.replace("'", "\\'")
|
|
706
|
+
return f"f'{escaped}'"
|
|
707
|
+
elif '"""' not in text:
|
|
708
|
+
if '"' not in text:
|
|
709
|
+
return f'f"{text}"'
|
|
710
|
+
elif "'" not in text:
|
|
711
|
+
return f"f'{text}'"
|
|
712
|
+
else:
|
|
713
|
+
return f'f"""{text}"""'
|
|
714
|
+
elif "'''" not in text:
|
|
715
|
+
return f"f'''{text}'''"
|
|
716
|
+
else:
|
|
717
|
+
escaped = text.replace('"', '\\"')
|
|
718
|
+
return f'f"{escaped}"'
|
|
719
|
+
|
|
720
|
+
def _inject_imports(self, code: str, symbols: Set[str]) -> tuple[str, int, int]:
|
|
721
|
+
"""Injects necessary pycli.runtime imports at the proper position.
|
|
722
|
+
|
|
723
|
+
Returns (updated_code, lines_added, insert_line_index).
|
|
724
|
+
"""
|
|
725
|
+
lines = code.splitlines(keepends=True)
|
|
726
|
+
if not symbols:
|
|
727
|
+
return code, 0, 0
|
|
728
|
+
|
|
729
|
+
# Try AST-based injection first
|
|
730
|
+
try:
|
|
731
|
+
tree = ast.parse(code)
|
|
732
|
+
except Exception:
|
|
733
|
+
tree = None
|
|
734
|
+
|
|
735
|
+
if tree is not None:
|
|
736
|
+
# 1. Check for existing 'from pycli.runtime import ...'
|
|
737
|
+
for stmt in tree.body:
|
|
738
|
+
if isinstance(stmt, ast.ImportFrom) and stmt.module == "pycli.runtime":
|
|
739
|
+
imported = {alias.name for alias in stmt.names}
|
|
740
|
+
if "*" in imported:
|
|
741
|
+
return code, 0, stmt.lineno - 1
|
|
742
|
+
missing = sorted(symbols - imported)
|
|
743
|
+
if not missing:
|
|
744
|
+
return code, 0, stmt.lineno - 1
|
|
745
|
+
# If it's a single-line import without aliases, update it in place
|
|
746
|
+
if stmt.lineno == stmt.end_lineno and not any(alias.asname for alias in stmt.names):
|
|
747
|
+
all_symbols = sorted(imported.union(missing))
|
|
748
|
+
newline = "\n" if lines[stmt.lineno - 1].endswith("\n") else ""
|
|
749
|
+
lines[stmt.lineno - 1] = f"from pycli.runtime import {', '.join(all_symbols)}{newline}"
|
|
750
|
+
return "".join(lines), 0, stmt.lineno - 1
|
|
751
|
+
else:
|
|
752
|
+
# Multiline or aliased: insert right after end_lineno
|
|
753
|
+
insert_idx = stmt.end_lineno
|
|
754
|
+
import_stmt = f"from pycli.runtime import {', '.join(missing)}\n"
|
|
755
|
+
result = "".join(lines[:insert_idx]) + import_stmt + "".join(lines[insert_idx:])
|
|
756
|
+
return result, 1, insert_idx
|
|
757
|
+
|
|
758
|
+
# 2. Determine insertion point
|
|
759
|
+
future_stmts = [
|
|
760
|
+
stmt
|
|
761
|
+
for stmt in tree.body
|
|
762
|
+
if isinstance(stmt, ast.ImportFrom) and stmt.module == "__future__"
|
|
763
|
+
]
|
|
764
|
+
if future_stmts:
|
|
765
|
+
insert_idx = max(stmt.end_lineno for stmt in future_stmts)
|
|
766
|
+
elif (
|
|
767
|
+
tree.body
|
|
768
|
+
and isinstance(tree.body[0], ast.Expr)
|
|
769
|
+
and isinstance(tree.body[0].value, ast.Constant)
|
|
770
|
+
and isinstance(tree.body[0].value.value, str)
|
|
771
|
+
):
|
|
772
|
+
insert_idx = tree.body[0].end_lineno
|
|
773
|
+
else:
|
|
774
|
+
insert_idx = 0
|
|
775
|
+
while insert_idx < len(lines):
|
|
776
|
+
line_str = lines[insert_idx].strip()
|
|
777
|
+
if insert_idx == 0 and line_str.startswith("#!"):
|
|
778
|
+
insert_idx += 1
|
|
779
|
+
elif line_str.startswith("#") and ("coding:" in line_str or "coding=" in line_str):
|
|
780
|
+
insert_idx += 1
|
|
781
|
+
else:
|
|
782
|
+
break
|
|
783
|
+
|
|
784
|
+
sorted_symbols = sorted(symbols)
|
|
785
|
+
import_stmt = f"from pycli.runtime import {', '.join(sorted_symbols)}\n"
|
|
786
|
+
result = "".join(lines[:insert_idx]) + import_stmt + "".join(lines[insert_idx:])
|
|
787
|
+
return result, 1, insert_idx
|
|
788
|
+
|
|
789
|
+
# Fallback when AST cannot parse code
|
|
790
|
+
insert_idx = 0
|
|
791
|
+
if lines and lines[0].startswith("#!"):
|
|
792
|
+
insert_idx = 1
|
|
793
|
+
|
|
794
|
+
sorted_symbols = sorted(symbols)
|
|
795
|
+
import_stmt = f"from pycli.runtime import {', '.join(sorted_symbols)}\n"
|
|
796
|
+
result = "".join(lines[:insert_idx]) + import_stmt + "".join(lines[insert_idx:])
|
|
797
|
+
return result, 1, insert_idx
|
|
798
|
+
|
|
799
|
+
|
|
800
|
+
def transpile(
|
|
801
|
+
source: str,
|
|
802
|
+
auto_import: bool = True,
|
|
803
|
+
validate: bool = False,
|
|
804
|
+
unsafe_interpolation: bool = False,
|
|
805
|
+
target_platform: str | None = None,
|
|
806
|
+
) -> str:
|
|
807
|
+
"""Convenience function to transpile .spy source code to Python."""
|
|
808
|
+
return Transformer(
|
|
809
|
+
auto_import=auto_import,
|
|
810
|
+
unsafe_interpolation=unsafe_interpolation,
|
|
811
|
+
target_platform=target_platform,
|
|
812
|
+
).transform(source, validate=validate)
|