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/parser.py
ADDED
|
@@ -0,0 +1,499 @@
|
|
|
1
|
+
"""Parser and AST definitions for pycli command expressions."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from dataclasses import dataclass, field
|
|
6
|
+
from typing import List, Optional
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
import ast
|
|
10
|
+
import re
|
|
11
|
+
|
|
12
|
+
import warnings
|
|
13
|
+
|
|
14
|
+
def is_valid_interpolation_expr(expr: str) -> bool:
|
|
15
|
+
try:
|
|
16
|
+
p = ast.parse(expr.strip(), mode="eval")
|
|
17
|
+
return not isinstance(p.body, ast.Dict)
|
|
18
|
+
except SyntaxError:
|
|
19
|
+
return False
|
|
20
|
+
except Exception as e:
|
|
21
|
+
warnings.warn(
|
|
22
|
+
f"Unexpected error validating interpolation expr {expr!r}: {e}",
|
|
23
|
+
RuntimeWarning,
|
|
24
|
+
stacklevel=3,
|
|
25
|
+
)
|
|
26
|
+
return False
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
class ParseError(Exception):
|
|
30
|
+
"""Raised when parsing fails."""
|
|
31
|
+
|
|
32
|
+
def __init__(
|
|
33
|
+
self,
|
|
34
|
+
message: str,
|
|
35
|
+
line: int = 1,
|
|
36
|
+
column: int = 1,
|
|
37
|
+
fragment: str = "",
|
|
38
|
+
) -> None:
|
|
39
|
+
self.message = message
|
|
40
|
+
self.line = line
|
|
41
|
+
self.column = column
|
|
42
|
+
self.fragment = fragment
|
|
43
|
+
loc = f"Line {line}, Column {column}: " if line or column else ""
|
|
44
|
+
frag = f" near {fragment!r}" if fragment else ""
|
|
45
|
+
super().__init__(f"{loc}{message}{frag}")
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def scan_balanced(
|
|
49
|
+
text: str,
|
|
50
|
+
start: int,
|
|
51
|
+
open_ch: str,
|
|
52
|
+
close_ch: str,
|
|
53
|
+
line: int = 1,
|
|
54
|
+
col: int = 1,
|
|
55
|
+
) -> int:
|
|
56
|
+
"""Scan from `start` until matching `close_ch` is found at depth 0, ignoring quotes and escapes."""
|
|
57
|
+
depth = 1
|
|
58
|
+
i = start
|
|
59
|
+
n = len(text)
|
|
60
|
+
cur_line = line
|
|
61
|
+
cur_col = col
|
|
62
|
+
|
|
63
|
+
while i < n and depth > 0:
|
|
64
|
+
c = text[i]
|
|
65
|
+
if c == "\n":
|
|
66
|
+
cur_line += 1
|
|
67
|
+
cur_col = 1
|
|
68
|
+
else:
|
|
69
|
+
cur_col += 1
|
|
70
|
+
|
|
71
|
+
if c in ("'", '"'):
|
|
72
|
+
q = c
|
|
73
|
+
is_triple = text[i : i + 3] == q * 3
|
|
74
|
+
delim = q * 3 if is_triple else q
|
|
75
|
+
i += len(delim)
|
|
76
|
+
while i < n:
|
|
77
|
+
if text[i : i + len(delim)] == delim:
|
|
78
|
+
i += len(delim)
|
|
79
|
+
break
|
|
80
|
+
if text[i] == "\\":
|
|
81
|
+
i += 2
|
|
82
|
+
else:
|
|
83
|
+
if text[i] == "\n":
|
|
84
|
+
cur_line += 1
|
|
85
|
+
cur_col = 1
|
|
86
|
+
else:
|
|
87
|
+
cur_col += 1
|
|
88
|
+
i += 1
|
|
89
|
+
continue
|
|
90
|
+
|
|
91
|
+
if c == open_ch:
|
|
92
|
+
depth += 1
|
|
93
|
+
i += 1
|
|
94
|
+
elif c == close_ch:
|
|
95
|
+
depth -= 1
|
|
96
|
+
i += 1
|
|
97
|
+
else:
|
|
98
|
+
i += 1
|
|
99
|
+
|
|
100
|
+
if depth > 0:
|
|
101
|
+
frag = text[max(0, start - 5) : min(n, start + 20)]
|
|
102
|
+
raise ParseError(
|
|
103
|
+
f"Unclosed '{open_ch}' expression",
|
|
104
|
+
line=line,
|
|
105
|
+
column=col,
|
|
106
|
+
fragment=frag,
|
|
107
|
+
)
|
|
108
|
+
return i
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
# ---------------------------------------------------------
|
|
112
|
+
# AST Nodes
|
|
113
|
+
# ---------------------------------------------------------
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
@dataclass
|
|
117
|
+
class ASTNode:
|
|
118
|
+
pass
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
class CommandPartNode(ASTNode):
|
|
122
|
+
has_leading_space: bool = True
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
@dataclass
|
|
127
|
+
class WordNode(CommandPartNode):
|
|
128
|
+
value: str
|
|
129
|
+
|
|
130
|
+
|
|
131
|
+
@dataclass
|
|
132
|
+
class StringNode(CommandPartNode):
|
|
133
|
+
value: str
|
|
134
|
+
quote: str = '"'
|
|
135
|
+
|
|
136
|
+
|
|
137
|
+
@dataclass
|
|
138
|
+
class InterpolationNode(CommandPartNode):
|
|
139
|
+
expression: str
|
|
140
|
+
|
|
141
|
+
|
|
142
|
+
@dataclass
|
|
143
|
+
class SplatNode(CommandPartNode):
|
|
144
|
+
expression: str
|
|
145
|
+
|
|
146
|
+
|
|
147
|
+
@dataclass
|
|
148
|
+
class RedirectionNode(CommandPartNode):
|
|
149
|
+
operator: str # '>', '>>', or '<'
|
|
150
|
+
target: str
|
|
151
|
+
|
|
152
|
+
|
|
153
|
+
@dataclass
|
|
154
|
+
class SubcommandNode(CommandPartNode):
|
|
155
|
+
pipeline: PipelineNode
|
|
156
|
+
raw: str = ""
|
|
157
|
+
line: int = 1
|
|
158
|
+
column: int = 1
|
|
159
|
+
|
|
160
|
+
|
|
161
|
+
@dataclass
|
|
162
|
+
class CommandNode(ASTNode):
|
|
163
|
+
parts: List[CommandPartNode] = field(default_factory=list)
|
|
164
|
+
|
|
165
|
+
def has_splat(self) -> bool:
|
|
166
|
+
return any(isinstance(part, SplatNode) for part in self.parts)
|
|
167
|
+
|
|
168
|
+
def has_interpolation(self) -> bool:
|
|
169
|
+
return any(
|
|
170
|
+
isinstance(part, (InterpolationNode, SplatNode))
|
|
171
|
+
or (isinstance(part, RedirectionNode) and "{" in part.target and "}" in part.target)
|
|
172
|
+
or (isinstance(part, (WordNode, StringNode)) and "{" in part.value and "}" in part.value)
|
|
173
|
+
for part in self.parts
|
|
174
|
+
)
|
|
175
|
+
|
|
176
|
+
|
|
177
|
+
@dataclass
|
|
178
|
+
class PipelineNode(ASTNode):
|
|
179
|
+
commands: List[CommandNode] = field(default_factory=list)
|
|
180
|
+
|
|
181
|
+
def has_splat(self) -> bool:
|
|
182
|
+
return any(cmd.has_splat() for cmd in self.commands)
|
|
183
|
+
|
|
184
|
+
def has_interpolation(self) -> bool:
|
|
185
|
+
return any(cmd.has_interpolation() for cmd in self.commands)
|
|
186
|
+
|
|
187
|
+
|
|
188
|
+
@dataclass
|
|
189
|
+
class CommandExpressionNode(ASTNode):
|
|
190
|
+
pipeline: PipelineNode
|
|
191
|
+
strict: bool = False
|
|
192
|
+
safe: bool = False
|
|
193
|
+
background: bool = False
|
|
194
|
+
raw: str = ""
|
|
195
|
+
|
|
196
|
+
|
|
197
|
+
# ---------------------------------------------------------
|
|
198
|
+
# Parser
|
|
199
|
+
# ---------------------------------------------------------
|
|
200
|
+
|
|
201
|
+
|
|
202
|
+
class CommandParser:
|
|
203
|
+
"""Parses the content of $(...) into a CommandExpressionNode AST."""
|
|
204
|
+
|
|
205
|
+
def __init__(
|
|
206
|
+
self,
|
|
207
|
+
raw: str,
|
|
208
|
+
strict: bool = False,
|
|
209
|
+
safe: bool = False,
|
|
210
|
+
background: bool = False,
|
|
211
|
+
base_line: int = 1,
|
|
212
|
+
base_column: int = 1,
|
|
213
|
+
) -> None:
|
|
214
|
+
self.raw = raw
|
|
215
|
+
self.strict = strict
|
|
216
|
+
self.safe = safe
|
|
217
|
+
self.background = background
|
|
218
|
+
self.base_line = base_line
|
|
219
|
+
self.base_column = base_column
|
|
220
|
+
self.pos = 0
|
|
221
|
+
self.length = len(self.raw)
|
|
222
|
+
|
|
223
|
+
def get_pos(self, index: int) -> tuple[int, int]:
|
|
224
|
+
"""Convert a character index in self.raw to (line, column) in the original .spy file."""
|
|
225
|
+
prefix = self.raw[:index]
|
|
226
|
+
lines = prefix.split("\n")
|
|
227
|
+
if len(lines) == 1:
|
|
228
|
+
return (self.base_line, self.base_column + len(prefix))
|
|
229
|
+
return (self.base_line + len(lines) - 1, 1 + len(lines[-1]))
|
|
230
|
+
|
|
231
|
+
def parse(self) -> CommandExpressionNode:
|
|
232
|
+
pipeline = self._parse_pipeline()
|
|
233
|
+
return CommandExpressionNode(
|
|
234
|
+
pipeline=pipeline,
|
|
235
|
+
strict=self.strict,
|
|
236
|
+
safe=self.safe,
|
|
237
|
+
background=self.background,
|
|
238
|
+
raw=self.raw,
|
|
239
|
+
)
|
|
240
|
+
|
|
241
|
+
def _parse_pipeline(self) -> PipelineNode:
|
|
242
|
+
# Split by '|' outside quotes, braces, and subcommands
|
|
243
|
+
stage_items = self._split_pipeline_stages(self.raw)
|
|
244
|
+
commands: List[CommandNode] = []
|
|
245
|
+
for cmd_str, offset in stage_items:
|
|
246
|
+
stripped = cmd_str.strip()
|
|
247
|
+
if stripped:
|
|
248
|
+
leading_ws = len(cmd_str) - len(cmd_str.lstrip())
|
|
249
|
+
commands.append(self._parse_command(stripped, text_offset=offset + leading_ws))
|
|
250
|
+
return PipelineNode(commands=commands)
|
|
251
|
+
|
|
252
|
+
def _split_pipeline_stages(self, text: str) -> List[tuple[str, int]]:
|
|
253
|
+
stages: List[tuple[str, int]] = []
|
|
254
|
+
buf: list[str] = []
|
|
255
|
+
stage_start = 0
|
|
256
|
+
i = 0
|
|
257
|
+
n = len(text)
|
|
258
|
+
|
|
259
|
+
while i < n:
|
|
260
|
+
ch = text[i]
|
|
261
|
+
|
|
262
|
+
# Quotes
|
|
263
|
+
if ch in ("'", '"'):
|
|
264
|
+
q = ch
|
|
265
|
+
is_triple = text[i : i + 3] == q * 3
|
|
266
|
+
delim = q * 3 if is_triple else q
|
|
267
|
+
start_q = i
|
|
268
|
+
i += len(delim)
|
|
269
|
+
while i < n:
|
|
270
|
+
if text[i : i + len(delim)] == delim:
|
|
271
|
+
i += len(delim)
|
|
272
|
+
break
|
|
273
|
+
if text[i] == "\\":
|
|
274
|
+
i += 2
|
|
275
|
+
else:
|
|
276
|
+
i += 1
|
|
277
|
+
buf.append(text[start_q:i])
|
|
278
|
+
continue
|
|
279
|
+
|
|
280
|
+
# Braces { ... }
|
|
281
|
+
if ch == "{":
|
|
282
|
+
start_b = i
|
|
283
|
+
cur_line, cur_col = self.get_pos(i)
|
|
284
|
+
i = scan_balanced(text, i + 1, "{", "}", line=cur_line, col=cur_col)
|
|
285
|
+
buf.append(text[start_b:i])
|
|
286
|
+
continue
|
|
287
|
+
|
|
288
|
+
# Subcommands $( ... )
|
|
289
|
+
if ch == "$" and i + 1 < n and text[i + 1] == "(":
|
|
290
|
+
start_s = i
|
|
291
|
+
cur_line, cur_col = self.get_pos(i)
|
|
292
|
+
i = scan_balanced(text, i + 2, "(", ")", line=cur_line, col=cur_col)
|
|
293
|
+
buf.append(text[start_s:i])
|
|
294
|
+
continue
|
|
295
|
+
|
|
296
|
+
# Pipeline separator '|'
|
|
297
|
+
if ch == "|":
|
|
298
|
+
stage_str = "".join(buf)
|
|
299
|
+
if not stage_str.strip():
|
|
300
|
+
line, col = self.get_pos(i)
|
|
301
|
+
raise ParseError("Empty pipeline stage near '|'", line=line, column=col)
|
|
302
|
+
stages.append((stage_str, stage_start))
|
|
303
|
+
buf.clear()
|
|
304
|
+
i += 1
|
|
305
|
+
stage_start = i
|
|
306
|
+
continue
|
|
307
|
+
|
|
308
|
+
buf.append(ch)
|
|
309
|
+
i += 1
|
|
310
|
+
|
|
311
|
+
stage_str = "".join(buf)
|
|
312
|
+
if stages and not stage_str.strip():
|
|
313
|
+
line, col = self.get_pos(i)
|
|
314
|
+
raise ParseError("Empty pipeline stage at end of command", line=line, column=col)
|
|
315
|
+
if stage_str.strip():
|
|
316
|
+
stages.append((stage_str, stage_start))
|
|
317
|
+
|
|
318
|
+
return stages
|
|
319
|
+
|
|
320
|
+
def _parse_command(self, cmd_text: str, text_offset: int = 0) -> CommandNode:
|
|
321
|
+
parts: List[CommandPartNode] = []
|
|
322
|
+
i = 0
|
|
323
|
+
n = len(cmd_text)
|
|
324
|
+
had_space = False
|
|
325
|
+
|
|
326
|
+
while i < n:
|
|
327
|
+
prev_i = i
|
|
328
|
+
|
|
329
|
+
# Skip whitespace
|
|
330
|
+
if cmd_text[i].isspace():
|
|
331
|
+
had_space = True
|
|
332
|
+
i += 1
|
|
333
|
+
continue
|
|
334
|
+
|
|
335
|
+
# Check redirection (>>, >, <)
|
|
336
|
+
if cmd_text[i : i + 2] == ">>":
|
|
337
|
+
op = ">>"
|
|
338
|
+
op_pos = i
|
|
339
|
+
i += 2
|
|
340
|
+
while i < n and cmd_text[i].isspace():
|
|
341
|
+
i += 1
|
|
342
|
+
if i >= n or cmd_text[i] in ("|", ">", "<"):
|
|
343
|
+
line, col = self.get_pos(text_offset + op_pos)
|
|
344
|
+
raise ParseError(
|
|
345
|
+
f"Missing target for redirection operator '{op}'",
|
|
346
|
+
line=line,
|
|
347
|
+
column=col,
|
|
348
|
+
)
|
|
349
|
+
target, i = self._read_word_or_string(cmd_text, i)
|
|
350
|
+
redir_node = RedirectionNode(operator=op, target=target)
|
|
351
|
+
redir_node.has_leading_space = had_space
|
|
352
|
+
parts.append(redir_node)
|
|
353
|
+
had_space = False
|
|
354
|
+
continue
|
|
355
|
+
if cmd_text[i] in (">", "<"):
|
|
356
|
+
op = cmd_text[i]
|
|
357
|
+
op_pos = i
|
|
358
|
+
i += 1
|
|
359
|
+
while i < n and cmd_text[i].isspace():
|
|
360
|
+
i += 1
|
|
361
|
+
if i >= n or cmd_text[i] in ("|", ">", "<"):
|
|
362
|
+
line, col = self.get_pos(text_offset + op_pos)
|
|
363
|
+
raise ParseError(
|
|
364
|
+
f"Missing target for redirection operator '{op}'",
|
|
365
|
+
line=line,
|
|
366
|
+
column=col,
|
|
367
|
+
)
|
|
368
|
+
target, i = self._read_word_or_string(cmd_text, i)
|
|
369
|
+
redir_node = RedirectionNode(operator=op, target=target)
|
|
370
|
+
redir_node.has_leading_space = had_space
|
|
371
|
+
parts.append(redir_node)
|
|
372
|
+
had_space = False
|
|
373
|
+
continue
|
|
374
|
+
|
|
375
|
+
# Check subcommand $(...)
|
|
376
|
+
if cmd_text[i : i + 2] == "$(":
|
|
377
|
+
start = i
|
|
378
|
+
cur_line, cur_col = self.get_pos(text_offset + start)
|
|
379
|
+
end = scan_balanced(cmd_text, i + 2, "(", ")", line=cur_line, col=cur_col)
|
|
380
|
+
sub_inner = cmd_text[start + 2 : end - 1]
|
|
381
|
+
sub_ast = CommandParser(
|
|
382
|
+
sub_inner,
|
|
383
|
+
base_line=cur_line,
|
|
384
|
+
base_column=cur_col + 2,
|
|
385
|
+
)._parse_pipeline()
|
|
386
|
+
sub_node = SubcommandNode(
|
|
387
|
+
pipeline=sub_ast,
|
|
388
|
+
raw=cmd_text[start:end],
|
|
389
|
+
line=cur_line,
|
|
390
|
+
column=cur_col,
|
|
391
|
+
)
|
|
392
|
+
sub_node.has_leading_space = had_space
|
|
393
|
+
parts.append(sub_node)
|
|
394
|
+
had_space = False
|
|
395
|
+
i = end
|
|
396
|
+
continue
|
|
397
|
+
|
|
398
|
+
# Check splat {*...}
|
|
399
|
+
if cmd_text[i : i + 2] == "{*":
|
|
400
|
+
start = i
|
|
401
|
+
cur_line, cur_col = self.get_pos(text_offset + start)
|
|
402
|
+
end = scan_balanced(cmd_text, i + 2, "{", "}", line=cur_line, col=cur_col)
|
|
403
|
+
expr = cmd_text[start + 2 : end - 1].strip()
|
|
404
|
+
if not expr:
|
|
405
|
+
line, col = self.get_pos(text_offset + start)
|
|
406
|
+
raise ParseError("Empty splat expression '{*}'", line=line, column=col)
|
|
407
|
+
splat_node = SplatNode(expression=expr)
|
|
408
|
+
splat_node.has_leading_space = had_space
|
|
409
|
+
parts.append(splat_node)
|
|
410
|
+
had_space = False
|
|
411
|
+
i = end
|
|
412
|
+
continue
|
|
413
|
+
|
|
414
|
+
# Check interpolation {...}
|
|
415
|
+
if cmd_text[i] == "{":
|
|
416
|
+
start = i
|
|
417
|
+
cur_line, cur_col = self.get_pos(text_offset + start)
|
|
418
|
+
end = scan_balanced(cmd_text, i + 1, "{", "}", line=cur_line, col=cur_col)
|
|
419
|
+
expr = cmd_text[start + 1 : end - 1].strip()
|
|
420
|
+
if not expr:
|
|
421
|
+
line, col = self.get_pos(text_offset + start)
|
|
422
|
+
raise ParseError("Empty interpolation expression '{}'", line=line, column=col)
|
|
423
|
+
interp_node = InterpolationNode(expression=expr)
|
|
424
|
+
interp_node.has_leading_space = had_space
|
|
425
|
+
parts.append(interp_node)
|
|
426
|
+
had_space = False
|
|
427
|
+
i = end
|
|
428
|
+
continue
|
|
429
|
+
|
|
430
|
+
# Check string literal
|
|
431
|
+
if cmd_text[i] in ("'", '"'):
|
|
432
|
+
q = cmd_text[i]
|
|
433
|
+
start_q = i
|
|
434
|
+
i += 1
|
|
435
|
+
while i < n:
|
|
436
|
+
if cmd_text[i] == q:
|
|
437
|
+
i += 1
|
|
438
|
+
break
|
|
439
|
+
if cmd_text[i] == "\\":
|
|
440
|
+
i += 2
|
|
441
|
+
else:
|
|
442
|
+
i += 1
|
|
443
|
+
str_val = cmd_text[start_q + 1 : i - 1]
|
|
444
|
+
str_node = StringNode(value=str_val, quote=q)
|
|
445
|
+
str_node.has_leading_space = had_space
|
|
446
|
+
parts.append(str_node)
|
|
447
|
+
had_space = False
|
|
448
|
+
continue
|
|
449
|
+
|
|
450
|
+
# Word / token (preserves shell variables like $HOME, $?, $1, or bare $)
|
|
451
|
+
start = i
|
|
452
|
+
while (
|
|
453
|
+
i < n
|
|
454
|
+
and not cmd_text[i].isspace()
|
|
455
|
+
and cmd_text[i] not in ("|", ">", "<", "{", "'", '"')
|
|
456
|
+
and cmd_text[i : i + 2] != "$("
|
|
457
|
+
):
|
|
458
|
+
i += 1
|
|
459
|
+
word = cmd_text[start:i]
|
|
460
|
+
if word:
|
|
461
|
+
w_node = WordNode(value=word)
|
|
462
|
+
w_node.has_leading_space = had_space
|
|
463
|
+
parts.append(w_node)
|
|
464
|
+
had_space = False
|
|
465
|
+
|
|
466
|
+
# Guarantee loop progress to avoid infinite loops
|
|
467
|
+
if i == prev_i:
|
|
468
|
+
ch_node = WordNode(value=cmd_text[i])
|
|
469
|
+
ch_node.has_leading_space = had_space
|
|
470
|
+
parts.append(ch_node)
|
|
471
|
+
had_space = False
|
|
472
|
+
i += 1
|
|
473
|
+
|
|
474
|
+
return CommandNode(parts=parts)
|
|
475
|
+
|
|
476
|
+
def _read_word_or_string(self, text: str, start: int) -> tuple[str, int]:
|
|
477
|
+
n = len(text)
|
|
478
|
+
i = start
|
|
479
|
+
if i < n and text[i] in ("'", '"'):
|
|
480
|
+
q = text[i]
|
|
481
|
+
buf: list[str] = [q]
|
|
482
|
+
i += 1
|
|
483
|
+
while i < n:
|
|
484
|
+
c = text[i]
|
|
485
|
+
buf.append(c)
|
|
486
|
+
i += 1
|
|
487
|
+
if c == q:
|
|
488
|
+
break
|
|
489
|
+
if c == "\\":
|
|
490
|
+
if i < n:
|
|
491
|
+
buf.append(text[i])
|
|
492
|
+
i += 1
|
|
493
|
+
return "".join(buf), i
|
|
494
|
+
else:
|
|
495
|
+
buf = []
|
|
496
|
+
while i < n and not text[i].isspace() and text[i] not in ("|", ">", "<"):
|
|
497
|
+
buf.append(text[i])
|
|
498
|
+
i += 1
|
|
499
|
+
return "".join(buf), i
|
pycli/repl.py
ADDED
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
"""Interactive REPL for pycli (.spy)."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import code
|
|
6
|
+
import sys
|
|
7
|
+
from typing import Any
|
|
8
|
+
|
|
9
|
+
from pycli.highlighter import enable_windows_ansi
|
|
10
|
+
from pycli.importer import install_import_hook
|
|
11
|
+
from pycli.lexer import LexerError
|
|
12
|
+
from pycli.parser import ParseError
|
|
13
|
+
from pycli.runtime import (
|
|
14
|
+
CommandError,
|
|
15
|
+
CommandResult,
|
|
16
|
+
CommandTimeoutError,
|
|
17
|
+
DynamicObj,
|
|
18
|
+
cd,
|
|
19
|
+
env,
|
|
20
|
+
run,
|
|
21
|
+
run_bg,
|
|
22
|
+
run_expanded,
|
|
23
|
+
shell_quote,
|
|
24
|
+
wait_all,
|
|
25
|
+
)
|
|
26
|
+
from pycli.transformer import TranspilerError, transpile
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
class SpyConsole(code.InteractiveConsole):
|
|
30
|
+
"""Interactive console that dynamically transpiles .spy syntax $(...) on input."""
|
|
31
|
+
|
|
32
|
+
def __init__(self, locals: dict[str, Any] | None = None, filename: str = "<console>") -> None:
|
|
33
|
+
install_import_hook()
|
|
34
|
+
enable_windows_ansi()
|
|
35
|
+
|
|
36
|
+
if locals is None:
|
|
37
|
+
locals = {}
|
|
38
|
+
|
|
39
|
+
locals.setdefault("run", run)
|
|
40
|
+
locals.setdefault("run_expanded", run_expanded)
|
|
41
|
+
locals.setdefault("run_bg", run_bg)
|
|
42
|
+
locals.setdefault("wait_all", wait_all)
|
|
43
|
+
locals.setdefault("cd", cd)
|
|
44
|
+
locals.setdefault("env", env)
|
|
45
|
+
locals.setdefault("shell_quote", shell_quote)
|
|
46
|
+
locals.setdefault("CommandResult", CommandResult)
|
|
47
|
+
locals.setdefault("CommandError", CommandError)
|
|
48
|
+
locals.setdefault("CommandTimeoutError", CommandTimeoutError)
|
|
49
|
+
locals.setdefault("DynamicObj", DynamicObj)
|
|
50
|
+
|
|
51
|
+
super().__init__(locals=locals, filename=filename)
|
|
52
|
+
|
|
53
|
+
def runsource(self, source: str, filename: str = "<input>", symbol: str = "single") -> bool:
|
|
54
|
+
"""Transpile source line and execute standard interactive Python."""
|
|
55
|
+
try:
|
|
56
|
+
transpiled = transpile(source)
|
|
57
|
+
except (LexerError, ParseError, TranspilerError) as e:
|
|
58
|
+
sys.stderr.write(f" spy syntax error: {e}\n")
|
|
59
|
+
return False
|
|
60
|
+
except Exception as e:
|
|
61
|
+
sys.stderr.write(f" [pycli internal error] {type(e).__name__}: {e}\n")
|
|
62
|
+
return False
|
|
63
|
+
|
|
64
|
+
return super().runsource(transpiled, filename=filename, symbol=symbol)
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def start_repl() -> None:
|
|
68
|
+
"""Launch the pycli interactive REPL."""
|
|
69
|
+
console = SpyConsole()
|
|
70
|
+
banner = (
|
|
71
|
+
"pycli Interactive Shell (.spy)\n"
|
|
72
|
+
"Python DevOps DSL with first-class shell execution $(cmd).\n"
|
|
73
|
+
"Type 'exit()' or Ctrl+Z/D to quit.\n"
|
|
74
|
+
)
|
|
75
|
+
console.interact(banner=banner)
|