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