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/__init__.py
ADDED
|
@@ -0,0 +1,340 @@
|
|
|
1
|
+
"""pycli: Python-compatible DevOps DSL.
|
|
2
|
+
|
|
3
|
+
Transpiles .spy files to standard Python with first-class shell execution.
|
|
4
|
+
"""
|
|
5
|
+
|
|
6
|
+
from __future__ import annotations
|
|
7
|
+
|
|
8
|
+
import argparse
|
|
9
|
+
import os
|
|
10
|
+
import sys
|
|
11
|
+
import traceback
|
|
12
|
+
from pathlib import Path
|
|
13
|
+
|
|
14
|
+
from pycli.highlighter import highlight_python, should_colorize
|
|
15
|
+
from pycli.importer import SpyFinder, SpyLoader, install_import_hook, uninstall_import_hook
|
|
16
|
+
from pycli.lexer import LexerError
|
|
17
|
+
from pycli.parser import ParseError
|
|
18
|
+
from pycli.repl import start_repl
|
|
19
|
+
from pycli.runtime import (
|
|
20
|
+
BackgroundJob,
|
|
21
|
+
CommandError,
|
|
22
|
+
CommandResult,
|
|
23
|
+
CommandTimeoutError,
|
|
24
|
+
DynamicObj,
|
|
25
|
+
ShellOp,
|
|
26
|
+
async_run,
|
|
27
|
+
async_run_expanded,
|
|
28
|
+
cd,
|
|
29
|
+
env,
|
|
30
|
+
run,
|
|
31
|
+
run_bg,
|
|
32
|
+
run_bg_expanded,
|
|
33
|
+
run_expanded,
|
|
34
|
+
shell_quote,
|
|
35
|
+
wait_all,
|
|
36
|
+
)
|
|
37
|
+
from pycli.transformer import Transformer, TranspilerError, transpile
|
|
38
|
+
|
|
39
|
+
__version__ = "0.1.0"
|
|
40
|
+
MAX_SOURCE_SIZE_BYTES = 10 * 1024 * 1024
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def get_max_source_size() -> int:
|
|
44
|
+
"""Return maximum allowed source size in bytes from env or default."""
|
|
45
|
+
return int(os.environ.get("PYCLI_MAX_SOURCE_SIZE", MAX_SOURCE_SIZE_BYTES))
|
|
46
|
+
|
|
47
|
+
__all__ = [
|
|
48
|
+
"transpile",
|
|
49
|
+
"TranspilerError",
|
|
50
|
+
"ParseError",
|
|
51
|
+
"LexerError",
|
|
52
|
+
"highlight_python",
|
|
53
|
+
"install_import_hook",
|
|
54
|
+
"uninstall_import_hook",
|
|
55
|
+
"SpyFinder",
|
|
56
|
+
"SpyLoader",
|
|
57
|
+
"run",
|
|
58
|
+
"run_expanded",
|
|
59
|
+
"run_bg",
|
|
60
|
+
"run_bg_expanded",
|
|
61
|
+
"wait_all",
|
|
62
|
+
"async_run",
|
|
63
|
+
"async_run_expanded",
|
|
64
|
+
"BackgroundJob",
|
|
65
|
+
"cd",
|
|
66
|
+
"env",
|
|
67
|
+
"shell_quote",
|
|
68
|
+
"ShellOp",
|
|
69
|
+
"start_repl",
|
|
70
|
+
"CommandResult",
|
|
71
|
+
"CommandError",
|
|
72
|
+
"CommandTimeoutError",
|
|
73
|
+
"DynamicObj",
|
|
74
|
+
"main",
|
|
75
|
+
]
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
def transpile_file(
|
|
79
|
+
input_path: Path,
|
|
80
|
+
output_path: Path | None = None,
|
|
81
|
+
validate: bool = False,
|
|
82
|
+
unsafe_interpolation: bool = False,
|
|
83
|
+
) -> str:
|
|
84
|
+
"""Transpile a .spy file to Python code, optionally writing to output_path."""
|
|
85
|
+
resolved = input_path.resolve()
|
|
86
|
+
if not resolved.is_file():
|
|
87
|
+
raise FileNotFoundError(f"File not found: {input_path}")
|
|
88
|
+
|
|
89
|
+
size = resolved.stat().st_size
|
|
90
|
+
max_size = get_max_source_size()
|
|
91
|
+
if size > max_size:
|
|
92
|
+
raise ValueError(
|
|
93
|
+
f"File {input_path} exceeds maximum allowed size "
|
|
94
|
+
f"({size} bytes > {max_size} bytes)"
|
|
95
|
+
)
|
|
96
|
+
|
|
97
|
+
source = resolved.read_text(encoding="utf-8")
|
|
98
|
+
transformer = Transformer(auto_import=True, unsafe_interpolation=unsafe_interpolation)
|
|
99
|
+
py_code = transformer.transform(source, validate=validate)
|
|
100
|
+
if output_path:
|
|
101
|
+
output_path.write_text(py_code, encoding="utf-8")
|
|
102
|
+
return py_code
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
def _make_spy_excepthook(script_path: Path, source_map: dict[int, int], original_hook):
|
|
106
|
+
resolved_path_str = str(script_path.resolve())
|
|
107
|
+
orig_path_str = str(script_path)
|
|
108
|
+
|
|
109
|
+
def _spy_excepthook(exc_type, exc_value, exc_tb):
|
|
110
|
+
try:
|
|
111
|
+
te = traceback.TracebackException(exc_type, exc_value, exc_tb)
|
|
112
|
+
spy_text = script_path.read_text(encoding="utf-8").splitlines()
|
|
113
|
+
for frame in te.stack:
|
|
114
|
+
if frame.filename in (resolved_path_str, orig_path_str, "<string>"):
|
|
115
|
+
frame.filename = orig_path_str
|
|
116
|
+
if frame.lineno in source_map:
|
|
117
|
+
spy_line = source_map[frame.lineno]
|
|
118
|
+
frame.lineno = spy_line
|
|
119
|
+
if 1 <= spy_line <= len(spy_text):
|
|
120
|
+
frame._line = spy_text[spy_line - 1]
|
|
121
|
+
for line in te.format():
|
|
122
|
+
sys.stderr.write(line)
|
|
123
|
+
except Exception:
|
|
124
|
+
original_hook(exc_type, exc_value, exc_tb)
|
|
125
|
+
|
|
126
|
+
return _spy_excepthook
|
|
127
|
+
|
|
128
|
+
|
|
129
|
+
def run_file(
|
|
130
|
+
script_path: Path,
|
|
131
|
+
script_args: list[str] | None = None,
|
|
132
|
+
validate: bool = False,
|
|
133
|
+
unsafe_interpolation: bool = False,
|
|
134
|
+
warn_external: bool = False,
|
|
135
|
+
) -> int:
|
|
136
|
+
"""Transpile and execute a .spy file using CPython with .spy import hook enabled."""
|
|
137
|
+
resolved_path = script_path.resolve()
|
|
138
|
+
if not resolved_path.is_file():
|
|
139
|
+
sys.stderr.write(f"Error: script not found: {script_path}\n")
|
|
140
|
+
return 1
|
|
141
|
+
|
|
142
|
+
size = resolved_path.stat().st_size
|
|
143
|
+
max_size = get_max_source_size()
|
|
144
|
+
if size > max_size:
|
|
145
|
+
sys.stderr.write(
|
|
146
|
+
f"Error: File {script_path} exceeds maximum allowed size "
|
|
147
|
+
f"({size} bytes > {max_size} bytes)\n"
|
|
148
|
+
)
|
|
149
|
+
return 1
|
|
150
|
+
|
|
151
|
+
if warn_external:
|
|
152
|
+
sys.stderr.write(
|
|
153
|
+
"Warning: Executing .spy script with full user privileges. pycli does not sandbox execution.\n"
|
|
154
|
+
)
|
|
155
|
+
|
|
156
|
+
install_import_hook(unsafe_interpolation=unsafe_interpolation, validate=validate)
|
|
157
|
+
|
|
158
|
+
# Ensure the script's directory is at the beginning of sys.path
|
|
159
|
+
script_dir = str(resolved_path.parent)
|
|
160
|
+
original_sys_path = list(sys.path)
|
|
161
|
+
if script_dir not in sys.path:
|
|
162
|
+
sys.path.insert(0, script_dir)
|
|
163
|
+
|
|
164
|
+
source = resolved_path.read_text(encoding="utf-8")
|
|
165
|
+
transformer = Transformer(auto_import=True, unsafe_interpolation=unsafe_interpolation)
|
|
166
|
+
try:
|
|
167
|
+
py_code = transformer.transform(source, validate=validate)
|
|
168
|
+
except (TranspilerError, LexerError, ParseError, ValueError) as e:
|
|
169
|
+
sys.stderr.write(f"Error: {e}\n")
|
|
170
|
+
return 1
|
|
171
|
+
|
|
172
|
+
original_argv = sys.argv
|
|
173
|
+
sys.argv = [str(script_path)] + (script_args or [])
|
|
174
|
+
|
|
175
|
+
global_namespace: dict = {
|
|
176
|
+
"__name__": "__main__",
|
|
177
|
+
"__file__": str(resolved_path),
|
|
178
|
+
"__doc__": None,
|
|
179
|
+
"cd": cd,
|
|
180
|
+
"env": env,
|
|
181
|
+
"wait_all": wait_all,
|
|
182
|
+
"run": run,
|
|
183
|
+
"run_expanded": run_expanded,
|
|
184
|
+
"run_bg": run_bg,
|
|
185
|
+
"async_run": async_run,
|
|
186
|
+
"shell_quote": shell_quote,
|
|
187
|
+
"ShellOp": ShellOp,
|
|
188
|
+
"CommandResult": CommandResult,
|
|
189
|
+
"CommandError": CommandError,
|
|
190
|
+
"CommandTimeoutError": CommandTimeoutError,
|
|
191
|
+
"DynamicObj": DynamicObj,
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
orig_excepthook = sys.excepthook
|
|
195
|
+
sys.excepthook = _make_spy_excepthook(resolved_path, transformer.source_map, orig_excepthook)
|
|
196
|
+
|
|
197
|
+
try:
|
|
198
|
+
compiled = compile(py_code, str(script_path), "exec")
|
|
199
|
+
exec(compiled, global_namespace)
|
|
200
|
+
return 0
|
|
201
|
+
except SystemExit as e:
|
|
202
|
+
return e.code if isinstance(e.code, int) else (0 if e.code is None else 1)
|
|
203
|
+
except Exception as e:
|
|
204
|
+
sys.excepthook(*sys.exc_info())
|
|
205
|
+
return 1
|
|
206
|
+
finally:
|
|
207
|
+
sys.excepthook = orig_excepthook
|
|
208
|
+
sys.argv = original_argv
|
|
209
|
+
sys.path = original_sys_path
|
|
210
|
+
|
|
211
|
+
|
|
212
|
+
def main(argv: list[str] | None = None) -> None:
|
|
213
|
+
"""CLI entrypoint for pycli."""
|
|
214
|
+
if argv is None:
|
|
215
|
+
argv = sys.argv[1:]
|
|
216
|
+
|
|
217
|
+
# If no arguments provided in an interactive terminal, launch REPL
|
|
218
|
+
if not argv:
|
|
219
|
+
if hasattr(sys.stdin, "isatty") and sys.stdin.isatty():
|
|
220
|
+
start_repl()
|
|
221
|
+
return
|
|
222
|
+
|
|
223
|
+
parser = argparse.ArgumentParser(
|
|
224
|
+
prog="pycli",
|
|
225
|
+
description="pycli: Python-compatible DevOps DSL transpiler and runner.",
|
|
226
|
+
)
|
|
227
|
+
parser.add_argument(
|
|
228
|
+
"--version",
|
|
229
|
+
action="version",
|
|
230
|
+
version=f"pycli {__version__}",
|
|
231
|
+
)
|
|
232
|
+
|
|
233
|
+
subparsers = parser.add_subparsers(dest="subcommand", help="Available subcommands")
|
|
234
|
+
|
|
235
|
+
# pycli repl
|
|
236
|
+
subparsers.add_parser("repl", help="Start the interactive pycli shell")
|
|
237
|
+
|
|
238
|
+
# pycli transpile <file.spy> [-o <file.py>] [--validate] [--unsafe-interpolation] [--color / --no-color]
|
|
239
|
+
transpile_parser = subparsers.add_parser("transpile", help="Transpile .spy to standard .py")
|
|
240
|
+
transpile_parser.add_argument("file", help="Path to .spy source file")
|
|
241
|
+
transpile_parser.add_argument("-o", "--output", help="Output .py file path (defaults to stdout)")
|
|
242
|
+
transpile_parser.add_argument(
|
|
243
|
+
"--validate",
|
|
244
|
+
action="store_true",
|
|
245
|
+
default=False,
|
|
246
|
+
help="Validate generated Python using ast.parse() before output",
|
|
247
|
+
)
|
|
248
|
+
transpile_parser.add_argument(
|
|
249
|
+
"--unsafe-interpolation",
|
|
250
|
+
action="store_true",
|
|
251
|
+
default=False,
|
|
252
|
+
help="Disable automatic shell_quote() sanitization on interpolations",
|
|
253
|
+
)
|
|
254
|
+
transpile_parser.add_argument(
|
|
255
|
+
"--color",
|
|
256
|
+
action="store_true",
|
|
257
|
+
default=False,
|
|
258
|
+
help="Force colorized output even when stdout is redirected",
|
|
259
|
+
)
|
|
260
|
+
transpile_parser.add_argument(
|
|
261
|
+
"--no-color",
|
|
262
|
+
action="store_true",
|
|
263
|
+
default=False,
|
|
264
|
+
help="Disable colorized output",
|
|
265
|
+
)
|
|
266
|
+
|
|
267
|
+
# pycli run <file.spy> [--validate] [--unsafe-interpolation] [--warn-external] [args...]
|
|
268
|
+
run_parser = subparsers.add_parser("run", help="Transpile and execute a .spy file")
|
|
269
|
+
run_parser.add_argument("file", help="Path to .spy file to run")
|
|
270
|
+
run_parser.add_argument(
|
|
271
|
+
"--validate",
|
|
272
|
+
action="store_true",
|
|
273
|
+
default=False,
|
|
274
|
+
help="Validate generated Python before executing",
|
|
275
|
+
)
|
|
276
|
+
run_parser.add_argument(
|
|
277
|
+
"--unsafe-interpolation",
|
|
278
|
+
action="store_true",
|
|
279
|
+
default=False,
|
|
280
|
+
help="Disable automatic shell_quote() sanitization on interpolations",
|
|
281
|
+
)
|
|
282
|
+
run_parser.add_argument(
|
|
283
|
+
"--warn-external",
|
|
284
|
+
action="store_true",
|
|
285
|
+
default=False,
|
|
286
|
+
help="Warn about executing untrusted scripts with user privileges",
|
|
287
|
+
)
|
|
288
|
+
run_parser.add_argument("args", nargs=argparse.REMAINDER, help="Arguments passed to script")
|
|
289
|
+
|
|
290
|
+
# If the first argument is a file ending in .spy or an existing file (not a known command/flag), default to run
|
|
291
|
+
if argv and not argv[0].startswith("-") and argv[0] not in ("transpile", "run", "repl", "help"):
|
|
292
|
+
argv = ["run"] + argv
|
|
293
|
+
|
|
294
|
+
args = parser.parse_args(argv)
|
|
295
|
+
|
|
296
|
+
if args.subcommand == "repl":
|
|
297
|
+
start_repl()
|
|
298
|
+
|
|
299
|
+
elif args.subcommand == "transpile":
|
|
300
|
+
input_file = Path(args.file)
|
|
301
|
+
if not input_file.exists():
|
|
302
|
+
sys.stderr.write(f"Error: file not found: {input_file}\n")
|
|
303
|
+
sys.exit(1)
|
|
304
|
+
out_path = Path(args.output) if args.output else None
|
|
305
|
+
try:
|
|
306
|
+
py_code = transpile_file(
|
|
307
|
+
input_file,
|
|
308
|
+
out_path,
|
|
309
|
+
validate=args.validate,
|
|
310
|
+
unsafe_interpolation=args.unsafe_interpolation,
|
|
311
|
+
)
|
|
312
|
+
except (ValueError, TranspilerError, LexerError, ParseError) as e:
|
|
313
|
+
sys.stderr.write(f"Error: {e}\n")
|
|
314
|
+
sys.exit(1)
|
|
315
|
+
if not out_path:
|
|
316
|
+
if should_colorize(force_color=args.color, no_color=args.no_color):
|
|
317
|
+
sys.stdout.write(highlight_python(py_code))
|
|
318
|
+
else:
|
|
319
|
+
sys.stdout.write(py_code)
|
|
320
|
+
|
|
321
|
+
elif args.subcommand == "run":
|
|
322
|
+
input_file = Path(args.file)
|
|
323
|
+
if not input_file.exists():
|
|
324
|
+
sys.stderr.write(f"Error: file not found: {input_file}\n")
|
|
325
|
+
sys.exit(1)
|
|
326
|
+
exit_code = run_file(
|
|
327
|
+
input_file,
|
|
328
|
+
args.args,
|
|
329
|
+
validate=args.validate,
|
|
330
|
+
unsafe_interpolation=args.unsafe_interpolation,
|
|
331
|
+
warn_external=args.warn_external,
|
|
332
|
+
)
|
|
333
|
+
sys.exit(exit_code)
|
|
334
|
+
|
|
335
|
+
else:
|
|
336
|
+
parser.print_help()
|
|
337
|
+
|
|
338
|
+
|
|
339
|
+
if __name__ == "__main__":
|
|
340
|
+
main()
|
pycli/highlighter.py
ADDED
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
"""ANSI syntax highlighting for Python code using Python's standard library tokenizer."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import builtins
|
|
6
|
+
import io
|
|
7
|
+
import keyword
|
|
8
|
+
import os
|
|
9
|
+
import sys
|
|
10
|
+
import tokenize
|
|
11
|
+
|
|
12
|
+
RESET = "\033[0m"
|
|
13
|
+
KW = "\033[94;1m" # Bright Blue bold (Python keywords)
|
|
14
|
+
DEF_NAME = "\033[93;1m" # Yellow bold (def / class names)
|
|
15
|
+
BUILTIN = "\033[96m" # Cyan (Python builtins)
|
|
16
|
+
STR = "\033[92m" # Bright Green (String literals)
|
|
17
|
+
NUM = "\033[93m" # Yellow (Numeric literals)
|
|
18
|
+
COMMENT = "\033[90;3m" # Gray italic (Comments)
|
|
19
|
+
OP = "\033[97m" # Bright White (Operators & punctuation)
|
|
20
|
+
PYCLI = "\033[95;1m" # Magenta bold (pycli runtime symbols)
|
|
21
|
+
|
|
22
|
+
BUILTIN_NAMES = set(dir(builtins))
|
|
23
|
+
PYCLI_NAMES = {
|
|
24
|
+
"run",
|
|
25
|
+
"run_expanded",
|
|
26
|
+
"CommandResult",
|
|
27
|
+
"CommandError",
|
|
28
|
+
"DynamicObj",
|
|
29
|
+
"stdout",
|
|
30
|
+
"stderr",
|
|
31
|
+
"exit_code",
|
|
32
|
+
"duration",
|
|
33
|
+
"command",
|
|
34
|
+
"json",
|
|
35
|
+
"to_dict",
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def enable_windows_ansi() -> None:
|
|
40
|
+
"""Enable virtual terminal processing on Windows consoles if needed."""
|
|
41
|
+
if os.name == "nt":
|
|
42
|
+
os.system("")
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def highlight_python(code: str) -> str:
|
|
46
|
+
"""Apply ANSI syntax highlighting to Python source code.
|
|
47
|
+
|
|
48
|
+
Uses Python's standard library `tokenize` module for lossless, dependency-free
|
|
49
|
+
syntax coloring.
|
|
50
|
+
"""
|
|
51
|
+
enable_windows_ansi()
|
|
52
|
+
|
|
53
|
+
try:
|
|
54
|
+
tokens = list(tokenize.tokenize(io.BytesIO(code.encode("utf-8")).readline))
|
|
55
|
+
except Exception:
|
|
56
|
+
# Fallback if code cannot be tokenized
|
|
57
|
+
return code
|
|
58
|
+
|
|
59
|
+
lines = code.splitlines(keepends=True)
|
|
60
|
+
cur_line, cur_col = 1, 0
|
|
61
|
+
result: list[str] = []
|
|
62
|
+
prev_tok: tokenize.TokenInfo | None = None
|
|
63
|
+
|
|
64
|
+
for t in tokens:
|
|
65
|
+
if t.type in (tokenize.ENCODING, tokenize.ENDMARKER):
|
|
66
|
+
continue
|
|
67
|
+
|
|
68
|
+
# Emit any whitespace/newlines preceding this token
|
|
69
|
+
while cur_line < t.start[0]:
|
|
70
|
+
if cur_line - 1 < len(lines):
|
|
71
|
+
result.append(lines[cur_line - 1][cur_col:])
|
|
72
|
+
cur_line += 1
|
|
73
|
+
cur_col = 0
|
|
74
|
+
if cur_line - 1 < len(lines) and cur_col < t.start[1]:
|
|
75
|
+
result.append(lines[cur_line - 1][cur_col : t.start[1]])
|
|
76
|
+
cur_col = t.start[1]
|
|
77
|
+
|
|
78
|
+
# Determine token color
|
|
79
|
+
color: str | None = None
|
|
80
|
+
if t.type == tokenize.NAME:
|
|
81
|
+
if keyword.iskeyword(t.string):
|
|
82
|
+
color = KW
|
|
83
|
+
elif prev_tok and prev_tok.string in ("def", "class"):
|
|
84
|
+
color = DEF_NAME
|
|
85
|
+
elif t.string in PYCLI_NAMES:
|
|
86
|
+
color = PYCLI
|
|
87
|
+
elif t.string in BUILTIN_NAMES:
|
|
88
|
+
color = BUILTIN
|
|
89
|
+
elif t.type == tokenize.STRING:
|
|
90
|
+
color = STR
|
|
91
|
+
elif t.type == tokenize.NUMBER:
|
|
92
|
+
color = NUM
|
|
93
|
+
elif t.type == tokenize.COMMENT:
|
|
94
|
+
color = COMMENT
|
|
95
|
+
elif t.type == tokenize.OP:
|
|
96
|
+
color = OP
|
|
97
|
+
|
|
98
|
+
if color:
|
|
99
|
+
result.append(f"{color}{t.string}{RESET}")
|
|
100
|
+
else:
|
|
101
|
+
result.append(t.string)
|
|
102
|
+
|
|
103
|
+
cur_line, cur_col = t.end
|
|
104
|
+
if t.type not in (tokenize.NL, tokenize.NEWLINE, tokenize.COMMENT, tokenize.INDENT, tokenize.DEDENT):
|
|
105
|
+
prev_tok = t
|
|
106
|
+
|
|
107
|
+
# Emit any remaining trailing whitespace or content
|
|
108
|
+
if cur_line - 1 < len(lines):
|
|
109
|
+
result.append(lines[cur_line - 1][cur_col:])
|
|
110
|
+
for rem in lines[cur_line:]:
|
|
111
|
+
result.append(rem)
|
|
112
|
+
|
|
113
|
+
return "".join(result)
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
def should_colorize(force_color: bool = False, no_color: bool = False) -> bool:
|
|
117
|
+
"""Determine whether syntax coloring should be applied to stdout."""
|
|
118
|
+
if no_color or os.environ.get("NO_COLOR"):
|
|
119
|
+
return False
|
|
120
|
+
if force_color:
|
|
121
|
+
return True
|
|
122
|
+
return hasattr(sys.stdout, "isatty") and sys.stdout.isatty()
|
pycli/importer.py
ADDED
|
@@ -0,0 +1,214 @@
|
|
|
1
|
+
"""Import hook system for pycli (.spy) files.
|
|
2
|
+
|
|
3
|
+
Allows standard Python `import` statements to transparently discover,
|
|
4
|
+
transpile, and execute .spy files as modules and packages.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import hashlib
|
|
10
|
+
import importlib.abc
|
|
11
|
+
import importlib.machinery
|
|
12
|
+
import importlib.util
|
|
13
|
+
import marshal
|
|
14
|
+
import sys
|
|
15
|
+
from pathlib import Path
|
|
16
|
+
from typing import Any, Sequence
|
|
17
|
+
|
|
18
|
+
from pycli.transformer import transpile
|
|
19
|
+
|
|
20
|
+
STDLIB_NAMES = getattr(sys, "stdlib_module_names", frozenset())
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def _get_cached_code(
|
|
24
|
+
spy_path: Path,
|
|
25
|
+
source: str,
|
|
26
|
+
unsafe_interpolation: bool = False,
|
|
27
|
+
validate: bool = False,
|
|
28
|
+
) -> Any:
|
|
29
|
+
"""Retrieve compiled bytecode from __pycache__ or compile and cache it."""
|
|
30
|
+
cache_dir = spy_path.parent / "__pycache__"
|
|
31
|
+
cache_key_data = f"{source}\0unsafe={unsafe_interpolation}".encode("utf-8")
|
|
32
|
+
src_hash = hashlib.sha256(cache_key_data).hexdigest()[:16]
|
|
33
|
+
cache_file = cache_dir / f"{spy_path.stem}.spy-{src_hash}.pyc"
|
|
34
|
+
|
|
35
|
+
if cache_file.is_file():
|
|
36
|
+
try:
|
|
37
|
+
data = cache_file.read_bytes()
|
|
38
|
+
return marshal.loads(data[8:]) # skip 8-byte magic header
|
|
39
|
+
except Exception:
|
|
40
|
+
pass
|
|
41
|
+
|
|
42
|
+
py_code = transpile(source, unsafe_interpolation=unsafe_interpolation, validate=validate)
|
|
43
|
+
compiled = compile(py_code, str(spy_path), "exec")
|
|
44
|
+
|
|
45
|
+
try:
|
|
46
|
+
cache_dir.mkdir(parents=True, exist_ok=True)
|
|
47
|
+
cache_file.write_bytes(b"\x00" * 8 + marshal.dumps(compiled))
|
|
48
|
+
except Exception:
|
|
49
|
+
# Silently ignore if filesystem is read-only or not writable
|
|
50
|
+
pass
|
|
51
|
+
|
|
52
|
+
return compiled
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
class SpyLoader(importlib.abc.Loader):
|
|
56
|
+
"""Loader that transpiles .spy code to standard Python and executes it."""
|
|
57
|
+
|
|
58
|
+
def __init__(
|
|
59
|
+
self,
|
|
60
|
+
fullname: str,
|
|
61
|
+
path: str | Path,
|
|
62
|
+
unsafe_interpolation: bool = False,
|
|
63
|
+
validate: bool = False,
|
|
64
|
+
) -> None:
|
|
65
|
+
self.fullname = fullname
|
|
66
|
+
self.path = str(Path(path).resolve())
|
|
67
|
+
self.unsafe_interpolation = unsafe_interpolation
|
|
68
|
+
self.validate = validate
|
|
69
|
+
|
|
70
|
+
def create_module(self, spec: importlib.machinery.ModuleSpec):
|
|
71
|
+
"""Use default Python module creation."""
|
|
72
|
+
return None
|
|
73
|
+
|
|
74
|
+
def exec_module(self, module) -> None:
|
|
75
|
+
"""Transpile .spy file and execute inside module.__dict__."""
|
|
76
|
+
filepath = Path(self.path).resolve()
|
|
77
|
+
if not filepath.is_file():
|
|
78
|
+
raise FileNotFoundError(f"Cannot import .spy module from non-existent path: {self.path}")
|
|
79
|
+
|
|
80
|
+
source = filepath.read_text(encoding="utf-8")
|
|
81
|
+
compiled = _get_cached_code(
|
|
82
|
+
filepath,
|
|
83
|
+
source,
|
|
84
|
+
unsafe_interpolation=self.unsafe_interpolation,
|
|
85
|
+
validate=self.validate,
|
|
86
|
+
)
|
|
87
|
+
|
|
88
|
+
module.__file__ = str(filepath)
|
|
89
|
+
module.__loader__ = self
|
|
90
|
+
|
|
91
|
+
if filepath.name == "__init__.spy":
|
|
92
|
+
module.__path__ = [str(filepath.parent)]
|
|
93
|
+
module.__package__ = self.fullname
|
|
94
|
+
else:
|
|
95
|
+
module.__package__ = self.fullname.rpartition(".")[0]
|
|
96
|
+
|
|
97
|
+
exec(compiled, module.__dict__)
|
|
98
|
+
|
|
99
|
+
def get_source(self, fullname: str) -> str | None:
|
|
100
|
+
"""Return the original .spy source text."""
|
|
101
|
+
filepath = Path(self.path).resolve()
|
|
102
|
+
if filepath.is_file():
|
|
103
|
+
return filepath.read_text(encoding="utf-8")
|
|
104
|
+
return None
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
class SpyFinder(importlib.abc.MetaPathFinder):
|
|
108
|
+
"""MetaPathFinder that locates .spy files and packages along search paths."""
|
|
109
|
+
|
|
110
|
+
def __init__(
|
|
111
|
+
self,
|
|
112
|
+
unsafe_interpolation: bool = False,
|
|
113
|
+
validate: bool = False,
|
|
114
|
+
) -> None:
|
|
115
|
+
self.unsafe_interpolation = unsafe_interpolation
|
|
116
|
+
self.validate = validate
|
|
117
|
+
|
|
118
|
+
def find_spec(
|
|
119
|
+
self,
|
|
120
|
+
fullname: str,
|
|
121
|
+
path: Sequence[str] | None = None,
|
|
122
|
+
target=None,
|
|
123
|
+
) -> importlib.machinery.ModuleSpec | None:
|
|
124
|
+
top_level = fullname.split(".")[0]
|
|
125
|
+
# Never shadow standard library modules
|
|
126
|
+
if top_level in STDLIB_NAMES:
|
|
127
|
+
return None
|
|
128
|
+
|
|
129
|
+
# If already imported and in sys.modules, do not interfere
|
|
130
|
+
if fullname in sys.modules:
|
|
131
|
+
return None
|
|
132
|
+
|
|
133
|
+
subname = fullname.rpartition(".")[-1]
|
|
134
|
+
|
|
135
|
+
# Use package search path if provided, otherwise standard sys.path
|
|
136
|
+
search_dirs = path if path is not None else sys.path
|
|
137
|
+
|
|
138
|
+
for search_dir in search_dirs:
|
|
139
|
+
if not search_dir or not isinstance(search_dir, (str, Path)):
|
|
140
|
+
continue
|
|
141
|
+
dir_path = Path(search_dir).resolve()
|
|
142
|
+
if not dir_path.is_dir():
|
|
143
|
+
continue
|
|
144
|
+
|
|
145
|
+
# 1. Look for single file module: {dir_path}/{subname}.spy
|
|
146
|
+
module_file = (dir_path / f"{subname}.spy").resolve()
|
|
147
|
+
# Ensure no path traversal outside search directory
|
|
148
|
+
if module_file.is_file() and str(module_file).startswith(str(dir_path)):
|
|
149
|
+
loader = SpyLoader(
|
|
150
|
+
fullname,
|
|
151
|
+
module_file,
|
|
152
|
+
unsafe_interpolation=self.unsafe_interpolation,
|
|
153
|
+
validate=self.validate,
|
|
154
|
+
)
|
|
155
|
+
return importlib.util.spec_from_loader(
|
|
156
|
+
fullname,
|
|
157
|
+
loader,
|
|
158
|
+
origin=str(module_file),
|
|
159
|
+
)
|
|
160
|
+
|
|
161
|
+
# 2. Look for package: {dir_path}/{subname}/__init__.spy
|
|
162
|
+
pkg_dir = (dir_path / subname).resolve()
|
|
163
|
+
init_file = (pkg_dir / "__init__.spy").resolve()
|
|
164
|
+
if init_file.is_file() and str(init_file).startswith(str(dir_path)):
|
|
165
|
+
loader = SpyLoader(
|
|
166
|
+
fullname,
|
|
167
|
+
init_file,
|
|
168
|
+
unsafe_interpolation=self.unsafe_interpolation,
|
|
169
|
+
validate=self.validate,
|
|
170
|
+
)
|
|
171
|
+
spec = importlib.util.spec_from_loader(
|
|
172
|
+
fullname,
|
|
173
|
+
loader,
|
|
174
|
+
origin=str(init_file),
|
|
175
|
+
is_package=True,
|
|
176
|
+
)
|
|
177
|
+
if spec:
|
|
178
|
+
spec.submodule_search_locations = [str(pkg_dir)]
|
|
179
|
+
return spec
|
|
180
|
+
|
|
181
|
+
return None
|
|
182
|
+
|
|
183
|
+
|
|
184
|
+
# Singleton finder instance
|
|
185
|
+
_spy_finder: SpyFinder | None = None
|
|
186
|
+
|
|
187
|
+
|
|
188
|
+
def install_import_hook(
|
|
189
|
+
unsafe_interpolation: bool = False,
|
|
190
|
+
validate: bool = False,
|
|
191
|
+
) -> SpyFinder:
|
|
192
|
+
"""Install the .spy import hook into sys.meta_path if not already present."""
|
|
193
|
+
global _spy_finder
|
|
194
|
+
if _spy_finder is None:
|
|
195
|
+
_spy_finder = SpyFinder(
|
|
196
|
+
unsafe_interpolation=unsafe_interpolation,
|
|
197
|
+
validate=validate,
|
|
198
|
+
)
|
|
199
|
+
else:
|
|
200
|
+
_spy_finder.unsafe_interpolation = unsafe_interpolation
|
|
201
|
+
_spy_finder.validate = validate
|
|
202
|
+
|
|
203
|
+
if _spy_finder not in sys.meta_path:
|
|
204
|
+
# Insert before standard PathFinder so .spy files can be loaded
|
|
205
|
+
sys.meta_path.insert(0, _spy_finder)
|
|
206
|
+
|
|
207
|
+
return _spy_finder
|
|
208
|
+
|
|
209
|
+
|
|
210
|
+
def uninstall_import_hook() -> None:
|
|
211
|
+
"""Remove the .spy import hook from sys.meta_path."""
|
|
212
|
+
global _spy_finder
|
|
213
|
+
if _spy_finder is not None and _spy_finder in sys.meta_path:
|
|
214
|
+
sys.meta_path.remove(_spy_finder)
|