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/flows.py
ADDED
|
@@ -0,0 +1,596 @@
|
|
|
1
|
+
"""The unified form layer (headless): any script becomes one pokeable form.
|
|
2
|
+
|
|
3
|
+
This is the redesign's north star made code ("users should never have to memorize
|
|
4
|
+
commands or flags, even for their own scripts"). Three value *sources* feed the same
|
|
5
|
+
form model; only the delivery differs:
|
|
6
|
+
|
|
7
|
+
| source | detected by | delivery |
|
|
8
|
+
|---------------|----------------------------------|---------------------------------|
|
|
9
|
+
| "inject" | [tool.skit] managed params | AST-inject a temp copy (A5) |
|
|
10
|
+
| "argparse" | static add_argument reading | assemble real CLI flags |
|
|
11
|
+
| "command" | command-template placeholders | fill the template |
|
|
12
|
+
|
|
13
|
+
The layer is presentation-free: the TUI renders a FormPlan as widgets, the CLI as
|
|
14
|
+
line prompts or an inline mini-form — one logic, N renderings. Rules implemented
|
|
15
|
+
here:
|
|
16
|
+
|
|
17
|
+
- Prefill: definition default < last-used < preset (this run's input wins in the UI).
|
|
18
|
+
- Values persist as TYPED TEXT (token/glob originals) — intent, not expansion.
|
|
19
|
+
- Tokens expand and globs re-expand at assembly time, every run.
|
|
20
|
+
- Explicit-pass: a filled field is always passed (reproducibility); checkboxes pass
|
|
21
|
+
their flag only when they differ from the script's default; degraded fields are
|
|
22
|
+
omitted when empty so the script's own default applies.
|
|
23
|
+
- Secrets: never prefilled, never saved (argstate enforces C3 structurally); an
|
|
24
|
+
env_source reads the value from the environment at assembly, and a missing
|
|
25
|
+
variable is a hard, named error — never a silently empty value.
|
|
26
|
+
"""
|
|
27
|
+
|
|
28
|
+
from __future__ import annotations
|
|
29
|
+
|
|
30
|
+
import glob as _glob
|
|
31
|
+
import os
|
|
32
|
+
import shlex
|
|
33
|
+
from collections.abc import Mapping
|
|
34
|
+
from dataclasses import dataclass, field
|
|
35
|
+
from datetime import datetime
|
|
36
|
+
from pathlib import Path
|
|
37
|
+
from typing import TYPE_CHECKING
|
|
38
|
+
|
|
39
|
+
from . import argspec, argstate, launcher, metawriter, reconcile, shim, tokens
|
|
40
|
+
from .analyzer import _is_secret_name
|
|
41
|
+
from .i18n import gettext
|
|
42
|
+
from .models import Entry
|
|
43
|
+
from .shim import ShimValueError, _coerce
|
|
44
|
+
|
|
45
|
+
if TYPE_CHECKING:
|
|
46
|
+
from collections.abc import Callable
|
|
47
|
+
|
|
48
|
+
_GLOB_CHARS = ("*", "?", "[")
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
class FormError(Exception):
|
|
52
|
+
"""A form-level failure with a user-ready, localized message."""
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
@dataclass
|
|
56
|
+
class FormField:
|
|
57
|
+
key: str
|
|
58
|
+
label: str
|
|
59
|
+
kind: str = "str" # str | int | float | bool | choice
|
|
60
|
+
source: str = "inject" # inject | flag | placeholder
|
|
61
|
+
choices: list[str] = field(default_factory=list)
|
|
62
|
+
default: str = "" # string-rendered script default
|
|
63
|
+
has_default: bool = False
|
|
64
|
+
help: str = ""
|
|
65
|
+
required: bool = False
|
|
66
|
+
secret: bool = False
|
|
67
|
+
env_source: str = ""
|
|
68
|
+
degraded: bool = False # free-text fallback; omit from delivery when empty
|
|
69
|
+
multiple: bool = False # shlex-split + glob-expand each piece
|
|
70
|
+
flag: str = "" # "--output"; "" = positional (flag source only)
|
|
71
|
+
action: str = "" # store_true | store_false (bool flags)
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
@dataclass
|
|
75
|
+
class FormPlan:
|
|
76
|
+
source: str # "inject" | "argparse" | "command" | "none"
|
|
77
|
+
fields: list[FormField] = field(default_factory=list)
|
|
78
|
+
drift_lines: list[str] = field(default_factory=list) # localized, shown as a banner
|
|
79
|
+
degraded_reason: str = "" # argparse whole-parser degradation: "subparsers" | "dynamic"
|
|
80
|
+
specs: list[metawriter.ParamSpec] = field(default_factory=list) # inject source only
|
|
81
|
+
text: str = "" # script text (inject delivery needs it)
|
|
82
|
+
|
|
83
|
+
@property
|
|
84
|
+
def secret_names(self) -> set[str]:
|
|
85
|
+
return {f.key for f in self.fields if f.secret}
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
@dataclass
|
|
89
|
+
class Assembly:
|
|
90
|
+
"""The delivery-ready result of a submitted form."""
|
|
91
|
+
|
|
92
|
+
args: list[str] = field(default_factory=list) # argv tail (flag source + extra args)
|
|
93
|
+
inject_values: dict[str, str] = field(default_factory=dict) # inject source, expanded
|
|
94
|
+
command_values: dict[str, str] = field(default_factory=dict) # placeholder source, expanded
|
|
95
|
+
# command_values with secret VALUES replaced by ••• — the command source's counterpart
|
|
96
|
+
# to masked_args, so a {api_key} placeholder is masked in the shown command line just
|
|
97
|
+
# like a secret flag. The real command_values still runs the process.
|
|
98
|
+
masked_command_values: dict[str, str] = field(default_factory=dict)
|
|
99
|
+
display: list[tuple[str, str]] = field(default_factory=list) # transparency (secrets masked)
|
|
100
|
+
# args with secret VALUES replaced by ••• — what transparency/--dry-run print, so a
|
|
101
|
+
# secret never lands in the scrollback (the process list is a documented boundary;
|
|
102
|
+
# the terminal log needn't be).
|
|
103
|
+
masked_args: list[str] = field(default_factory=list)
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
# --------------------------------------------------------------------------
|
|
107
|
+
# plan
|
|
108
|
+
# --------------------------------------------------------------------------
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
def plan_for_entry(entry: Entry) -> FormPlan:
|
|
112
|
+
"""Build the form plan for an entry. Total: unreadable/missing scripts yield the
|
|
113
|
+
"none" plan (extra-args escape only) rather than raising — preflight owns existence
|
|
114
|
+
errors, the form layer just refuses to invent fields it can't see."""
|
|
115
|
+
if entry.meta.kind == "command":
|
|
116
|
+
# required: an empty placeholder silently assembles a broken command (`convert '' ''`),
|
|
117
|
+
# which the non-interactive contract forbids. secret: C3 applies to every source —
|
|
118
|
+
# a {api_key} placeholder masks and never lands in a state file, like any other secret.
|
|
119
|
+
fields = [
|
|
120
|
+
FormField(
|
|
121
|
+
key=p, label=p, source="placeholder", required=True, secret=_is_secret_name(p)
|
|
122
|
+
)
|
|
123
|
+
for p in (entry.meta.params or [])
|
|
124
|
+
]
|
|
125
|
+
return FormPlan(source="command", fields=fields)
|
|
126
|
+
if entry.meta.kind != "python" or not entry.script_path.exists():
|
|
127
|
+
return FormPlan(source="none")
|
|
128
|
+
text = entry.script_path.read_text(encoding="utf-8", errors="replace") # pragma: no mutate
|
|
129
|
+
specs = metawriter.read_params(text)
|
|
130
|
+
if specs:
|
|
131
|
+
report = reconcile.reconcile(text, specs)
|
|
132
|
+
drift = list(reconcile.drift_lines(report, entry.meta.name)) if report.has_drift else []
|
|
133
|
+
return FormPlan(
|
|
134
|
+
source="inject",
|
|
135
|
+
fields=[_field_from_spec(s) for s in report.usable],
|
|
136
|
+
drift_lines=drift,
|
|
137
|
+
specs=report.usable,
|
|
138
|
+
text=text,
|
|
139
|
+
)
|
|
140
|
+
spec = argspec.read_cli(text)
|
|
141
|
+
if spec is not None:
|
|
142
|
+
if not spec.ok:
|
|
143
|
+
return FormPlan(source="argparse", degraded_reason=spec.reason, text=text)
|
|
144
|
+
return FormPlan(
|
|
145
|
+
source="argparse", fields=[_field_from_arg(a) for a in spec.fields], text=text
|
|
146
|
+
)
|
|
147
|
+
return FormPlan(source="none", text=text)
|
|
148
|
+
|
|
149
|
+
|
|
150
|
+
def _field_from_spec(s: metawriter.ParamSpec) -> FormField:
|
|
151
|
+
return FormField(
|
|
152
|
+
key=s.name,
|
|
153
|
+
label=s.prompt or s.name,
|
|
154
|
+
# "str" is intentionally absent from the whitelist: an unknown type already falls back
|
|
155
|
+
# to "str", so listing it would be redundant (and only breeds equivalent mutants).
|
|
156
|
+
kind=s.type if s.type in ("int", "float", "bool") else "str",
|
|
157
|
+
# source is left to FormField's own default ("inject") — inject IS the default source, so
|
|
158
|
+
# spelling it out here would only be a redundant, unkillable-equivalent mutation site.
|
|
159
|
+
# test_field_from_spec_maps_every_field pins that the result is "inject".
|
|
160
|
+
default="" if s.default is None else _render_default(s.default),
|
|
161
|
+
has_default=s.default is not None,
|
|
162
|
+
secret=s.secret,
|
|
163
|
+
env_source=s.env_source,
|
|
164
|
+
)
|
|
165
|
+
|
|
166
|
+
|
|
167
|
+
def _field_from_arg(a: argspec.ArgField) -> FormField:
|
|
168
|
+
return FormField(
|
|
169
|
+
key=a.dest,
|
|
170
|
+
label=a.dest,
|
|
171
|
+
kind="str" if a.degraded else a.kind,
|
|
172
|
+
source="flag",
|
|
173
|
+
choices=list(a.choices),
|
|
174
|
+
default="" if a.default is None else _render_default(a.default),
|
|
175
|
+
has_default=a.default is not None,
|
|
176
|
+
help=a.help,
|
|
177
|
+
required=a.required,
|
|
178
|
+
secret=a.secret,
|
|
179
|
+
degraded=a.degraded,
|
|
180
|
+
multiple=a.multiple,
|
|
181
|
+
flag=a.flag,
|
|
182
|
+
action=a.action,
|
|
183
|
+
)
|
|
184
|
+
|
|
185
|
+
|
|
186
|
+
def _render_default(value: str | int | float | bool) -> str:
|
|
187
|
+
if isinstance(value, bool):
|
|
188
|
+
return "true" if value else "false"
|
|
189
|
+
return str(value)
|
|
190
|
+
|
|
191
|
+
|
|
192
|
+
# --------------------------------------------------------------------------
|
|
193
|
+
# prefill / validate
|
|
194
|
+
# --------------------------------------------------------------------------
|
|
195
|
+
|
|
196
|
+
|
|
197
|
+
def prefill(plan: FormPlan, slug: str, preset: str | None = None) -> dict[str, str]:
|
|
198
|
+
"""Definition default < last-used < preset. Secrets are never prefilled — their
|
|
199
|
+
values are never on disk (C3), and echoing a remembered secret would defeat the
|
|
200
|
+
point of masking."""
|
|
201
|
+
state = argstate.load_state(slug)
|
|
202
|
+
keys = {f.key for f in plan.fields}
|
|
203
|
+
secret = plan.secret_names
|
|
204
|
+
out: dict[str, str] = {}
|
|
205
|
+
for f in plan.fields:
|
|
206
|
+
if f.has_default and not f.secret:
|
|
207
|
+
out[f.key] = f.default
|
|
208
|
+
out.update({k: v for k, v in state["values"].items() if k in keys and k not in secret})
|
|
209
|
+
if preset:
|
|
210
|
+
chosen = state["presets"].get(preset, {})
|
|
211
|
+
out.update({k: v for k, v in chosen.items() if k in keys and k not in secret})
|
|
212
|
+
return out
|
|
213
|
+
|
|
214
|
+
|
|
215
|
+
def validate(plan: FormPlan, values: Mapping[str, str]) -> dict[str, str]:
|
|
216
|
+
"""Per-field, user-ready error messages; empty dict means the form may submit."""
|
|
217
|
+
errors: dict[str, str] = {}
|
|
218
|
+
for f in plan.fields:
|
|
219
|
+
error = validate_value(f, values.get(f.key, ""))
|
|
220
|
+
if error:
|
|
221
|
+
errors[f.key] = error
|
|
222
|
+
return errors
|
|
223
|
+
|
|
224
|
+
|
|
225
|
+
def validate_value(f: FormField, value: str) -> str | None:
|
|
226
|
+
"""One field's pre-submit check (renderers use this for inline re-prompting).
|
|
227
|
+
Empty optional fields are fine (the script's own default applies); token-bearing
|
|
228
|
+
values are deferred to assembly (they can't be type-checked before expansion)."""
|
|
229
|
+
if not value.strip():
|
|
230
|
+
if f.required:
|
|
231
|
+
return gettext("%(name)s is required.") % {"name": f.label}
|
|
232
|
+
return None
|
|
233
|
+
if tokens.has_tokens(value):
|
|
234
|
+
return None
|
|
235
|
+
return _type_error(f, value)
|
|
236
|
+
|
|
237
|
+
|
|
238
|
+
def _type_error(f: FormField, value: str) -> str | None:
|
|
239
|
+
if f.kind in ("int", "float", "bool"):
|
|
240
|
+
try:
|
|
241
|
+
# f.key feeds only ShimValueError's param_name, which this function discards (it
|
|
242
|
+
# rebuilds the message from f.label below); f.key -> None is thus equivalent. The
|
|
243
|
+
# killable value/kind coercion stays covered by test_type_error_messages_exact.
|
|
244
|
+
_coerce(value, f.kind, f.key) # pragma: no mutate
|
|
245
|
+
except ShimValueError:
|
|
246
|
+
type_names = {
|
|
247
|
+
"int": gettext("a whole number"),
|
|
248
|
+
"float": gettext("a number"),
|
|
249
|
+
"bool": gettext("on or off"),
|
|
250
|
+
}
|
|
251
|
+
return gettext("%(name)s needs %(type)s — you typed %(value)r.") % {
|
|
252
|
+
"name": f.label,
|
|
253
|
+
"type": type_names[f.kind],
|
|
254
|
+
"value": value,
|
|
255
|
+
}
|
|
256
|
+
if f.kind == "choice" and f.choices and value not in f.choices:
|
|
257
|
+
return gettext("%(name)s must be one of: %(choices)s") % {
|
|
258
|
+
"name": f.label,
|
|
259
|
+
"choices": ", ".join(f.choices),
|
|
260
|
+
}
|
|
261
|
+
return None
|
|
262
|
+
|
|
263
|
+
|
|
264
|
+
def glob_feedback(value: str, cwd: Path) -> int | None:
|
|
265
|
+
"""Live match count for a multi-file field ("✓ matches N files"); None when the
|
|
266
|
+
value has no glob characters (nothing to report)."""
|
|
267
|
+
if not any(ch in value for ch in _GLOB_CHARS):
|
|
268
|
+
return None
|
|
269
|
+
try:
|
|
270
|
+
pieces = shlex.split(value)
|
|
271
|
+
except ValueError:
|
|
272
|
+
return None
|
|
273
|
+
count = 0
|
|
274
|
+
for piece in pieces:
|
|
275
|
+
if any(ch in piece for ch in _GLOB_CHARS):
|
|
276
|
+
count += len(_glob.glob(piece, root_dir=str(cwd), recursive=True))
|
|
277
|
+
else:
|
|
278
|
+
count += 1
|
|
279
|
+
return count
|
|
280
|
+
|
|
281
|
+
|
|
282
|
+
# --------------------------------------------------------------------------
|
|
283
|
+
# assemble
|
|
284
|
+
# --------------------------------------------------------------------------
|
|
285
|
+
|
|
286
|
+
|
|
287
|
+
def assemble(
|
|
288
|
+
plan: FormPlan,
|
|
289
|
+
values: Mapping[str, str],
|
|
290
|
+
extra_args: list[str],
|
|
291
|
+
*,
|
|
292
|
+
cwd: Path,
|
|
293
|
+
env: Mapping[str, str] | None = None,
|
|
294
|
+
now: datetime | None = None,
|
|
295
|
+
expand_extra: bool = True,
|
|
296
|
+
) -> Assembly:
|
|
297
|
+
"""Turn raw form values (token/glob originals) into delivery-ready material.
|
|
298
|
+
Raises FormError with a user-ready message; never assembles around a hole."""
|
|
299
|
+
if env is None:
|
|
300
|
+
env = os.environ
|
|
301
|
+
final: dict[str, str] = {}
|
|
302
|
+
display: list[tuple[str, str]] = []
|
|
303
|
+
for f in plan.fields:
|
|
304
|
+
# A missing key means "unset": "" and None are falsy-equivalent everywhere downstream
|
|
305
|
+
# (_final_value / _resolve_secret only read `raw` via truthiness), so the default is a
|
|
306
|
+
# genuinely equivalent mutant. The killable "XXXX"-style mutant stays covered by
|
|
307
|
+
# test_assemble_degraded_empty_omitted_filled_passed (a missing field must not inject).
|
|
308
|
+
raw = values.get(f.key, "") # pragma: no mutate
|
|
309
|
+
value = _final_value(f, raw, cwd=cwd, env=env, now=now)
|
|
310
|
+
if value:
|
|
311
|
+
display.append((f.key, "•••" if f.secret else value))
|
|
312
|
+
final[f.key] = value
|
|
313
|
+
# expand_extra=False: the CLI's `-- args` already went through the user's shell —
|
|
314
|
+
# a second token/glob pass would rewrite what they deliberately quoted (and --raw
|
|
315
|
+
# must be genuinely raw). The TUI's extra-args field has no shell, so it expands.
|
|
316
|
+
if expand_extra:
|
|
317
|
+
expanded_extra: list[str] = []
|
|
318
|
+
for item in extra_args:
|
|
319
|
+
try:
|
|
320
|
+
expanded_extra.extend(
|
|
321
|
+
_expand_glob_piece(tokens.expand(item, cwd=cwd, env=env, now=now), cwd)
|
|
322
|
+
)
|
|
323
|
+
except tokens.TokenError as exc:
|
|
324
|
+
raise FormError(str(exc)) from exc
|
|
325
|
+
else:
|
|
326
|
+
expanded_extra = list(extra_args)
|
|
327
|
+
out = Assembly(display=display)
|
|
328
|
+
if plan.source == "inject":
|
|
329
|
+
out.inject_values = {k: v for k, v in final.items() if v}
|
|
330
|
+
out.args = expanded_extra
|
|
331
|
+
out.masked_args = list(expanded_extra)
|
|
332
|
+
elif plan.source == "argparse":
|
|
333
|
+
out.args = _assemble_flags(plan, final, cwd) + expanded_extra
|
|
334
|
+
# final[f.key] is always present (the loop above wrote every field's key).
|
|
335
|
+
masked_final = {
|
|
336
|
+
f.key: ("•••" if f.secret and final[f.key] else final[f.key]) for f in plan.fields
|
|
337
|
+
}
|
|
338
|
+
out.masked_args = _assemble_flags(plan, masked_final, cwd) + expanded_extra
|
|
339
|
+
elif plan.source == "command":
|
|
340
|
+
out.command_values = final
|
|
341
|
+
# A {api_key} placeholder is a secret like any other: mask its value in the shown
|
|
342
|
+
# command line so it never reaches the scrollback / --dry-run output, while the
|
|
343
|
+
# real value still substitutes into the process that runs. final[f.key] is always
|
|
344
|
+
# present (the loop above wrote every field's key).
|
|
345
|
+
out.masked_command_values = {
|
|
346
|
+
f.key: ("•••" if f.secret and final[f.key] else final[f.key]) for f in plan.fields
|
|
347
|
+
}
|
|
348
|
+
out.args = expanded_extra
|
|
349
|
+
out.masked_args = list(expanded_extra)
|
|
350
|
+
else:
|
|
351
|
+
out.args = expanded_extra
|
|
352
|
+
out.masked_args = list(expanded_extra)
|
|
353
|
+
return out
|
|
354
|
+
|
|
355
|
+
|
|
356
|
+
def _final_value(
|
|
357
|
+
f: FormField,
|
|
358
|
+
raw: str,
|
|
359
|
+
*,
|
|
360
|
+
cwd: Path,
|
|
361
|
+
env: Mapping[str, str],
|
|
362
|
+
now: datetime | None,
|
|
363
|
+
) -> str:
|
|
364
|
+
"""One field's delivery value: secret resolution or token expansion, plus the
|
|
365
|
+
post-expansion type check that pre-submit validation had to defer."""
|
|
366
|
+
if f.secret:
|
|
367
|
+
return _resolve_secret(f, raw, env)
|
|
368
|
+
try:
|
|
369
|
+
value = tokens.expand(raw, cwd=cwd, env=env, now=now) if raw else ""
|
|
370
|
+
except tokens.TokenError as exc:
|
|
371
|
+
raise FormError(str(exc)) from exc
|
|
372
|
+
if raw and tokens.has_tokens(raw):
|
|
373
|
+
error = _type_error(f, value)
|
|
374
|
+
if error:
|
|
375
|
+
raise FormError(error)
|
|
376
|
+
return value
|
|
377
|
+
|
|
378
|
+
|
|
379
|
+
def _resolve_secret(f: FormField, raw: str, env: Mapping[str, str]) -> str:
|
|
380
|
+
"""This run's typed value wins; else the configured environment source; a configured
|
|
381
|
+
source that is unset is a named error, not an empty string."""
|
|
382
|
+
if raw:
|
|
383
|
+
return raw
|
|
384
|
+
if f.env_source:
|
|
385
|
+
if f.env_source not in env:
|
|
386
|
+
raise FormError(
|
|
387
|
+
gettext("%(name)s reads from the environment variable %(env)s, but it isn't set.")
|
|
388
|
+
% {"name": f.label, "env": f.env_source}
|
|
389
|
+
)
|
|
390
|
+
return env[f.env_source]
|
|
391
|
+
return ""
|
|
392
|
+
|
|
393
|
+
|
|
394
|
+
def _assemble_flags(plan: FormPlan, final: Mapping[str, str], cwd: Path) -> list[str]:
|
|
395
|
+
"""Positional fields in declared order, then option flags in declared order.
|
|
396
|
+
Explicit-pass rule: filled fields always travel; checkboxes only when they differ
|
|
397
|
+
from the script default; degraded/empty fields are omitted."""
|
|
398
|
+
positionals: list[str] = []
|
|
399
|
+
flags: list[str] = []
|
|
400
|
+
for f in plan.fields:
|
|
401
|
+
value = final.get(f.key, "")
|
|
402
|
+
if f.kind == "bool":
|
|
403
|
+
fired = _coerce_bool_lenient(value)
|
|
404
|
+
# A checkbox fires its flag only when it differs from the script default:
|
|
405
|
+
# store_true fires on checked, store_false fires on unchecked.
|
|
406
|
+
if (f.action == "store_true" and fired) or (f.action == "store_false" and not fired):
|
|
407
|
+
flags.append(f.flag)
|
|
408
|
+
continue
|
|
409
|
+
if not value:
|
|
410
|
+
continue # optional/degraded left empty: the script's own default applies
|
|
411
|
+
pieces = _split_multi(value, cwd) if f.multiple else [value]
|
|
412
|
+
if f.flag == "":
|
|
413
|
+
positionals.extend(pieces)
|
|
414
|
+
else:
|
|
415
|
+
flags.append(f.flag)
|
|
416
|
+
flags.extend(pieces)
|
|
417
|
+
return positionals + flags
|
|
418
|
+
|
|
419
|
+
|
|
420
|
+
def _split_multi(value: str, cwd: Path) -> list[str]:
|
|
421
|
+
"""A multi-value field holds shell-ish text: split it like a shell would, then
|
|
422
|
+
re-expand globs against the run's cwd (the TUI has no shell to do either)."""
|
|
423
|
+
try:
|
|
424
|
+
pieces = shlex.split(value)
|
|
425
|
+
except ValueError:
|
|
426
|
+
pieces = [value]
|
|
427
|
+
out: list[str] = []
|
|
428
|
+
for piece in pieces:
|
|
429
|
+
out.extend(_expand_glob_piece(piece, cwd))
|
|
430
|
+
return out
|
|
431
|
+
|
|
432
|
+
|
|
433
|
+
def _expand_glob_piece(piece: str, cwd: Path) -> list[str]:
|
|
434
|
+
if not any(ch in piece for ch in _GLOB_CHARS):
|
|
435
|
+
return [piece]
|
|
436
|
+
matches = sorted(_glob.glob(piece, root_dir=str(cwd), recursive=True))
|
|
437
|
+
return matches if matches else [piece]
|
|
438
|
+
|
|
439
|
+
|
|
440
|
+
def _coerce_bool_lenient(value: str) -> bool:
|
|
441
|
+
"""Checkbox state from its string form; anything unrecognized counts as unchecked
|
|
442
|
+
(validate() already rejected typed garbage for bool fields)."""
|
|
443
|
+
return value.strip().lower() in ("true", "1", "yes", "y", "on")
|
|
444
|
+
|
|
445
|
+
|
|
446
|
+
# --------------------------------------------------------------------------
|
|
447
|
+
# after the run
|
|
448
|
+
# --------------------------------------------------------------------------
|
|
449
|
+
|
|
450
|
+
|
|
451
|
+
def save_after_run(
|
|
452
|
+
slug: str,
|
|
453
|
+
plan: FormPlan,
|
|
454
|
+
values: Mapping[str, str],
|
|
455
|
+
extra_args: list[str],
|
|
456
|
+
exit_code: int,
|
|
457
|
+
*,
|
|
458
|
+
at: str,
|
|
459
|
+
) -> None:
|
|
460
|
+
"""Persist intent (raw token/glob text), never expansion; secrets structurally
|
|
461
|
+
stripped by argstate (C3); stamp the run for Library sorting and the r key."""
|
|
462
|
+
# Retroactive C3 scrub: a placeholder/param that is secret NOW must not keep old
|
|
463
|
+
# plaintext in values or presets from the days it wasn't (purge is idempotent).
|
|
464
|
+
if plan.secret_names:
|
|
465
|
+
argstate.purge_secret(slug, plan.secret_names)
|
|
466
|
+
argstate.save_last(
|
|
467
|
+
slug,
|
|
468
|
+
values={k: v for k, v in values.items() if v},
|
|
469
|
+
extra_args=list(extra_args),
|
|
470
|
+
secret_names=plan.secret_names,
|
|
471
|
+
)
|
|
472
|
+
argstate.record_run(slug, exit_code, at=at)
|
|
473
|
+
|
|
474
|
+
|
|
475
|
+
# --------------------------------------------------------------------------
|
|
476
|
+
# execute — the single delivery pipeline (one flow, two renderings)
|
|
477
|
+
# --------------------------------------------------------------------------
|
|
478
|
+
|
|
479
|
+
# Why a code did not launch, so each renderer can classify without re-catching the
|
|
480
|
+
# launcher/shim exception hierarchy (the CLI maps these to exit codes 125/126/127; the
|
|
481
|
+
# TUI maps them to a status line). "" means the script actually ran.
|
|
482
|
+
FAIL_BAD_VALUE = "bad_value" # a value doesn't fit its declared type (shim rejected it)
|
|
483
|
+
FAIL_DRIFT = "drift" # injection targets no longer match the definitions
|
|
484
|
+
FAIL_MISSING = "missing" # the launch target is gone from disk
|
|
485
|
+
FAIL_NOT_EXECUTABLE = "not_executable" # an exe exists but isn't +x
|
|
486
|
+
FAIL_LAUNCH = "launch" # any other launch failure
|
|
487
|
+
|
|
488
|
+
|
|
489
|
+
@dataclass
|
|
490
|
+
class RunOutcome:
|
|
491
|
+
"""The result of execute(). code is the script's own exit code, or None when the
|
|
492
|
+
script never launched (failure names why, message is user-ready and localized)."""
|
|
493
|
+
|
|
494
|
+
code: int | None
|
|
495
|
+
failure: str = ""
|
|
496
|
+
message: str = ""
|
|
497
|
+
|
|
498
|
+
@property
|
|
499
|
+
def launched(self) -> bool:
|
|
500
|
+
return self.code is not None
|
|
501
|
+
|
|
502
|
+
|
|
503
|
+
def transparency_lines(entry: Entry, asm: Assembly, injected: Path | None) -> list[str]:
|
|
504
|
+
"""The plain what-actually-runs lines (trust through transparency; also how users
|
|
505
|
+
passively learn their scripts' own flags). Secret values are already masked in
|
|
506
|
+
asm.masked_args / asm.display. The renderer applies its own styling/escaping — these
|
|
507
|
+
are semantic plain text, identical across CLI and TUI (the one place they used to
|
|
508
|
+
drift: `k = v` vs `k = 'v'`)."""
|
|
509
|
+
lines: list[str] = []
|
|
510
|
+
if asm.inject_values:
|
|
511
|
+
pairs = ", ".join(f"{k} = {v}" for k, v in asm.display)
|
|
512
|
+
lines.append(gettext("→ inject: %(pairs)s") % {"pairs": pairs})
|
|
513
|
+
lines.append(
|
|
514
|
+
gettext(
|
|
515
|
+
" (written to a temporary copy, deleted after the run; your original file is untouched)"
|
|
516
|
+
)
|
|
517
|
+
)
|
|
518
|
+
lines.append(
|
|
519
|
+
"→ "
|
|
520
|
+
+ launcher.describe_command(
|
|
521
|
+
entry, asm.masked_args, asm.masked_command_values, script_override=injected
|
|
522
|
+
)
|
|
523
|
+
)
|
|
524
|
+
return lines
|
|
525
|
+
|
|
526
|
+
|
|
527
|
+
def execute(
|
|
528
|
+
entry: Entry,
|
|
529
|
+
plan: FormPlan,
|
|
530
|
+
asm: Assembly,
|
|
531
|
+
*,
|
|
532
|
+
emit: Callable[[str], None],
|
|
533
|
+
invoke_cwd: Path | None = None,
|
|
534
|
+
) -> RunOutcome:
|
|
535
|
+
"""Deliver an assembled run: inject (if the source calls for it) -> emit the
|
|
536
|
+
transparency lines -> run straight through the terminal -> clean up the temp copy.
|
|
537
|
+
|
|
538
|
+
The single delivery pipeline both `skit run` and the TUI go through, so the two can
|
|
539
|
+
never drift again. The caller owns everything around it: prompting, the suspend
|
|
540
|
+
boundary (the TUI must call this inside App.suspend()), the run banner, and mapping
|
|
541
|
+
the outcome to an exit code or a status line. emit receives already-localized plain
|
|
542
|
+
lines; the renderer styles them (dim console print / bare print).
|
|
543
|
+
"""
|
|
544
|
+
injected: Path | None = None
|
|
545
|
+
try:
|
|
546
|
+
if plan.source == "inject" and asm.inject_values:
|
|
547
|
+
try:
|
|
548
|
+
# entry.dir is write_injected's fallback directory (used only when the OS
|
|
549
|
+
# temp dir isn't writable); test_execute_inject_falls_back_to_entry_dir
|
|
550
|
+
# pins that this run passes it through.
|
|
551
|
+
injected = shim.write_injected(
|
|
552
|
+
entry.dir, shim.inject(plan.text, plan.specs, asm.inject_values)
|
|
553
|
+
)
|
|
554
|
+
except ShimValueError as exc:
|
|
555
|
+
return RunOutcome(
|
|
556
|
+
None,
|
|
557
|
+
FAIL_BAD_VALUE,
|
|
558
|
+
gettext("%(value)s isn't a valid %(type)s for %(param)s.")
|
|
559
|
+
% {
|
|
560
|
+
"value": repr(exc.value),
|
|
561
|
+
"type": exc.type_name,
|
|
562
|
+
"param": exc.param_name,
|
|
563
|
+
},
|
|
564
|
+
)
|
|
565
|
+
except shim.ShimError as exc:
|
|
566
|
+
return RunOutcome(
|
|
567
|
+
None,
|
|
568
|
+
FAIL_DRIFT,
|
|
569
|
+
gettext(
|
|
570
|
+
"The script and its form definitions don't match anymore: %(detail)s. "
|
|
571
|
+
"Run `skit params %(name)s --resync` to fix it."
|
|
572
|
+
)
|
|
573
|
+
% {"name": entry.meta.name, "detail": str(exc)},
|
|
574
|
+
)
|
|
575
|
+
for line in transparency_lines(entry, asm, injected):
|
|
576
|
+
emit(line)
|
|
577
|
+
try:
|
|
578
|
+
code = launcher.run_entry(
|
|
579
|
+
entry,
|
|
580
|
+
asm.args,
|
|
581
|
+
values=asm.command_values,
|
|
582
|
+
invoke_cwd=invoke_cwd,
|
|
583
|
+
script_override=injected,
|
|
584
|
+
)
|
|
585
|
+
except launcher.TargetMissingError as exc:
|
|
586
|
+
return RunOutcome(None, FAIL_MISSING, str(exc))
|
|
587
|
+
except launcher.NotExecutableError as exc:
|
|
588
|
+
return RunOutcome(None, FAIL_NOT_EXECUTABLE, str(exc))
|
|
589
|
+
except launcher.LaunchError as exc:
|
|
590
|
+
return RunOutcome(None, FAIL_LAUNCH, str(exc))
|
|
591
|
+
return RunOutcome(code)
|
|
592
|
+
finally:
|
|
593
|
+
if injected is not None and injected.exists():
|
|
594
|
+
# missing_ok is redundant: exists() already gated this, and we created the
|
|
595
|
+
# file ourselves, so True/False/None all unlink it identically here.
|
|
596
|
+
injected.unlink(missing_ok=True) # pragma: no mutate
|