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