skit-cli 0.0.1__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.
- skit/__init__.py +3 -0
- skit/analyzer.py +410 -0
- skit/argspec.py +528 -0
- skit/argstate.py +166 -0
- skit/atomic.py +101 -0
- skit/cli.py +1621 -0
- skit/config.py +255 -0
- skit/editor.py +77 -0
- skit/flows.py +596 -0
- skit/i18n.py +324 -0
- skit/inlineform.py +61 -0
- skit/launcher.py +326 -0
- skit/locales/zh_CN/LC_MESSAGES/skit.mo +0 -0
- skit/locales/zh_TW/LC_MESSAGES/skit.mo +0 -0
- skit/metawriter.py +267 -0
- skit/models.py +154 -0
- skit/paths.py +44 -0
- skit/pep723.py +292 -0
- skit/promptform.py +67 -0
- skit/reconcile.py +321 -0
- skit/shim.py +386 -0
- skit/store.py +566 -0
- skit/theme.py +123 -0
- skit/tokens.py +112 -0
- skit/tui.py +774 -0
- skit/tui_add.py +361 -0
- skit/tui_footer.py +38 -0
- skit/tui_form.py +586 -0
- skit/tui_health.py +150 -0
- skit/tui_prefs.py +175 -0
- skit/tui_settings.py +374 -0
- skit/uvman.py +288 -0
- skit_cli-0.0.1.dist-info/METADATA +199 -0
- skit_cli-0.0.1.dist-info/RECORD +37 -0
- skit_cli-0.0.1.dist-info/WHEEL +4 -0
- skit_cli-0.0.1.dist-info/entry_points.txt +3 -0
- skit_cli-0.0.1.dist-info/licenses/LICENSE +21 -0
skit/launcher.py
ADDED
|
@@ -0,0 +1,326 @@
|
|
|
1
|
+
"""Launcher: assemble the run command and execute it straight through the terminal (C2/C5/C6).
|
|
2
|
+
|
|
3
|
+
- python entries: always `uv run --no-project --script <path>` (C2). `--script` alone does NOT
|
|
4
|
+
isolate a block-less script — uv attaches it to any enclosing project's environment (verified
|
|
5
|
+
empirically), so --no-project is unconditional; PEP 723 blocks and --with deps still resolve.
|
|
6
|
+
- exe entries: run directly.
|
|
7
|
+
- command entries: template + placeholder fill-in, executed through the shell.
|
|
8
|
+
- The terminal is handed entirely to the child process (stdin/stdout/stderr pass through); the TUI
|
|
9
|
+
caller is responsible for suspend/resume.
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
from __future__ import annotations
|
|
13
|
+
|
|
14
|
+
import os
|
|
15
|
+
import re
|
|
16
|
+
import shlex
|
|
17
|
+
import shutil
|
|
18
|
+
import subprocess
|
|
19
|
+
import sys
|
|
20
|
+
from pathlib import Path
|
|
21
|
+
|
|
22
|
+
from . import config
|
|
23
|
+
from .i18n import gettext
|
|
24
|
+
from .models import Entry
|
|
25
|
+
from .paths import private_bin_dir
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
class LaunchError(Exception):
|
|
29
|
+
pass
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
class TargetMissingError(LaunchError):
|
|
33
|
+
"""The launch target (script file / executable) is gone from disk.
|
|
34
|
+
|
|
35
|
+
A distinct type so `skit run` can map it to exit 127 (command not found, docker
|
|
36
|
+
convention) while other skit-side failures map to 125 — scripts that themselves
|
|
37
|
+
exit 1 stay distinguishable from skit failing to launch them at all."""
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
class NotExecutableError(LaunchError):
|
|
41
|
+
"""The exe target exists but has no execute permission (exit 126, docker convention)."""
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def find_uv() -> str | None:
|
|
45
|
+
"""Detection order: PATH -> skit's private bin (A9/§5.6)."""
|
|
46
|
+
found = shutil.which("uv")
|
|
47
|
+
if found:
|
|
48
|
+
return found
|
|
49
|
+
private = private_bin_dir() / "uv"
|
|
50
|
+
if private.exists():
|
|
51
|
+
return str(private)
|
|
52
|
+
private_exe = private_bin_dir() / "uv.exe"
|
|
53
|
+
if private_exe.exists():
|
|
54
|
+
return str(private_exe)
|
|
55
|
+
return None
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def ensure_uv() -> str:
|
|
59
|
+
"""Find uv or auto-download a managed copy (first-run experience: zero user action). Raises
|
|
60
|
+
LaunchError on failure."""
|
|
61
|
+
found = find_uv()
|
|
62
|
+
if found:
|
|
63
|
+
return found
|
|
64
|
+
from . import uvman
|
|
65
|
+
|
|
66
|
+
try:
|
|
67
|
+
return uvman.ensure_uv_downloaded()
|
|
68
|
+
except uvman.UvDownloadError as exc:
|
|
69
|
+
raise LaunchError(
|
|
70
|
+
f"{gettext('uv not found. Install it (https://docs.astral.sh/uv/) or run skit doctor for guidance.')} ({exc})"
|
|
71
|
+
) from exc
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def _resolve_workdir(entry: Entry, invoke_cwd: Path) -> Path:
|
|
75
|
+
policy = entry.meta.workdir
|
|
76
|
+
if policy == "origin":
|
|
77
|
+
src = entry.meta.source
|
|
78
|
+
origin_dir = Path(src).parent if src else invoke_cwd
|
|
79
|
+
if entry.meta.mode == "copy" and not origin_dir.is_dir():
|
|
80
|
+
# Copy mode exists to decouple the entry from its original location, so a vanished
|
|
81
|
+
# origin must not block a run when the store copy is intact — this also recovers
|
|
82
|
+
# entries persisted with workdir="origin" before store.add_python's copy-mode default
|
|
83
|
+
# changed to "invoke". Reference-mode entries are not decoupled from their origin (the
|
|
84
|
+
# script check already fails first with a clearer message if it's gone), so they keep
|
|
85
|
+
# resolving to the origin dir unconditionally.
|
|
86
|
+
return invoke_cwd
|
|
87
|
+
return origin_dir
|
|
88
|
+
if policy == "store":
|
|
89
|
+
return entry.dir
|
|
90
|
+
if policy == "invoke":
|
|
91
|
+
return invoke_cwd
|
|
92
|
+
return Path(policy) # absolute path
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
def _check_script_exists(script: Path) -> None:
|
|
96
|
+
if not script.exists():
|
|
97
|
+
raise TargetMissingError(
|
|
98
|
+
gettext("The script file doesn't exist: %(path)s") % {"path": str(script)}
|
|
99
|
+
)
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
def _check_exe_exists(source: str) -> None:
|
|
103
|
+
path = Path(source)
|
|
104
|
+
if not path.exists():
|
|
105
|
+
raise TargetMissingError(
|
|
106
|
+
gettext("The executable doesn't exist: %(path)s") % {"path": source}
|
|
107
|
+
)
|
|
108
|
+
if sys.platform != "win32" and path.is_file() and not os.access(path, os.X_OK):
|
|
109
|
+
raise NotExecutableError(
|
|
110
|
+
gettext("%(path)s exists but isn't executable (chmod +x it?).") % {"path": source}
|
|
111
|
+
)
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
def _build_python(entry: Entry, extra: list[str], script_override: Path | None) -> list[str]:
|
|
115
|
+
# Check the cheap, local condition (does the script exist?) before the potentially-network-
|
|
116
|
+
# bound one (is uv installed, or does it need downloading?) — mirrors preflight's ordering, and
|
|
117
|
+
# spares a user with a missing script a pointless uv download/error first.
|
|
118
|
+
script = script_override or entry.script_path
|
|
119
|
+
_check_script_exists(script)
|
|
120
|
+
uv = ensure_uv()
|
|
121
|
+
# C2: unconditional isolation. Without --no-project, `uv run --script` attaches a
|
|
122
|
+
# block-less script to whatever uv project encloses the cwd (empirically verified) —
|
|
123
|
+
# and copy-mode entries default to workdir="invoke", so "run it from inside any
|
|
124
|
+
# project directory" was a live hijack path. Scripts with a PEP 723 block and
|
|
125
|
+
# reference-mode --with deps are unaffected by the flag.
|
|
126
|
+
cmd = [uv, "run", "--no-project"]
|
|
127
|
+
# In reference mode, dependencies are recorded in meta (the original file can't take a PEP 723
|
|
128
|
+
# block), so pass them via --with/--python.
|
|
129
|
+
if entry.meta.requires_python:
|
|
130
|
+
cmd += ["--python", entry.meta.requires_python]
|
|
131
|
+
for dep in entry.meta.dependencies or []:
|
|
132
|
+
cmd += ["--with", dep]
|
|
133
|
+
return [*cmd, "--script", str(script), *extra]
|
|
134
|
+
|
|
135
|
+
|
|
136
|
+
def _build_exe(entry: Entry, extra: list[str]) -> list[str]:
|
|
137
|
+
exe = entry.meta.source
|
|
138
|
+
_check_exe_exists(exe)
|
|
139
|
+
return [exe, *extra]
|
|
140
|
+
|
|
141
|
+
|
|
142
|
+
def _quote_for_shell(value: str) -> str:
|
|
143
|
+
"""Quote a single substituted value for the platform shell _build_shell executes under,
|
|
144
|
+
mirroring how `extra` args are already quoted below (shlex on POSIX, list2cmdline on Windows) —
|
|
145
|
+
otherwise a value with spaces or shell metacharacters reshapes the command's argument
|
|
146
|
+
structure or, worse, injects extra shell syntax."""
|
|
147
|
+
if sys.platform == "win32":
|
|
148
|
+
return subprocess.list2cmdline([value])
|
|
149
|
+
return shlex.quote(value)
|
|
150
|
+
|
|
151
|
+
|
|
152
|
+
# Matches, left to right: a `{{` escape, a `}}` escape, or a `{name}` placeholder (the same
|
|
153
|
+
# identifier rule as store.extract_placeholders). Substitution and escape-restoration run together
|
|
154
|
+
# in ONE pass over the ORIGINAL template via this pattern so replacement text is never re-scanned —
|
|
155
|
+
# doing it as two sequential passes (substitute placeholders, then str.replace "{{"/"}}") would
|
|
156
|
+
# corrupt any substituted value that itself contains "{{" or "}}".
|
|
157
|
+
_TEMPLATE_TOKEN_RE = re.compile(r"\{\{|\}\}|(?<!\{)\{([a-zA-Z_][a-zA-Z0-9_]*)\}(?!\})")
|
|
158
|
+
|
|
159
|
+
|
|
160
|
+
def _build_shell(entry: Entry, extra: list[str], values: dict[str, str] | None) -> str:
|
|
161
|
+
template = entry.meta.template
|
|
162
|
+
vals = values or {}
|
|
163
|
+
if entry.meta.params:
|
|
164
|
+
missing = [p for p in entry.meta.params if p not in vals]
|
|
165
|
+
if missing:
|
|
166
|
+
raise LaunchError(
|
|
167
|
+
gettext("Missing parameter values: %(names)s") % {"names": ", ".join(missing)}
|
|
168
|
+
)
|
|
169
|
+
|
|
170
|
+
def repl(m: re.Match[str]) -> str:
|
|
171
|
+
matched = m.group(0)
|
|
172
|
+
if matched == "{{":
|
|
173
|
+
return "{"
|
|
174
|
+
if matched == "}}":
|
|
175
|
+
return "}"
|
|
176
|
+
name = m.group(1)
|
|
177
|
+
if name is None or name not in vals:
|
|
178
|
+
return matched
|
|
179
|
+
return _quote_for_shell(vals[name])
|
|
180
|
+
|
|
181
|
+
cmd = _TEMPLATE_TOKEN_RE.sub(repl, template)
|
|
182
|
+
if extra:
|
|
183
|
+
# shell=True execution: quoting must follow that platform's shell (POSIX uses shlex, Windows
|
|
184
|
+
# cmd uses list2cmdline), or arguments containing $ or backticks would be expanded.
|
|
185
|
+
if sys.platform == "win32":
|
|
186
|
+
cmd = cmd + " " + subprocess.list2cmdline(extra)
|
|
187
|
+
else:
|
|
188
|
+
cmd = cmd + " " + shlex.join(extra)
|
|
189
|
+
return cmd
|
|
190
|
+
|
|
191
|
+
|
|
192
|
+
def build_command(
|
|
193
|
+
entry: Entry,
|
|
194
|
+
extra_args: list[str] | None = None,
|
|
195
|
+
values: dict[str, str] | None = None,
|
|
196
|
+
*,
|
|
197
|
+
script_override: Path | None = None,
|
|
198
|
+
) -> list[str] | str:
|
|
199
|
+
"""Return an argv list (python/exe) or a shell string (command).
|
|
200
|
+
|
|
201
|
+
values: fill-ins for the named placeholders of a command template (missing values raise
|
|
202
|
+
LaunchError).
|
|
203
|
+
script_override: the temporary script path after shim injection (python entries only; A5 leaves
|
|
204
|
+
the original copy untouched).
|
|
205
|
+
"""
|
|
206
|
+
extra = extra_args or []
|
|
207
|
+
kind = entry.meta.kind
|
|
208
|
+
if kind == "python":
|
|
209
|
+
return _build_python(entry, extra, script_override)
|
|
210
|
+
if kind == "exe":
|
|
211
|
+
return _build_exe(entry, extra)
|
|
212
|
+
if kind == "command":
|
|
213
|
+
return _build_shell(entry, extra, values)
|
|
214
|
+
raise LaunchError(gettext("Unknown entry kind: %(kind)s") % {"kind": kind})
|
|
215
|
+
|
|
216
|
+
|
|
217
|
+
def describe_command(
|
|
218
|
+
entry: Entry,
|
|
219
|
+
extra_args: list[str] | None = None,
|
|
220
|
+
values: dict[str, str] | None = None,
|
|
221
|
+
*,
|
|
222
|
+
script_override: Path | None = None,
|
|
223
|
+
) -> str:
|
|
224
|
+
"""A purely descriptive command line for transparency output and --dry-run: no uv
|
|
225
|
+
lookup or download, no existence checks, no side effects. Mirrors build_command's
|
|
226
|
+
shape; when uv isn't installed yet the literal "uv" stands in."""
|
|
227
|
+
extra = extra_args or []
|
|
228
|
+
kind = entry.meta.kind
|
|
229
|
+
if kind == "python":
|
|
230
|
+
uv = find_uv() or "uv"
|
|
231
|
+
cmd = [uv, "run", "--no-project"] # mirrors _build_python's unconditional C2 isolation
|
|
232
|
+
if entry.meta.requires_python:
|
|
233
|
+
cmd += ["--python", entry.meta.requires_python]
|
|
234
|
+
for dep in entry.meta.dependencies or []:
|
|
235
|
+
cmd += ["--with", dep]
|
|
236
|
+
script = script_override or entry.script_path
|
|
237
|
+
return _join_for_display([*cmd, "--script", str(script), *extra])
|
|
238
|
+
if kind == "exe":
|
|
239
|
+
return _join_for_display([entry.meta.source, *extra])
|
|
240
|
+
try:
|
|
241
|
+
built = _build_shell(entry, extra, values)
|
|
242
|
+
except LaunchError:
|
|
243
|
+
built = entry.meta.template
|
|
244
|
+
return built
|
|
245
|
+
|
|
246
|
+
|
|
247
|
+
def _join_for_display(argv: list[str]) -> str:
|
|
248
|
+
if sys.platform == "win32":
|
|
249
|
+
return subprocess.list2cmdline(argv)
|
|
250
|
+
return shlex.join(argv)
|
|
251
|
+
|
|
252
|
+
|
|
253
|
+
def target_missing(entry: Entry) -> bool:
|
|
254
|
+
"""Whether entry's launch target is already known to be gone from disk: the source path for
|
|
255
|
+
exe/reference entries, the stored copy for copy-mode python. Command entries have no file
|
|
256
|
+
target and never report missing."""
|
|
257
|
+
if entry.meta.kind == "exe":
|
|
258
|
+
return not Path(entry.meta.source).exists()
|
|
259
|
+
if entry.meta.kind == "python":
|
|
260
|
+
return not entry.script_path.exists()
|
|
261
|
+
return False # command entries have no file target
|
|
262
|
+
|
|
263
|
+
|
|
264
|
+
def missing_marker(entry: Entry) -> str | None:
|
|
265
|
+
"""A human-readable "target is missing" message for entry, or None when it's healthy or has no
|
|
266
|
+
file target (command entries). Callers decide how to style/render it (TUI table, CLI list).
|
|
267
|
+
exe entries are always reference-mode, so script_path is exactly their source path."""
|
|
268
|
+
if not target_missing(entry):
|
|
269
|
+
return None
|
|
270
|
+
return gettext("⚠ missing: %(path)s") % {"path": str(entry.script_path)}
|
|
271
|
+
|
|
272
|
+
|
|
273
|
+
def _check_workdir(cwd: Path) -> None:
|
|
274
|
+
if not cwd.is_dir():
|
|
275
|
+
raise LaunchError(
|
|
276
|
+
gettext("The working directory doesn't exist: %(path)s") % {"path": str(cwd)}
|
|
277
|
+
)
|
|
278
|
+
|
|
279
|
+
|
|
280
|
+
def preflight(entry: Entry, invoke_cwd: Path | None = None) -> None:
|
|
281
|
+
"""Validate what can be checked before any values/params are collected: the launch target
|
|
282
|
+
(script/exe) and the working directory. Raises LaunchError with the same messages the actual
|
|
283
|
+
build/run would eventually raise, but does none of the actual work (no uv lookup/download, no
|
|
284
|
+
process spawn) — so the TUI can call this before suspending the terminal."""
|
|
285
|
+
if entry.meta.kind == "python":
|
|
286
|
+
_check_script_exists(entry.script_path)
|
|
287
|
+
elif entry.meta.kind == "exe":
|
|
288
|
+
_check_exe_exists(entry.meta.source)
|
|
289
|
+
_check_workdir(_resolve_workdir(entry, invoke_cwd or Path.cwd()))
|
|
290
|
+
|
|
291
|
+
|
|
292
|
+
def run_entry(
|
|
293
|
+
entry: Entry,
|
|
294
|
+
extra_args: list[str] | None = None,
|
|
295
|
+
*,
|
|
296
|
+
values: dict[str, str] | None = None,
|
|
297
|
+
invoke_cwd: Path | None = None,
|
|
298
|
+
script_override: Path | None = None,
|
|
299
|
+
) -> int:
|
|
300
|
+
"""Run straight through the terminal and return the exit code.
|
|
301
|
+
|
|
302
|
+
The TUI must be suspended before calling this.
|
|
303
|
+
"""
|
|
304
|
+
cmd = build_command(entry, extra_args, values, script_override=script_override)
|
|
305
|
+
cwd = _resolve_workdir(entry, invoke_cwd or Path.cwd())
|
|
306
|
+
_check_workdir(cwd)
|
|
307
|
+
# Overlay skit's mirror settings onto uv's environment — a no-op unless the user enabled them,
|
|
308
|
+
# and never clobbering a variable the user set themselves (see config.mirror_env).
|
|
309
|
+
env = {**os.environ, **config.mirror_env(os.environ)}
|
|
310
|
+
if isinstance(cmd, str):
|
|
311
|
+
# A command entry is by definition "a shell command the user registered"; shell=True is a
|
|
312
|
+
# feature, not a hole. The template was written by the user via `skit add`, so the trust
|
|
313
|
+
# boundary is the same as the user's own shell history.
|
|
314
|
+
proc = subprocess.run(cmd, shell=True, cwd=cwd, check=False, env=env) # noqa: S602 # pragma: no mutate — check=None is falsy-equivalent to False; omitting it matches subprocess.run's own default
|
|
315
|
+
else:
|
|
316
|
+
proc = subprocess.run(cmd, cwd=cwd, check=False, env=env) # noqa: S603 — argv from a user entry # pragma: no mutate — check=None is falsy-equivalent to False; omitting it matches subprocess.run's own default
|
|
317
|
+
return _normalize_exit_code(proc.returncode)
|
|
318
|
+
|
|
319
|
+
|
|
320
|
+
def _normalize_exit_code(returncode: int) -> int:
|
|
321
|
+
"""Map subprocess.run's signal-death reporting (a negative returncode -N for "killed by signal
|
|
322
|
+
N") onto the conventional shell exit status 128+N, matching what a user would see running the
|
|
323
|
+
same command directly in a POSIX shell. Left as a raw negative number, it would be silently
|
|
324
|
+
mangled by sys.exit (which reduces any status to a byte via `& 0xFF`, e.g. -11 -> 245) while
|
|
325
|
+
also being printed to the user as a confusing negative code."""
|
|
326
|
+
return returncode if returncode >= 0 else 128 - returncode
|
|
Binary file
|
|
Binary file
|
skit/metawriter.py
ADDED
|
@@ -0,0 +1,267 @@
|
|
|
1
|
+
"""MetaWriter: write parameter definitions into the `[tool.skit]` table of a PEP 723 block (A5/A6).
|
|
2
|
+
|
|
3
|
+
Strict discipline:
|
|
4
|
+
- **Plain-text operations, never through the AST** — never reorder or reformat a single line of the
|
|
5
|
+
user's code.
|
|
6
|
+
- Only touch the `# /// script … # ///` comment block; create one (with empty dependencies) if none
|
|
7
|
+
exists.
|
|
8
|
+
- Inside the block, only the `[tool.skit]` section is replaced; everything else (dependencies, other
|
|
9
|
+
tool tables) is preserved verbatim.
|
|
10
|
+
- Definitions travel with the file (portable, hand-editable); values and presets live in central
|
|
11
|
+
state (argstate).
|
|
12
|
+
|
|
13
|
+
Shape of `[tool.skit]` (the TOML once comment prefixes are stripped):
|
|
14
|
+
|
|
15
|
+
[tool.skit]
|
|
16
|
+
schema = 1
|
|
17
|
+
|
|
18
|
+
[[tool.skit.params]]
|
|
19
|
+
name = "API_KEY"
|
|
20
|
+
kind = "const" # const | input
|
|
21
|
+
type = "str" # str | int | float | bool
|
|
22
|
+
default = "xxx" # const: the source value (absent for input)
|
|
23
|
+
prompt = "API key: " # input: the original prompt; const may set a custom form prompt
|
|
24
|
+
order = 0 # input: the call-order key (B1); omitted for const
|
|
25
|
+
secret = true # C3: the value never lands in a state file
|
|
26
|
+
"""
|
|
27
|
+
|
|
28
|
+
from __future__ import annotations
|
|
29
|
+
|
|
30
|
+
import re
|
|
31
|
+
from dataclasses import dataclass, field
|
|
32
|
+
from typing import TYPE_CHECKING, Any
|
|
33
|
+
|
|
34
|
+
from . import pep723
|
|
35
|
+
|
|
36
|
+
if TYPE_CHECKING:
|
|
37
|
+
from .analyzer import Candidate
|
|
38
|
+
|
|
39
|
+
SCT_SCHEMA = 1
|
|
40
|
+
|
|
41
|
+
_BLOCK_RE = re.compile(
|
|
42
|
+
# See the identical comment on pep723._BLOCK_RE: the closer's trailing whitespace is restricted
|
|
43
|
+
# to horizontal whitespace so a greedy `\s*$` can't cross a line boundary and swallow blank lines
|
|
44
|
+
# that follow the block, deleting them on rewrite.
|
|
45
|
+
r"(?m)^# /// script\s*$\n(?P<body>(?:^#(?:| .*)$\n)*?)^# ///[^\S\n]*$\n?",
|
|
46
|
+
)
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
@dataclass
|
|
50
|
+
class ParamSpec:
|
|
51
|
+
"""One `[[tool.skit.params]]`. Field-aligned with analyzer.Candidate and inter-convertible."""
|
|
52
|
+
|
|
53
|
+
name: str
|
|
54
|
+
kind: str = "const" # "const" | "input"
|
|
55
|
+
type: str = "str"
|
|
56
|
+
default: str | int | float | bool | None = None
|
|
57
|
+
prompt: str = ""
|
|
58
|
+
order: int = -1
|
|
59
|
+
secret: bool = False
|
|
60
|
+
# Secret-value source: the name of an environment variable to read at run time instead of
|
|
61
|
+
# asking on every run. Stores WHERE to find the value, never the value itself — the reason
|
|
62
|
+
# this may live in a plain-text file at all (C3: values never land on disk; a variable
|
|
63
|
+
# *name* is not a secret).
|
|
64
|
+
env_source: str = ""
|
|
65
|
+
|
|
66
|
+
@classmethod
|
|
67
|
+
def from_candidate(cls, c: Candidate) -> ParamSpec:
|
|
68
|
+
"""Build a managed-parameter spec from an analyzer.Candidate (the two are
|
|
69
|
+
field-aligned by design — A2). The one place this conversion lives, so the CLI,
|
|
70
|
+
TUI add panel, TUI settings, and reconcile can't drift on which fields carry over."""
|
|
71
|
+
return cls(
|
|
72
|
+
name=c.name,
|
|
73
|
+
kind=c.kind,
|
|
74
|
+
type=c.type,
|
|
75
|
+
default=c.default,
|
|
76
|
+
prompt=c.prompt,
|
|
77
|
+
order=c.order,
|
|
78
|
+
secret=c.secret,
|
|
79
|
+
)
|
|
80
|
+
|
|
81
|
+
def to_dict(self) -> dict[str, str | int | float | bool]:
|
|
82
|
+
d: dict[str, str | int | float | bool] = {
|
|
83
|
+
"name": self.name,
|
|
84
|
+
"kind": self.kind,
|
|
85
|
+
"type": self.type,
|
|
86
|
+
}
|
|
87
|
+
if self.default is not None:
|
|
88
|
+
d["default"] = self.default
|
|
89
|
+
if self.prompt:
|
|
90
|
+
d["prompt"] = self.prompt
|
|
91
|
+
if self.order >= 0:
|
|
92
|
+
d["order"] = self.order
|
|
93
|
+
if self.secret:
|
|
94
|
+
d["secret"] = True
|
|
95
|
+
if self.env_source:
|
|
96
|
+
d["env_source"] = self.env_source
|
|
97
|
+
return d
|
|
98
|
+
|
|
99
|
+
@classmethod
|
|
100
|
+
def from_dict(cls, d: dict[str, Any]) -> ParamSpec:
|
|
101
|
+
# read_params is contracted to be total: a hand-edited `order` can be any valid TOML scalar
|
|
102
|
+
# (e.g. a non-numeric string), so an uncoercible value must fall back to "no explicit order"
|
|
103
|
+
# rather than raising and crashing every caller (TUI load, `skit params`/`run`/`edit`).
|
|
104
|
+
try:
|
|
105
|
+
order = int(d.get("order", -1))
|
|
106
|
+
except (TypeError, ValueError):
|
|
107
|
+
order = -1
|
|
108
|
+
return cls(
|
|
109
|
+
name=str(d.get("name", "")),
|
|
110
|
+
kind=str(d.get("kind", "const")),
|
|
111
|
+
type=str(d.get("type", "str")),
|
|
112
|
+
default=d.get("default"),
|
|
113
|
+
prompt=str(d.get("prompt", "")),
|
|
114
|
+
order=order,
|
|
115
|
+
secret=bool(d.get("secret", False)),
|
|
116
|
+
env_source=str(d.get("env_source", "")),
|
|
117
|
+
)
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
@dataclass
|
|
121
|
+
class SctMeta:
|
|
122
|
+
params: list[ParamSpec] = field(default_factory=list)
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
def _toml_str(value: str) -> str:
|
|
126
|
+
r"""A TOML basic string. Control characters must be escaped: if a prompt contains something like
|
|
127
|
+
\n and is emitted verbatim, it splits the string across comment lines, the rewritten block fails
|
|
128
|
+
to parse, and all definitions are lost."""
|
|
129
|
+
out = value.replace("\\", "\\\\").replace('"', '\\"')
|
|
130
|
+
out = out.replace("\n", "\\n").replace("\r", "\\r").replace("\t", "\\t")
|
|
131
|
+
|
|
132
|
+
# A TOML basic string forbids unescaped U+0000..U+001F and U+007F; escape those.
|
|
133
|
+
# ALSO escape U+0085/U+2028/U+2029: TOML permits them literally, but _commentify splits
|
|
134
|
+
# the rewritten block with str.splitlines(), which breaks on those three too — an
|
|
135
|
+
# unescaped one would shred the comment body and drop every managed param definition.
|
|
136
|
+
def _escape(ch: str) -> str:
|
|
137
|
+
if ch < " " or ch in ("\x7f", "\x85", "\u2028", "\u2029"): # pragma: no mutate
|
|
138
|
+
return f"\\u{ord(ch):04X}"
|
|
139
|
+
return ch
|
|
140
|
+
|
|
141
|
+
out = "".join(_escape(ch) for ch in out)
|
|
142
|
+
return '"' + out + '"'
|
|
143
|
+
|
|
144
|
+
|
|
145
|
+
def _toml_value(value: str | int | float | bool) -> str:
|
|
146
|
+
if isinstance(value, bool):
|
|
147
|
+
return "true" if value else "false"
|
|
148
|
+
if isinstance(value, (int, float)):
|
|
149
|
+
return repr(value)
|
|
150
|
+
return _toml_str(value)
|
|
151
|
+
|
|
152
|
+
|
|
153
|
+
def render_skit_toml(params: list[ParamSpec]) -> str:
|
|
154
|
+
"""Generate the comment-stripped [tool.skit] TOML text (without the comment prefix)."""
|
|
155
|
+
lines = ["[tool.skit]", f"schema = {SCT_SCHEMA}"]
|
|
156
|
+
for p in params:
|
|
157
|
+
lines.append("")
|
|
158
|
+
lines.append("[[tool.skit.params]]")
|
|
159
|
+
for key, val in p.to_dict().items():
|
|
160
|
+
lines.append(f"{key} = {_toml_value(val)}")
|
|
161
|
+
return "\n".join(lines) + "\n"
|
|
162
|
+
|
|
163
|
+
|
|
164
|
+
def _commentify(toml_text: str) -> list[str]:
|
|
165
|
+
return [("# " + ln).rstrip() for ln in toml_text.splitlines()]
|
|
166
|
+
|
|
167
|
+
|
|
168
|
+
def _strip_comment_prefix(line: str) -> str:
|
|
169
|
+
if line.startswith("# "): # pragma: no mutate — prefix length is unobservable, re-stripped next
|
|
170
|
+
return line[2:]
|
|
171
|
+
if line.startswith("#"):
|
|
172
|
+
return line[1:]
|
|
173
|
+
return line
|
|
174
|
+
|
|
175
|
+
|
|
176
|
+
def _strip_skit_section(body_lines: list[str]) -> list[str]:
|
|
177
|
+
"""Remove any existing [tool.skit] section (and its [[tool.skit.params]]) from the block body,
|
|
178
|
+
keeping the rest."""
|
|
179
|
+
out: list[str] = []
|
|
180
|
+
skipping = False # pragma: no mutate — only read via truthiness (`if not skipping`)
|
|
181
|
+
for line in body_lines:
|
|
182
|
+
stripped = _strip_comment_prefix(line).strip()
|
|
183
|
+
if stripped.startswith("["):
|
|
184
|
+
in_skit = stripped.startswith("[tool.skit]") or stripped.startswith("[[tool.skit.")
|
|
185
|
+
skipping = in_skit
|
|
186
|
+
if not skipping:
|
|
187
|
+
out.append(line)
|
|
188
|
+
# Drop trailing empty comment lines.
|
|
189
|
+
while out and out[-1].strip() in ("#", ""):
|
|
190
|
+
out.pop()
|
|
191
|
+
return out
|
|
192
|
+
|
|
193
|
+
|
|
194
|
+
def _drop_synthetic_separator(base: str, original: str) -> str:
|
|
195
|
+
"""Undo inject_block()'s optional blank-line separator when write_params() is about to fill the
|
|
196
|
+
freshly-created block with params in the same operation.
|
|
197
|
+
|
|
198
|
+
inject_block() inserts a blank line between the closer and the following code purely for
|
|
199
|
+
readability when it is used on its own (e.g. `skit deps` adding a bare dependencies-only
|
|
200
|
+
block). But when write_params() creates the block itself (this is the "no block yet, non-empty
|
|
201
|
+
params" path), that blank line would be the ONLY thing ever added outside the
|
|
202
|
+
"# /// … # ///" block — breaking the comment-only-edits contract (A5) and the golden-corpus
|
|
203
|
+
byte-fidelity invariant. Detected structurally (not by re-deriving inject_block's shebang/coding
|
|
204
|
+
insertion logic): strip exactly one blank line, and only when it truly is synthetic (i.e.
|
|
205
|
+
`original`'s own content did not already start with a blank line at that point — inject_block
|
|
206
|
+
itself skips the separator in that case, to avoid a double blank line).
|
|
207
|
+
"""
|
|
208
|
+
block_only = pep723.build_block([])
|
|
209
|
+
idx = base.index(block_only)
|
|
210
|
+
prefix = base[:idx]
|
|
211
|
+
rest = base[idx + len(block_only) :]
|
|
212
|
+
suffix = original[len(prefix) :]
|
|
213
|
+
if rest == "\n" + suffix:
|
|
214
|
+
return prefix + block_only + suffix
|
|
215
|
+
return base
|
|
216
|
+
|
|
217
|
+
|
|
218
|
+
def write_params(text: str, params: list[ParamSpec]) -> str:
|
|
219
|
+
"""Write (or replace) the script's [tool.skit] with the parameter definitions. Empty params
|
|
220
|
+
means removing the section.
|
|
221
|
+
|
|
222
|
+
- Existing PEP 723 block: replace/append [tool.skit] inside the block, other lines verbatim.
|
|
223
|
+
- No block and non-empty params: create a block with empty dependencies (same insertion point as
|
|
224
|
+
pep723.inject_block).
|
|
225
|
+
"""
|
|
226
|
+
m = _BLOCK_RE.search(text)
|
|
227
|
+
if m is None:
|
|
228
|
+
if not params:
|
|
229
|
+
return text
|
|
230
|
+
base = pep723.inject_block(
|
|
231
|
+
text, []
|
|
232
|
+
) # pragma: no mutate — build_block only checks truthiness of dependencies
|
|
233
|
+
# inject_block has inserted the block; recurse once through the "block exists" path.
|
|
234
|
+
if _BLOCK_RE.search(base) is None: # pragma: no cover — inject_block guarantees a block
|
|
235
|
+
raise RuntimeError("inject_block failed to create a PEP 723 block")
|
|
236
|
+
base = _drop_synthetic_separator(base, text)
|
|
237
|
+
return write_params(base, params)
|
|
238
|
+
body_lines = m.group("body").splitlines()
|
|
239
|
+
kept = _strip_skit_section(body_lines)
|
|
240
|
+
new_body_lines = list(kept)
|
|
241
|
+
if params:
|
|
242
|
+
if new_body_lines:
|
|
243
|
+
new_body_lines.append("#")
|
|
244
|
+
new_body_lines.extend(_commentify(render_skit_toml(params)))
|
|
245
|
+
new_block = "# /// script\n"
|
|
246
|
+
if new_body_lines:
|
|
247
|
+
new_block += "\n".join(new_body_lines) + "\n"
|
|
248
|
+
new_block += "# ///\n"
|
|
249
|
+
return text[: m.start()] + new_block + text[m.end() :]
|
|
250
|
+
|
|
251
|
+
|
|
252
|
+
def read_params(text: str) -> list[ParamSpec]:
|
|
253
|
+
"""Read the script's [tool.skit] parameter definitions; empty list if no block/section/parse."""
|
|
254
|
+
meta = pep723.parse_block(text)
|
|
255
|
+
if not meta:
|
|
256
|
+
return []
|
|
257
|
+
# All valid TOML, all defended: `tool`, `tool.skit`, and `tool.skit.params` could each be a
|
|
258
|
+
# scalar (e.g. `tool = 5`) rather than the table/array shape this module writes. read_params is
|
|
259
|
+
# contracted to be total ("empty list if no block/section/parse"), so a malformed shape must
|
|
260
|
+
# fall back to [] rather than raising AttributeError/TypeError out of the `.get` chain or the
|
|
261
|
+
# iteration below.
|
|
262
|
+
tool = meta.get("tool")
|
|
263
|
+
skit = tool.get("skit") if isinstance(tool, dict) else None
|
|
264
|
+
raw = skit.get("params") if isinstance(skit, dict) else None
|
|
265
|
+
if not isinstance(raw, list):
|
|
266
|
+
return []
|
|
267
|
+
return [ParamSpec.from_dict(d) for d in raw if isinstance(d, dict) and d.get("name")]
|