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