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/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)