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/argspec.py ADDED
@@ -0,0 +1,528 @@
1
+ """Static argparse reader: turn literal add_argument(...) calls into form fields.
2
+
3
+ The unified-form model's third source (UX spec: "any script becomes a pokeable form"):
4
+ scripts that parse their own CLI don't get injection — skit reads their argument
5
+ declarations statically and renders the same form, then assembles real flags.
6
+
7
+ Honesty rules (mirrors the analyzer's A4/C4 stance — never execute the user's script):
8
+ - Only LITERAL arguments to add_argument are trusted. A field whose type/choices/default
9
+ can't be read statically degrades to a free-text field that is omitted when left empty
10
+ (the script's own default then applies).
11
+ - A parser that can't be modeled at all — add_subparsers, add_argument inside a loop —
12
+ degrades the whole spec: the form keeps only the passthrough-args escape field, and the
13
+ UI says so instead of pretending.
14
+
15
+ Headless, stdlib-only.
16
+ """
17
+
18
+ from __future__ import annotations
19
+
20
+ import ast
21
+ from dataclasses import dataclass, field
22
+
23
+ from .analyzer import _is_secret_name, _literal_value
24
+
25
+ # Actions that add no form field at all (argparse handles them internally).
26
+ _NON_FIELD_ACTIONS = ("help", "version")
27
+ # Actions we model as a checkbox.
28
+ _BOOL_ACTIONS = ("store_true", "store_false")
29
+
30
+
31
+ @dataclass
32
+ class ArgField:
33
+ """One add_argument call, as a form field."""
34
+
35
+ dest: str # display/name key (flag name without dashes, or the positional name)
36
+ flag: str = "" # "--output" (longest declared flag); "" for a positional
37
+ required: bool = False
38
+ kind: str = "str" # "str" | "int" | "float" | "bool" | "choice"
39
+ choices: list[str] = field(default_factory=list)
40
+ default: str | int | float | bool | None = None
41
+ help: str = ""
42
+ multiple: bool = False # nargs "+" / "*"
43
+ degraded: bool = False # free-text fallback; omit from the command when left empty
44
+ secret: bool = False
45
+ action: str = "" # "store_true" / "store_false" when kind == "bool"
46
+ order: int = 0
47
+
48
+
49
+ @dataclass
50
+ class ArgSpec:
51
+ fields: list[ArgField] = field(default_factory=list)
52
+ ok: bool = True # False -> whole-parser degradation (passthrough escape only)
53
+ reason: str = "" # symbolic: "subparsers" | "dynamic" (UI owns the wording)
54
+
55
+
56
+ def read_cli(text: str) -> ArgSpec | None:
57
+ """The unified entry point: the first CLI surface that reads statically wins.
58
+ argparse first (the overwhelmingly common case in AI-written scripts), then click,
59
+ then typer. None when the script has no readable CLI surface at all."""
60
+ try:
61
+ tree = ast.parse(text)
62
+ except SyntaxError:
63
+ return None
64
+ for reader in (read_argparse, _read_click, _read_typer):
65
+ spec = reader(text) if reader is read_argparse else reader(tree)
66
+ if spec is not None:
67
+ return spec
68
+ return None
69
+
70
+
71
+ def read_argparse(text: str) -> ArgSpec | None:
72
+ """Read the script's argparse surface. None when there's nothing argparse-shaped
73
+ (no add_argument calls at all) — callers then fall back to the other form sources."""
74
+ try:
75
+ tree = ast.parse(text)
76
+ except SyntaxError:
77
+ return None
78
+ calls = [
79
+ node
80
+ for node in ast.walk(tree)
81
+ if isinstance(node, ast.Call)
82
+ and isinstance(node.func, ast.Attribute)
83
+ and node.func.attr == "add_argument"
84
+ ]
85
+ if not calls:
86
+ return None
87
+ if any(
88
+ isinstance(node, ast.Call)
89
+ and isinstance(node.func, ast.Attribute)
90
+ and node.func.attr == "add_subparsers"
91
+ for node in ast.walk(tree)
92
+ ):
93
+ return ArgSpec(ok=False, reason="subparsers")
94
+ if _any_call_inside_loop(tree):
95
+ return ArgSpec(ok=False, reason="dynamic")
96
+ fields: list[ArgField] = []
97
+ for i, call in enumerate(sorted(calls, key=lambda c: (c.lineno, c.col_offset))):
98
+ f = _read_call(call, order=i)
99
+ if f is not None:
100
+ fields.append(f)
101
+ return ArgSpec(fields=fields)
102
+
103
+
104
+ def _any_call_inside_loop(tree: ast.Module) -> bool:
105
+ """add_argument under a for/while: the argument list is data-driven; we can't model it."""
106
+ for node in ast.walk(tree):
107
+ if isinstance(node, (ast.For, ast.While)):
108
+ for sub in ast.walk(node):
109
+ if (
110
+ isinstance(sub, ast.Call)
111
+ and isinstance(sub.func, ast.Attribute)
112
+ and sub.func.attr == "add_argument"
113
+ ):
114
+ return True
115
+ return False
116
+
117
+
118
+ def _read_call(call: ast.Call, order: int) -> ArgField | None:
119
+ """One add_argument call -> ArgField, or None for non-field actions (--help/--version)."""
120
+ names = [a.value for a in call.args if isinstance(a, ast.Constant) and isinstance(a.value, str)]
121
+ if not names or len(names) != len(call.args):
122
+ # A non-literal name (or none at all): we can't even label the field — skip it and
123
+ # let the passthrough escape field carry it.
124
+ return None
125
+ kwargs = {kw.arg: kw.value for kw in call.keywords if kw.arg}
126
+ action = _literal_str(kwargs.get("action"))
127
+ if action in _NON_FIELD_ACTIONS:
128
+ return None
129
+ positional = not names[0].startswith("-")
130
+ long_flags = [n for n in names if n.startswith("--")]
131
+ flag = long_flags[0] if long_flags else ("" if positional else names[0])
132
+ dest = _literal_str(kwargs.get("dest")) or (
133
+ names[0] if positional else flag.lstrip("-").replace("-", "_")
134
+ )
135
+ nargs = _literal_value(kwargs["nargs"])[1] if "nargs" in kwargs else None
136
+ multiple = nargs in ("+", "*")
137
+ if positional:
138
+ required = nargs not in ("*", "?")
139
+ else:
140
+ req_node = kwargs.get("required")
141
+ required = isinstance(req_node, ast.Constant) and req_node.value is True
142
+
143
+ f = ArgField(
144
+ dest=dest,
145
+ flag=flag,
146
+ required=required,
147
+ help=_literal_str(kwargs.get("help")),
148
+ multiple=multiple,
149
+ secret=_is_secret_name(dest),
150
+ order=order,
151
+ )
152
+ if action in _BOOL_ACTIONS:
153
+ f.kind = "bool"
154
+ f.action = action
155
+ f.default = action == "store_false" # store_false means "on unless flagged"
156
+ elif action:
157
+ # append / count / extend / custom Action classes: real but unmodelable — degrade.
158
+ f.degraded = True
159
+ else:
160
+ _apply_value_kwargs(f, kwargs)
161
+ return f
162
+
163
+
164
+ def _apply_value_kwargs(f: ArgField, kwargs: dict[str, ast.expr]) -> None:
165
+ """Fill kind/choices/default from literal kwargs; degrade the field on anything opaque."""
166
+ if "choices" in kwargs:
167
+ choices = _literal_str_list(kwargs["choices"])
168
+ if choices is None:
169
+ f.degraded = True
170
+ return
171
+ f.kind = "choice"
172
+ f.choices = choices
173
+ if "type" in kwargs and not _apply_type(f, kwargs["type"]):
174
+ # A conversion function we can't run (e.g. parse_color): free-text fallback.
175
+ f.degraded = True
176
+ return
177
+ if "default" in kwargs:
178
+ ok, value = _literal_value(kwargs["default"])
179
+ if ok:
180
+ f.default = value
181
+ elif not (isinstance(kwargs["default"], ast.Constant) and kwargs["default"].value is None):
182
+ # A computed default (tuple, call, attribute): show the field, but leave it
183
+ # empty and omit the flag when untouched so the script's own default applies.
184
+ f.degraded = True
185
+
186
+
187
+ def _apply_type(f: ArgField, node: ast.expr) -> bool:
188
+ """Apply a literal type= kwarg. True when the type is form-representable."""
189
+ if isinstance(node, ast.Name) and node.id in ("int", "float", "str"):
190
+ if f.kind != "choice": # choices win: the selector already constrains input
191
+ f.kind = node.id
192
+ return True
193
+ # Path renders as text; anything else is an arbitrary callable we won't execute.
194
+ return isinstance(node, ast.Name) and node.id == "Path"
195
+
196
+
197
+ # --------------------------------------------------------------------------
198
+ # click
199
+ # --------------------------------------------------------------------------
200
+
201
+
202
+ def _imports_module(tree: ast.Module, root: str) -> bool:
203
+ """Whether the script imports `root` in any form (import x / import x.y / from x[.y]
204
+ import z). The root comparison is dot-split so `import click.testing` counts."""
205
+ for n in ast.walk(tree):
206
+ if isinstance(n, ast.Import) and any(a.name.split(".")[0] == root for a in n.names):
207
+ return True
208
+ if (
209
+ isinstance(n, ast.ImportFrom)
210
+ and n.module is not None
211
+ and n.module.split(".")[0] == root
212
+ ):
213
+ return True
214
+ return False
215
+
216
+
217
+ def _decorator_call(node: ast.expr) -> ast.Call | None:
218
+ return node if isinstance(node, ast.Call) else None
219
+
220
+
221
+ def _decorator_name(node: ast.expr) -> str:
222
+ """The trailing attribute/name of a decorator callable: click.option -> "option"."""
223
+ target = node.func if isinstance(node, ast.Call) else node
224
+ if isinstance(target, ast.Attribute):
225
+ return target.attr
226
+ if isinstance(target, ast.Name):
227
+ return target.id
228
+ return ""
229
+
230
+
231
+ def _read_click(tree: ast.Module) -> ArgSpec | None:
232
+ """Static click reading: @click.option/@click.argument stacks on a command function.
233
+
234
+ click applies decorators bottom-up, so the BOTTOM decorator declares the first
235
+ parameter — fields are collected in reversed decorator order to match runtime.
236
+ A @click.group (or several commands) is subcommand territory: whole-spec degrade."""
237
+ if not _imports_module(tree, "click"):
238
+ return None # a bare @app.command() without click is somebody else's (typer's) surface
239
+ decorated = [
240
+ (node, [_decorator_name(d) for d in node.decorator_list])
241
+ for node in ast.walk(tree)
242
+ if isinstance(node, ast.FunctionDef)
243
+ ]
244
+ has_group = any("group" in names for _fn, names in decorated)
245
+ commands = [fn for fn, names in decorated if "command" in names]
246
+ if not commands and not has_group:
247
+ return None
248
+ if has_group or len(commands) > 1:
249
+ return ArgSpec(ok=False, reason="subparsers")
250
+ fields: list[ArgField] = []
251
+ order = 0
252
+ for deco in reversed(commands[0].decorator_list):
253
+ call = _decorator_call(deco)
254
+ if call is None:
255
+ continue
256
+ kind = _decorator_name(deco)
257
+ if kind not in ("option", "argument"):
258
+ continue
259
+ f = _read_click_param(call, positional=(kind == "argument"), order=order)
260
+ if f is not None:
261
+ fields.append(f)
262
+ order += 1
263
+ return ArgSpec(fields=fields)
264
+
265
+
266
+ def _is_true_kwarg(node: ast.expr | None) -> bool:
267
+ return isinstance(node, ast.Constant) and node.value is True
268
+
269
+
270
+ def _read_click_param(call: ast.Call, positional: bool, order: int) -> ArgField | None:
271
+ names = [a.value for a in call.args if isinstance(a, ast.Constant) and isinstance(a.value, str)]
272
+ if not names or len(names) != len(call.args):
273
+ return None
274
+ kwargs = {kw.arg: kw.value for kw in call.keywords if kw.arg}
275
+ long_flags = [n for n in names if n.startswith("--")]
276
+ flag = "" if positional else (long_flags[0] if long_flags else names[0])
277
+ dest = names[0] if positional else (flag.lstrip("-").replace("-", "_"))
278
+ nargs = _literal_value(kwargs["nargs"])[1] if "nargs" in kwargs else None
279
+ f = ArgField(
280
+ dest=dest,
281
+ flag=flag,
282
+ # click arguments are required by default; nargs=-1 (variadic) is not.
283
+ required=(positional and nargs != -1) or _is_true_kwarg(kwargs.get("required")),
284
+ help=_literal_str(kwargs.get("help")),
285
+ multiple=nargs == -1 or _is_true_kwarg(kwargs.get("multiple")),
286
+ secret=_is_secret_name(dest),
287
+ order=order,
288
+ )
289
+ is_flag = kwargs.get("is_flag")
290
+ if isinstance(is_flag, ast.Constant) and is_flag.value is True:
291
+ if _is_true_kwarg(kwargs.get("default")):
292
+ # An is_flag that DEFAULTS to on needs the --flag/--no-flag pairing we
293
+ # can't assemble faithfully — degrade honestly (typer's rule, mirrored).
294
+ f.degraded = True
295
+ return f
296
+ f.kind = "bool"
297
+ f.action = "store_true"
298
+ f.default = False
299
+ return f
300
+ type_node = kwargs.get("type")
301
+ if type_node is not None and not _apply_click_type(f, type_node):
302
+ f.degraded = True
303
+ return f
304
+ if "default" in kwargs:
305
+ ok, value = _literal_value(kwargs["default"])
306
+ if ok:
307
+ f.default = value
308
+ elif not (isinstance(kwargs["default"], ast.Constant) and kwargs["default"].value is None):
309
+ f.degraded = True
310
+ if "count" in kwargs:
311
+ f.degraded = True
312
+ return f
313
+
314
+
315
+ def _apply_click_type(f: ArgField, node: ast.expr) -> bool:
316
+ """click type=: bare int/float/str, click.INT/FLOAT/STRING, click.Choice([...])."""
317
+ if isinstance(node, ast.Name) and node.id in ("int", "float", "str"):
318
+ f.kind = node.id
319
+ return True
320
+ if isinstance(node, ast.Attribute) and node.attr in ("INT", "FLOAT", "STRING"):
321
+ f.kind = {"INT": "int", "FLOAT": "float", "STRING": "str"}[node.attr]
322
+ return True
323
+ if isinstance(node, ast.Call) and _decorator_name(node) == "Choice" and node.args:
324
+ choices = _literal_str_list(node.args[0])
325
+ if choices is None:
326
+ return False
327
+ f.kind = "choice"
328
+ f.choices = choices
329
+ return True
330
+ return False
331
+
332
+
333
+ # --------------------------------------------------------------------------
334
+ # typer
335
+ # --------------------------------------------------------------------------
336
+
337
+ _ANNOTATION_KINDS = {"int": "int", "float": "float", "str": "str", "bool": "bool", "Path": "str"}
338
+
339
+
340
+ def _read_typer(tree: ast.Module) -> ArgSpec | None:
341
+ """Static typer reading: the command function's signature IS the CLI surface.
342
+
343
+ Finds @<app>.command() functions or the function handed to typer.run(); more than
344
+ one command means subcommands (whole-spec degrade). Parameters map by annotation
345
+ (int/float/str/bool/Path) with typer.Option/typer.Argument defaults; both the legacy
346
+ `x: int = typer.Option(...)` form and the modern `x: Annotated[int, typer.Option(...)]`
347
+ form (what AI-written typer scripts overwhelmingly use) are read. A bool that defaults
348
+ to True would need paired --x/--no-x flags we can't assemble faithfully, so it degrades
349
+ instead of guessing."""
350
+ if not _imports_module(tree, "typer"):
351
+ return None
352
+ commands = [
353
+ node
354
+ for node in ast.walk(tree)
355
+ if isinstance(node, ast.FunctionDef)
356
+ and any(_decorator_name(d) == "command" for d in node.decorator_list)
357
+ ]
358
+ if not commands:
359
+ run_targets = {
360
+ node.args[0].id
361
+ for node in ast.walk(tree)
362
+ if isinstance(node, ast.Call)
363
+ and isinstance(node.func, ast.Attribute)
364
+ and node.func.attr == "run"
365
+ and isinstance(node.func.value, ast.Name)
366
+ and node.func.value.id == "typer"
367
+ and node.args
368
+ and isinstance(node.args[0], ast.Name)
369
+ }
370
+ commands = [
371
+ node
372
+ for node in ast.walk(tree)
373
+ if isinstance(node, ast.FunctionDef) and node.name in run_targets
374
+ ]
375
+ if not commands:
376
+ return None
377
+ if len(commands) > 1:
378
+ return ArgSpec(ok=False, reason="subparsers")
379
+ fn = commands[0]
380
+ args = fn.args.args + fn.args.kwonlyargs
381
+ defaults: list[ast.expr | None] = list(fn.args.defaults) + list(fn.args.kw_defaults)
382
+ # Positional-args defaults align to the TAIL of fn.args.args.
383
+ pad = len(fn.args.args) - len(fn.args.defaults)
384
+ aligned: list[ast.expr | None] = [None] * pad + defaults
385
+ fields: list[ArgField] = []
386
+ # len(aligned) == len(args) by construction: pad + positional defaults covers
387
+ # args.args, and kw_defaults is exactly one entry per kwonly arg (None when absent).
388
+ for i, arg in enumerate(args):
389
+ fields.append(_read_typer_param(arg, aligned[i], order=i))
390
+ return ArgSpec(fields=fields)
391
+
392
+
393
+ def _annotated_parts(annotation: ast.expr | None) -> tuple[ast.expr | None, ast.Call | None]:
394
+ """Unwrap `Annotated[T, typer.Option/Argument(...), ...]` (the modern typer style,
395
+ and what AI-generated typer scripts overwhelmingly use). Returns (T, the typer
396
+ metadata call or None). A non-Annotated annotation passes straight through as
397
+ (annotation, None), so the legacy `x: int = typer.Option(...)` path is untouched."""
398
+ if not (
399
+ isinstance(annotation, ast.Subscript)
400
+ and (
401
+ (isinstance(annotation.value, ast.Name) and annotation.value.id == "Annotated")
402
+ or (
403
+ isinstance(annotation.value, ast.Attribute) and annotation.value.attr == "Annotated"
404
+ )
405
+ )
406
+ ):
407
+ return annotation, None
408
+ elts = annotation.slice.elts if isinstance(annotation.slice, ast.Tuple) else [annotation.slice]
409
+ base = elts[0] if elts else None
410
+ meta = next(
411
+ (
412
+ e
413
+ for e in elts[1:]
414
+ if isinstance(e, ast.Call) and _decorator_name(e) in ("Option", "Argument")
415
+ ),
416
+ None,
417
+ )
418
+ return base, meta
419
+
420
+
421
+ def _read_typer_param(arg: ast.arg, default: ast.expr | None, order: int) -> ArgField:
422
+ name = arg.arg
423
+ annotation, annotated_meta = _annotated_parts(arg.annotation)
424
+ looked_up = _ANNOTATION_KINDS.get(annotation.id) if isinstance(annotation, ast.Name) else None
425
+ f = ArgField(
426
+ dest=name,
427
+ flag=f"--{name.replace('_', '-')}",
428
+ kind=looked_up if looked_up is not None else "str",
429
+ secret=_is_secret_name(name),
430
+ order=order,
431
+ # An annotation we can't model (Enum, Optional[...], List[...]) degrades the
432
+ # field; NO annotation at all is fine — typer treats it as str, and so do we.
433
+ degraded=annotation is not None and looked_up is None,
434
+ )
435
+ if annotated_meta is not None:
436
+ # Annotated style: the Option/Argument metadata lives in the annotation, and the
437
+ # default (if any) is the parameter's own `= value`.
438
+ # pragma: has_positional_default=False vs None is equivalent (both falsy in every
439
+ # branch of _apply_typer_meta); the killable True variant is behaviorally pinned by
440
+ # test_annotated_option_positional_decl_is_a_flag_not_a_default.
441
+ _apply_typer_meta(f, annotated_meta, has_positional_default=False) # pragma: no mutate
442
+ _apply_typer_signature_default(f, default)
443
+ return _typer_finish_bool(f)
444
+ if default is None:
445
+ # No default: a positional, required argument (typer's own rule).
446
+ f.flag = ""
447
+ f.required = True
448
+ return _typer_finish_bool(f)
449
+ if isinstance(default, ast.Call) and _decorator_name(default) in ("Option", "Argument"):
450
+ # Legacy style: the Option/Argument call IS the parameter default, and its first
451
+ # positional is the value default (ellipsis = required).
452
+ _apply_typer_meta(f, default, has_positional_default=True)
453
+ return _typer_finish_bool(f)
454
+ _apply_typer_signature_default(f, default)
455
+ return _typer_finish_bool(f)
456
+
457
+
458
+ def _apply_typer_meta(f: ArgField, call: ast.Call, *, has_positional_default: bool) -> None:
459
+ """Read flag/help (and, in the legacy style, the value default) from a typer
460
+ Option/Argument call. In the Annotated style the call carries no positional default,
461
+ so every string positional is a flag declaration."""
462
+ kwargs = {kw.arg: kw.value for kw in call.keywords if kw.arg}
463
+ decl_args = call.args[1:] if has_positional_default else call.args
464
+ decls = [a.value for a in decl_args if isinstance(a, ast.Constant) and isinstance(a.value, str)]
465
+ long_flags = [d for d in decls if d.startswith("--")]
466
+ if _decorator_name(call) == "Argument":
467
+ f.flag = ""
468
+ elif long_flags:
469
+ f.flag = long_flags[0]
470
+ f.help = _literal_str(kwargs.get("help"))
471
+ if has_positional_default and call.args:
472
+ first = call.args[0]
473
+ if isinstance(first, ast.Constant) and first.value is ...:
474
+ f.required = True
475
+ else:
476
+ ok, value = _literal_value(first)
477
+ if ok:
478
+ f.default = value
479
+ elif not (isinstance(first, ast.Constant) and first.value is None):
480
+ f.degraded = True
481
+
482
+
483
+ def _apply_typer_signature_default(f: ArgField, default: ast.expr | None) -> None:
484
+ """Apply the parameter's own `= value` default (the Annotated style keeps it there,
485
+ and a bare `x: int = 5` too). None means required."""
486
+ if default is None:
487
+ f.required = True
488
+ return
489
+ ok, value = _literal_value(default)
490
+ if ok:
491
+ f.default = value
492
+ else:
493
+ f.degraded = True
494
+
495
+
496
+ def _typer_finish_bool(f: ArgField) -> ArgField:
497
+ """typer bools become --x/--no-x pairs; only the default-False case assembles as a
498
+ plain store_true flag. Default-True (or required) would need the --no-x spelling —
499
+ degrade rather than emit a flag that means the opposite."""
500
+ if f.kind != "bool":
501
+ return f
502
+ if f.default in (None, False) and not f.required:
503
+ f.action = "store_true"
504
+ f.default = False
505
+ return f
506
+ f.degraded = True
507
+ f.kind = "str"
508
+ return f
509
+
510
+
511
+ def _literal_str(node: ast.expr | None) -> str:
512
+ if isinstance(node, ast.Constant) and isinstance(node.value, str):
513
+ return node.value
514
+ return ""
515
+
516
+
517
+ def _literal_str_list(node: ast.expr) -> list[str] | None:
518
+ """A literal list/tuple of scalars, rendered as strings (choices are typed at the form
519
+ edge anyway); None when any element isn't a literal."""
520
+ if not isinstance(node, (ast.List, ast.Tuple)):
521
+ return None
522
+ out: list[str] = []
523
+ for elt in node.elts:
524
+ ok, value = _literal_value(elt)
525
+ if not ok:
526
+ return None
527
+ out.append(str(value))
528
+ return out
skit/argstate.py ADDED
@@ -0,0 +1,166 @@
1
+ """Persistence of parameter values (state layer, separate from the data layer).
2
+
3
+ - Stored at state_dir()/values/<slug>.toml, removed together with the script.
4
+ - File shape: [values] (last-used), extra_args, [presets.<name>] (named presets).
5
+ - **C3 is enforced structurally here**: every write entry point requires secret_names, and any key
6
+ in that list is stripped before it hits disk, so a secret value can never appear in a state file
7
+ (there are tests for this). This holds for *new* writes; it says nothing about a value that was
8
+ written while the parameter was still public. purge_secret() retroactively scrubs that plaintext
9
+ once a parameter transitions to secret, and save_last() also drops any now-secret key left over
10
+ from before, even on calls that carry no new value for it — so nothing written while a parameter
11
+ was public can outlive it becoming secret.
12
+ - Value resolution (this run's input > preset > last-used > definition default) lives in
13
+ flows.prefill; this module only stores and strips.
14
+ """
15
+
16
+ from __future__ import annotations
17
+
18
+ import contextlib
19
+ import tomllib
20
+ from collections.abc import Iterable
21
+ from typing import Any
22
+
23
+ from .atomic import atomic_write_toml
24
+ from .paths import values_dir
25
+
26
+
27
+ def _load_doc(slug: str) -> dict[str, Any]:
28
+ path = values_dir() / f"{slug}.toml"
29
+ if not path.exists():
30
+ return {}
31
+ try:
32
+ with open(path, "rb") as f:
33
+ return tomllib.load(f)
34
+ except (OSError, tomllib.TOMLDecodeError):
35
+ return {}
36
+
37
+
38
+ def _save_doc(slug: str, doc: dict[str, Any]) -> None:
39
+ doc = {k: v for k, v in doc.items() if v} # don't persist empty sections
40
+ atomic_write_toml(values_dir() / f"{slug}.toml", doc)
41
+
42
+
43
+ def _strip_secrets(values: dict[str, str], secret_names: Iterable[str]) -> dict[str, str]:
44
+ banned = set(secret_names)
45
+ return {k: v for k, v in values.items() if k not in banned}
46
+
47
+
48
+ def load_state(slug: str) -> dict[str, Any]:
49
+ """Return {"values": {…}, "extra_args": […], "presets": {name: {…}}, "last_run": {…}}.
50
+
51
+ last_run is {"at": ISO-8601 str, "exit": int} after the first recorded run, else {}.
52
+ """
53
+ doc = _load_doc(slug)
54
+ return {
55
+ "values": dict(doc.get("values", {})),
56
+ "extra_args": list(doc.get("extra_args", [])),
57
+ "presets": {k: dict(v) for k, v in doc.get("presets", {}).items()},
58
+ "last_run": dict(doc.get("last_run", {})),
59
+ }
60
+
61
+
62
+ def save_last(
63
+ slug: str,
64
+ *,
65
+ values: dict[str, str] | None = None,
66
+ extra_args: list[str] | None = None,
67
+ secret_names: Iterable[str] = (),
68
+ ) -> None:
69
+ """Remember last-used (read-modify-write, keeping presets). Secret keys are stripped (C3).
70
+
71
+ None means "no new data — leave the stored value alone"; an EMPTY dict/list means
72
+ "the user cleared it" and erases the stored value. (Folding those two into one falsy
73
+ check made cleared extra args resurrect forever: the form saved nothing, the next
74
+ run re-read the old value, reused it, and wrote it back.)
75
+
76
+ Even on a call that carries no new values, any name in secret_names is dropped from
77
+ the previously-stored values — a value saved while a parameter was public must not
78
+ survive on disk after it becomes secret.
79
+ """
80
+ doc = _load_doc(slug)
81
+ banned = set(secret_names)
82
+ if values is not None:
83
+ doc["values"] = _strip_secrets(values, banned)
84
+ elif banned:
85
+ doc["values"] = _strip_secrets(doc.get("values", {}), banned)
86
+ if extra_args is not None:
87
+ doc["extra_args"] = extra_args
88
+ _save_doc(slug, doc)
89
+
90
+
91
+ def save_preset(
92
+ slug: str,
93
+ preset: str,
94
+ values: dict[str, str],
95
+ *,
96
+ secret_names: Iterable[str] = (),
97
+ ) -> None:
98
+ """Save one named preset. Secret keys are stripped (C3)."""
99
+ doc = _load_doc(slug)
100
+ presets = dict(doc.get("presets", {}))
101
+ presets[preset] = _strip_secrets(values, secret_names)
102
+ doc["presets"] = presets
103
+ _save_doc(slug, doc)
104
+
105
+
106
+ def delete_preset(slug: str, preset: str) -> bool:
107
+ doc = _load_doc(slug)
108
+ presets = dict(doc.get("presets", {}))
109
+ if preset not in presets:
110
+ return False
111
+ del presets[preset]
112
+ doc["presets"] = presets
113
+ _save_doc(slug, doc)
114
+ return True
115
+
116
+
117
+ def purge_secret(slug: str, names: Iterable[str]) -> set[str]:
118
+ """Retroactively scrub plaintext for parameters that have just become secret.
119
+
120
+ C3 (see module docstring) only stops *new* writes; a value stored while a parameter was still
121
+ public stays on disk until something removes it. Call this once, at the moment a parameter
122
+ transitions to secret, to purge that name from last-used [values] and from every
123
+ [presets.*] entry for this slug.
124
+
125
+ Returns the subset of names that actually had a stored value removed (from either [values] or
126
+ any preset), so callers can tell the user what was cleaned up. Passing an empty names is a
127
+ no-op that touches nothing on disk.
128
+ """
129
+ banned = set(names)
130
+ if not banned:
131
+ return set()
132
+ doc = _load_doc(slug)
133
+ removed: set[str] = set()
134
+
135
+ values = dict(doc.get("values", {}))
136
+ removed |= banned & values.keys()
137
+ doc["values"] = _strip_secrets(values, banned)
138
+
139
+ presets = dict(doc.get("presets", {}))
140
+ new_presets: dict[str, dict[str, str]] = {}
141
+ for name, preset_values in presets.items():
142
+ removed |= banned & preset_values.keys()
143
+ cleaned = _strip_secrets(preset_values, banned)
144
+ # Drop a preset that held only the now-secret param, mirroring delete_preset — an empty
145
+ # [presets.<name>] table would otherwise linger and still validate for `run --preset`.
146
+ if cleaned:
147
+ new_presets[name] = cleaned
148
+ doc["presets"] = new_presets
149
+
150
+ _save_doc(slug, doc)
151
+ return removed
152
+
153
+
154
+ def record_run(slug: str, exit_code: int, *, at: str) -> None:
155
+ """Remember when the entry last ran and how it exited (Library sort order, detail pane,
156
+ and the r-rerun context key all read this). Stored as a table — a bare `last_exit = 0`
157
+ top-level key would be dropped by _save_doc's empty-section pruning (0 is falsy)."""
158
+ doc = _load_doc(slug)
159
+ doc["last_run"] = {"at": at, "exit": exit_code}
160
+ _save_doc(slug, doc)
161
+
162
+
163
+ def forget(slug: str) -> None:
164
+ path = values_dir() / f"{slug}.toml"
165
+ with contextlib.suppress(FileNotFoundError):
166
+ path.unlink()