runspec 0.75.2__tar.gz → 0.76.0__tar.gz

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.
Files changed (84) hide show
  1. {runspec-0.75.2 → runspec-0.76.0}/PKG-INFO +1 -1
  2. {runspec-0.75.2 → runspec-0.76.0}/pyproject.toml +1 -1
  3. {runspec-0.75.2 → runspec-0.76.0}/runspec/ansiblegen.py +3 -0
  4. {runspec-0.75.2 → runspec-0.76.0}/runspec/inbox.py +2 -2
  5. {runspec-0.75.2 → runspec-0.76.0}/runspec/logging_setup.py +6 -0
  6. {runspec-0.75.2 → runspec-0.76.0}/runspec/models.py +1 -1
  7. {runspec-0.75.2 → runspec-0.76.0}/runspec/parser.py +9 -1
  8. {runspec-0.75.2 → runspec-0.76.0}/runspec/rundeckgen.py +15 -4
  9. {runspec-0.75.2 → runspec-0.76.0}/runspec/runexec.py +11 -5
  10. {runspec-0.75.2 → runspec-0.76.0}/runspec/runsdb.py +10 -3
  11. {runspec-0.75.2 → runspec-0.76.0}/runspec/serve.py +1 -1
  12. {runspec-0.75.2 → runspec-0.76.0}/runspec.egg-info/PKG-INFO +1 -1
  13. {runspec-0.75.2 → runspec-0.76.0}/tests/test_ansiblegen.py +1 -0
  14. {runspec-0.75.2 → runspec-0.76.0}/tests/test_exec.py +59 -0
  15. {runspec-0.75.2 → runspec-0.76.0}/tests/test_inbox.py +5 -0
  16. {runspec-0.75.2 → runspec-0.76.0}/tests/test_models.py +1 -1
  17. {runspec-0.75.2 → runspec-0.76.0}/tests/test_parser.py +2 -2
  18. {runspec-0.75.2 → runspec-0.76.0}/tests/test_run_summary.py +46 -0
  19. {runspec-0.75.2 → runspec-0.76.0}/tests/test_rundeckgen.py +24 -11
  20. {runspec-0.75.2 → runspec-0.76.0}/tests/test_runsdb.py +3 -1
  21. {runspec-0.75.2 → runspec-0.76.0}/tests/test_serve.py +1 -0
  22. {runspec-0.75.2 → runspec-0.76.0}/tests/test_testing.py +34 -34
  23. {runspec-0.75.2 → runspec-0.76.0}/runspec/__init__.py +0 -0
  24. {runspec-0.75.2 → runspec-0.76.0}/runspec/auditlog.py +0 -0
  25. {runspec-0.75.2 → runspec-0.76.0}/runspec/become.py +0 -0
  26. {runspec-0.75.2 → runspec-0.76.0}/runspec/cli.py +0 -0
  27. {runspec-0.75.2 → runspec-0.76.0}/runspec/denylist.py +0 -0
  28. {runspec-0.75.2 → runspec-0.76.0}/runspec/discovery.py +0 -0
  29. {runspec-0.75.2 → runspec-0.76.0}/runspec/emitutil.py +0 -0
  30. {runspec-0.75.2 → runspec-0.76.0}/runspec/env.py +0 -0
  31. {runspec-0.75.2 → runspec-0.76.0}/runspec/errors.py +0 -0
  32. {runspec-0.75.2 → runspec-0.76.0}/runspec/execute.py +0 -0
  33. {runspec-0.75.2 → runspec-0.76.0}/runspec/finder.py +0 -0
  34. {runspec-0.75.2 → runspec-0.76.0}/runspec/inference.py +0 -0
  35. {runspec-0.75.2 → runspec-0.76.0}/runspec/jump.py +0 -0
  36. {runspec-0.75.2 → runspec-0.76.0}/runspec/loader.py +0 -0
  37. {runspec-0.75.2 → runspec-0.76.0}/runspec/logs.py +0 -0
  38. {runspec-0.75.2 → runspec-0.76.0}/runspec/mock.py +0 -0
  39. {runspec-0.75.2 → runspec-0.76.0}/runspec/py.typed +0 -0
  40. {runspec-0.75.2 → runspec-0.76.0}/runspec/rebrand.py +0 -0
  41. {runspec-0.75.2 → runspec-0.76.0}/runspec/runspec.toml +0 -0
  42. {runspec-0.75.2 → runspec-0.76.0}/runspec/schemacheck.py +0 -0
  43. {runspec-0.75.2 → runspec-0.76.0}/runspec/stubgen.py +0 -0
  44. {runspec-0.75.2 → runspec-0.76.0}/runspec/testing.py +0 -0
  45. {runspec-0.75.2 → runspec-0.76.0}/runspec/types.py +0 -0
  46. {runspec-0.75.2 → runspec-0.76.0}/runspec/validator.py +0 -0
  47. {runspec-0.75.2 → runspec-0.76.0}/runspec.egg-info/SOURCES.txt +0 -0
  48. {runspec-0.75.2 → runspec-0.76.0}/runspec.egg-info/dependency_links.txt +0 -0
  49. {runspec-0.75.2 → runspec-0.76.0}/runspec.egg-info/entry_points.txt +0 -0
  50. {runspec-0.75.2 → runspec-0.76.0}/runspec.egg-info/requires.txt +0 -0
  51. {runspec-0.75.2 → runspec-0.76.0}/runspec.egg-info/top_level.txt +0 -0
  52. {runspec-0.75.2 → runspec-0.76.0}/setup.cfg +0 -0
  53. {runspec-0.75.2 → runspec-0.76.0}/tests/__init__.py +0 -0
  54. {runspec-0.75.2 → runspec-0.76.0}/tests/conftest.py +0 -0
  55. {runspec-0.75.2 → runspec-0.76.0}/tests/test_agent.py +0 -0
  56. {runspec-0.75.2 → runspec-0.76.0}/tests/test_audit.py +0 -0
  57. {runspec-0.75.2 → runspec-0.76.0}/tests/test_become.py +0 -0
  58. {runspec-0.75.2 → runspec-0.76.0}/tests/test_cli_discover.py +0 -0
  59. {runspec-0.75.2 → runspec-0.76.0}/tests/test_cli_emit.py +0 -0
  60. {runspec-0.75.2 → runspec-0.76.0}/tests/test_cli_init.py +0 -0
  61. {runspec-0.75.2 → runspec-0.76.0}/tests/test_cli_test.py +0 -0
  62. {runspec-0.75.2 → runspec-0.76.0}/tests/test_denylist.py +0 -0
  63. {runspec-0.75.2 → runspec-0.76.0}/tests/test_discovery.py +0 -0
  64. {runspec-0.75.2 → runspec-0.76.0}/tests/test_env.py +0 -0
  65. {runspec-0.75.2 → runspec-0.76.0}/tests/test_finder.py +0 -0
  66. {runspec-0.75.2 → runspec-0.76.0}/tests/test_inference.py +0 -0
  67. {runspec-0.75.2 → runspec-0.76.0}/tests/test_jump.py +0 -0
  68. {runspec-0.75.2 → runspec-0.76.0}/tests/test_loader.py +0 -0
  69. {runspec-0.75.2 → runspec-0.76.0}/tests/test_log_resilience.py +0 -0
  70. {runspec-0.75.2 → runspec-0.76.0}/tests/test_logging_setup.py +0 -0
  71. {runspec-0.75.2 → runspec-0.76.0}/tests/test_logs.py +0 -0
  72. {runspec-0.75.2 → runspec-0.76.0}/tests/test_mock.py +0 -0
  73. {runspec-0.75.2 → runspec-0.76.0}/tests/test_password.py +0 -0
  74. {runspec-0.75.2 → runspec-0.76.0}/tests/test_rebrand.py +0 -0
  75. {runspec-0.75.2 → runspec-0.76.0}/tests/test_run_as_enforce.py +0 -0
  76. {runspec-0.75.2 → runspec-0.76.0}/tests/test_runsdb_events.py +0 -0
  77. {runspec-0.75.2 → runspec-0.76.0}/tests/test_runsdb_sync.py +0 -0
  78. {runspec-0.75.2 → runspec-0.76.0}/tests/test_schemacheck.py +0 -0
  79. {runspec-0.75.2 → runspec-0.76.0}/tests/test_secret_argv.py +0 -0
  80. {runspec-0.75.2 → runspec-0.76.0}/tests/test_serve_e2e.py +0 -0
  81. {runspec-0.75.2 → runspec-0.76.0}/tests/test_stubgen.py +0 -0
  82. {runspec-0.75.2 → runspec-0.76.0}/tests/test_types.py +0 -0
  83. {runspec-0.75.2 → runspec-0.76.0}/tests/test_typing.py +0 -0
  84. {runspec-0.75.2 → runspec-0.76.0}/tests/test_validator.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: runspec
3
- Version: 0.75.2
3
+ Version: 0.76.0
4
4
  Summary: A language-agnostic, TOML-based interface specification for anything runnable
5
5
  Author-email: Jason Finestone <jason@finestone.dev>
6
6
  Project-URL: Homepage, https://runspec.app
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "runspec"
3
- version = "0.75.2"
3
+ version = "0.76.0"
4
4
  description = "A language-agnostic, TOML-based interface specification for anything runnable"
5
5
  readme = "README.md"
6
6
  license = { file = "../../LICENSE" }
@@ -24,6 +24,8 @@ Design points
24
24
  `check_mode` short-circuits to `changed=false` (conservative).
25
25
  - **Non-interactive:** the module injects `RUNSPEC_AUTONOMY=autonomous` so a
26
26
  `confirm` runnable runs in a playbook without prompting.
27
+ - **Label:** it sets `RUNSPEC_LABEL=ansible`, so the run is labelled `ansible`
28
+ in the runs index.
27
29
  """
28
30
 
29
31
  from __future__ import annotations
@@ -279,6 +281,7 @@ def build_command(params):
279
281
  def build_env(params):
280
282
  env = dict(os.environ)
281
283
  env["RUNSPEC_AUTONOMY"] = "autonomous"
284
+ env["RUNSPEC_LABEL"] = "ansible"
282
285
  for param, kind, _cli, _position, secret_env in ARGS:
283
286
  if kind == "secret" and params.get(param) is not None:
284
287
  env[secret_env] = str(params[param])
@@ -482,10 +482,10 @@ def row_from_records(run_id: str, records: list[dict[str, Any]]) -> dict[str, An
482
482
  row["runnable"] = start.get("runnable") or src.get("runnable") or ""
483
483
  cmd = start.get("command_path") or src.get("command_path") or []
484
484
  row["command"] = " ".join(cmd) if isinstance(cmd, list) else str(cmd or "")
485
- for k in ("user", "user_target", "agent", "trigger", "skill", "autonomy"):
485
+ for k in ("user", "user_target", "agent", "trigger", "skill", "autonomy", "label"):
486
486
  v = start.get(k)
487
487
  row[k] = v if not _empty(v) else src.get(k)
488
- for k in ("pid", "host", "proc_start", "run_as", "label", "exec_uid", "venv"):
488
+ for k in ("pid", "host", "proc_start", "run_as", "exec_uid", "venv"):
489
489
  row[k] = start.get(k)
490
490
  args = start.get("invocation_args") or src.get("invocation_args") or {}
491
491
  row["args"] = args if isinstance(args, dict) else {}
@@ -155,6 +155,7 @@ def configure_logging(
155
155
  agent: bool = False,
156
156
  trigger: str = "",
157
157
  skill: str = "",
158
+ label: str = "",
158
159
  command_path: list[str] | None = None,
159
160
  invocation_args: dict[str, Any] | None = None,
160
161
  history_cfg: dict[str, Any] | None = None,
@@ -366,6 +367,8 @@ def configure_logging(
366
367
  # The saved skill that drove this run (RUNSPEC_SKILL), "" otherwise — a
367
368
  # further provenance axis alongside trigger/agent.
368
369
  "skill": skill,
370
+ # Where the run was launched from (RUNSPEC_LABEL, "cli" when unset).
371
+ "label": label,
369
372
  "command_path": list(command_path or []),
370
373
  "exception": None,
371
374
  "emitted": False,
@@ -516,6 +519,7 @@ def _write_run_start(state: dict[str, Any], *, indexed: bool) -> None:
516
519
  "agent": state["agent"],
517
520
  "trigger": state["trigger"],
518
521
  "skill": state.get("skill", ""),
522
+ "label": state.get("label") or None,
519
523
  "autonomy": state["autonomy"],
520
524
  "run_as": state.get("run_as") or "",
521
525
  "invocation_args": {k: v["value"] for k, v in state.get("invocation_args", {}).items()},
@@ -1004,6 +1008,7 @@ def _begin_history_row(state: dict[str, Any], log_dir: Path, log_file: str) -> N
1004
1008
  "agent": state["agent"],
1005
1009
  "trigger": state["trigger"],
1006
1010
  "skill": state.get("skill", ""),
1011
+ "label": state.get("label") or None,
1007
1012
  "autonomy": state["autonomy"],
1008
1013
  "pid": os.getpid(),
1009
1014
  "host": socket.gethostname(),
@@ -1053,6 +1058,7 @@ def _emit_run_summary() -> None:
1053
1058
  "agent": state["agent"],
1054
1059
  "trigger": state["trigger"],
1055
1060
  "skill": state.get("skill", ""),
1061
+ "label": state.get("label") or None,
1056
1062
  "autonomy": state["autonomy"],
1057
1063
  "exception": state["exception"],
1058
1064
  "events": dict(state["counter"].counts),
@@ -222,7 +222,7 @@ class RunSpec:
222
222
  you don't have to guard every access:
223
223
 
224
224
  since = args.get("since").value # None if not in this path
225
- symbol = args.get("symbol", "VOD.L").value
225
+ symbol = args.get("symbol", "ABC123").value
226
226
 
227
227
  When the arg is in the active command path its real Arg is returned;
228
228
  otherwise a synthetic ``not_set`` Arg wrapping ``default`` is returned,
@@ -265,7 +265,14 @@ def _parse_impl(
265
265
  # The skill (saved runbook) that drove this run, when started from the console's
266
266
  # `$` skills menu. Recorded in run_summary as `extra.skill` — another provenance
267
267
  # axis alongside `trigger`/`agent`, so History/Analytics can attribute runs to it.
268
- skill = os.environ.get("RUNSPEC_SKILL", "").strip()
268
+ # The console sends the runbook id as RUNSPEC_RUNBOOK (skills were renamed to
269
+ # runbooks); RUNSPEC_SKILL is the older spelling, still honoured.
270
+ skill = (os.environ.get("RUNSPEC_SKILL", "") or os.environ.get("RUNSPEC_RUNBOOK", "")).strip()
271
+ # Where the run was launched from (RUNSPEC_LABEL, set by each launcher: the
272
+ # console, `runspec serve`, a Rundeck job, ...); a run nobody labelled was
273
+ # started from a shell, cron or a script, so it records "cli". Stored in the
274
+ # runs index's `label` column, the one `runspec exec --label` fills.
275
+ label = os.environ.get("RUNSPEC_LABEL", "").strip() or "cli"
269
276
  # A `runspec exec` wrapper's run id (RUNSPEC_RUN_ID). Taken — and removed from
270
277
  # the environment — here, unconditionally, so it never leaks to runnables this
271
278
  # one shells out to, even when this runnable has no [config.logging].
@@ -341,6 +348,7 @@ def _parse_impl(
341
348
  agent=agent,
342
349
  trigger=trigger,
343
350
  skill=skill,
351
+ label=label,
344
352
  command_path=command_path,
345
353
  invocation_args=invocation_args,
346
354
  history_cfg=config.get("history"),
@@ -241,6 +241,11 @@ def rd_option_token(name: str) -> str:
241
241
  return f"@option.{name}@"
242
242
 
243
243
 
244
+ # Recorded as the run's label in the runs index (SPEC "Run labels"), so History
245
+ # shows the run came from Rundeck.
246
+ _LABEL_ASSIGN = "RUNSPEC_LABEL=rundeck"
247
+
248
+
244
249
  def _env_prelude_argv(invocation: str, venv: str | None) -> list[str]:
245
250
  """The env-prelude wrapper for a run behind `sudo` (SPEC "Command
246
251
  building"): the venv's own `bin/python` when the venv is known, so a sudoers
@@ -273,13 +278,15 @@ def _exists_check(runnable: str, invocation: str, venv: str | None, run_as: str
273
278
 
274
279
  def _direct(env_vars: list[tuple[str, str]], cmd: str, indent: str = "") -> list[str]:
275
280
  """Export the non-empty values (an empty option stays unset so the
276
- runnable's own default applies) and exec the binary as this account."""
281
+ runnable's own default applies) and the run's label, and exec the binary as
282
+ this account."""
277
283
  lines: list[str] = []
278
284
  if env_vars:
279
285
  names = " ".join(var for var, _ in env_vars)
280
286
  lines.append(f"{indent}for rs_var in {names}; do")
281
287
  lines.append(f'{indent} if [ -n "${{!rs_var}}" ]; then export "$rs_var"; else unset "$rs_var"; fi')
282
288
  lines.append(f"{indent}done")
289
+ lines.append(f"{indent}export {_LABEL_ASSIGN}")
283
290
  lines.append(f"{indent}exec {cmd}")
284
291
  return lines
285
292
 
@@ -324,7 +331,6 @@ def render_script(
324
331
  path.
325
332
  """
326
333
  env_vars = [(secret_env_var(runnable, name), name) for name in args]
327
- names = " ".join(var for var, _ in env_vars)
328
334
 
329
335
  lines = ["#!/bin/bash", _PRIVATE, _GUARD, "set -e"]
330
336
  for var, name in env_vars:
@@ -366,15 +372,20 @@ def render_script(
366
372
  lines.append("fi")
367
373
 
368
374
  become = 'sudo -n -u "$rs_user"'
369
- if not env_vars:
375
+ if not env_vars and not venv:
376
+ # Nothing to send but the label, and the `sh` prelude would need a
377
+ # sudoers rule that allows a shell: run the binary directly (the run then
378
+ # records the "cli" label).
370
379
  lines.append(f"exec {become} {cmd}")
371
380
  return _finish(lines)
372
381
 
373
382
  prelude = PY_ENV_PRELUDE if venv else ENV_PRELUDE
374
383
  lines.append(f"RS_PRELUDE='{prelude}'")
375
384
  lines.append(_FRAME_FUNC)
385
+ lines.append(_LABEL_ASSIGN)
376
386
  wrapped = " ".join([*_env_prelude_argv(invocation, venv), cmd])
377
- lines.append(f"rs_frame {names} | {become} {wrapped}")
387
+ frame_vars = " ".join([*(var for var, _ in env_vars), "RUNSPEC_LABEL"])
388
+ lines.append(f"rs_frame {frame_vars} | {become} {wrapped}")
378
389
  return _finish(lines)
379
390
 
380
391
 
@@ -199,6 +199,11 @@ def exec_runnable(
199
199
  out_stream = stdout if stdout is not None else sys.stdout
200
200
  err_stream = stderr if stderr is not None else sys.stderr
201
201
  label = label or None
202
+ # The label recorded on the run and handed to the child: --label, else the
203
+ # launcher's RUNSPEC_LABEL, else "cli" (what the child's parse() would record).
204
+ # `label` alone stays the --skip-if-running key, so an unlabelled run never
205
+ # waits on every other unlabelled run.
206
+ run_label = label or os.environ.get("RUNSPEC_LABEL", "").strip() or "cli"
202
207
  trigger = trigger if trigger is not None else os.environ.get("RUNSPEC_TRIGGER", "").strip()
203
208
  run_id = str(uuid.uuid4())
204
209
  start = _now()
@@ -237,7 +242,7 @@ def exec_runnable(
237
242
  "pid": os.getpid(),
238
243
  "host": socket.gethostname(),
239
244
  "proc_start": runsdb.proc_start_token(),
240
- "label": label,
245
+ "label": run_label,
241
246
  "run_as": (spec or {}).get("run_as") or None,
242
247
  "os_user": inbox.effective_user() or None,
243
248
  "source": "direct",
@@ -268,7 +273,7 @@ def exec_runnable(
268
273
  _record(
269
274
  message,
270
275
  run_id,
271
- {"event": "exec_result", "status": status, "exit_code": exit_code, "duration_ms": 0, "label": label, "trigger": trigger, "reason": reason},
276
+ {"event": "exec_result", "status": status, "exit_code": exit_code, "duration_ms": 0, "label": run_label, "trigger": trigger, "reason": reason},
272
277
  level="INFO" if status == "skipped" else "ERROR",
273
278
  )
274
279
  ],
@@ -322,6 +327,7 @@ def exec_runnable(
322
327
  child_env["RUNSPEC_RUN_LOG"] = str(box.path)
323
328
  if trigger:
324
329
  child_env["RUNSPEC_TRIGGER"] = trigger
330
+ child_env["RUNSPEC_LABEL"] = run_label
325
331
  cmd, extra_env, frame = build_command(spec, list(argv), child_env)
326
332
  capture = capture_bytes > 0
327
333
  t0 = time.monotonic()
@@ -337,7 +343,7 @@ def exec_runnable(
337
343
  if write_row: # drop the running placeholder; record the launch failure
338
344
  runsdb.finish_run(log_dir, run_id, exit_code=EXIT_NOT_EXECUTABLE, duration_ms=0, status="failed", cfg=history_cfg, runnable=name)
339
345
  log_path = box.path if box is not None else _log_file_for(log_dir, None, name, start, run_id)
340
- result = {"event": "exec_result", "status": "failed", "exit_code": EXIT_NOT_EXECUTABLE, "duration_ms": 0, "label": label, "trigger": trigger, "reason": "launch_error"}
346
+ result = {"event": "exec_result", "status": "failed", "exit_code": EXIT_NOT_EXECUTABLE, "duration_ms": 0, "label": run_label, "trigger": trigger, "reason": "launch_error"}
341
347
  sink.write(log_path, [_record(f"could not start '{name}': {exc}", run_id, result, level="ERROR")])
342
348
  with contextlib.suppress(Exception):
343
349
  err_stream.write(f"runspec exec: could not start '{name}': {exc}\n")
@@ -381,7 +387,7 @@ def exec_runnable(
381
387
  if write_row and row is not None and row.get("pid") == os.getpid() and row.get("status") == "running":
382
388
  # The child never touched our row — no logging, failed before logging,
383
389
  # or an older runspec that minted its own id. Adopt the latter.
384
- adopted = runsdb.adopt_child_row(log_dir, run_id, runnable=name, since_ts=start_iso, label=label)
390
+ adopted = runsdb.adopt_child_row(log_dir, run_id, runnable=name, since_ts=start_iso, label=run_label)
385
391
  if adopted:
386
392
  target_id = adopted
387
393
  if write_row:
@@ -405,7 +411,7 @@ def exec_runnable(
405
411
  _record(
406
412
  result_msg,
407
413
  target_id,
408
- {"event": "exec_result", "status": status, "exit_code": rc, "duration_ms": duration_ms, "label": label, "trigger": trigger, "reason": "timeout" if timed_out else ""},
414
+ {"event": "exec_result", "status": status, "exit_code": rc, "duration_ms": duration_ms, "label": run_label, "trigger": trigger, "reason": "timeout" if timed_out else ""},
409
415
  level="INFO" if rc == 0 else "ERROR",
410
416
  )
411
417
  )
@@ -657,13 +657,19 @@ def _normalise_row(row: dict[str, Any]) -> dict[str, Any]:
657
657
  _HAS_UPSERT = sqlite3.sqlite_version_info >= (3, 24, 0)
658
658
 
659
659
 
660
+ # Kept columns where the value already on the row wins over a later upsert's.
661
+ _FIRST_WINS = ("label",)
662
+
663
+
660
664
  def _upsert(con: sqlite3.Connection, row: dict[str, Any]) -> None:
661
665
  """INSERT or overwrite a row by ``run_id``. ``label``, ``events`` and
662
666
  ``exception`` (and v6 ``os_user`` / ``source`` / ``exec_uid``) are written on
663
667
  insert but only ever *kept* on conflict: under
664
668
  ``runspec exec`` the wrapper writes the labelled row first and the child's own
665
669
  begin/finish upserts (which don't know the label) must not clear it — and a
666
- begin that races a finish must not clear the finish's events."""
670
+ begin that races a finish must not clear the finish's events. ``label`` goes
671
+ further: the first non-empty value stays, so a child's own label (``cli`` when
672
+ nothing set ``RUNSPEC_LABEL``) never replaces the wrapper's ``--label``."""
667
673
  kept = ("label", "events", "exception", "os_user", "source", "exec_uid")
668
674
  cols = ", ".join((*_COLUMNS, *kept))
669
675
  placeholders = ", ".join("?" for _ in range(len(_COLUMNS) + len(kept)))
@@ -674,7 +680,7 @@ def _upsert(con: sqlite3.Connection, row: dict[str, Any]) -> None:
674
680
  # left a row without user / args / agent. UPDATE then INSERT does the same in
675
681
  # one write transaction (the UPDATE takes the write lock).
676
682
  sets = ", ".join(f"{c}=?" for c in _COLUMNS if c != "run_id")
677
- sets += "".join(f", {c}=COALESCE(?, {c})" for c in kept)
683
+ sets += "".join(f", {c}=COALESCE({c}, ?)" if c in _FIRST_WINS else f", {c}=COALESCE(?, {c})" for c in kept)
678
684
  cur = con.execute(
679
685
  f"UPDATE runs SET {sets} WHERE run_id=?",
680
686
  (*(row[c] for c in _COLUMNS if c != "run_id"), *kept_vals, row["run_id"]),
@@ -683,7 +689,7 @@ def _upsert(con: sqlite3.Connection, row: dict[str, Any]) -> None:
683
689
  con.execute(f"INSERT INTO runs ({cols}) VALUES ({placeholders})", (*(row[c] for c in _COLUMNS), *kept_vals))
684
690
  return
685
691
  updates = ", ".join(f"{c}=excluded.{c}" for c in _COLUMNS if c != "run_id")
686
- updates += "".join(f", {c}=COALESCE(excluded.{c}, runs.{c})" for c in kept)
692
+ updates += "".join(f", {c}=COALESCE(runs.{c}, excluded.{c})" if c in _FIRST_WINS else f", {c}=COALESCE(excluded.{c}, runs.{c})" for c in kept)
687
693
  con.execute(
688
694
  f"INSERT INTO runs ({cols}) VALUES ({placeholders}) ON CONFLICT(run_id) DO UPDATE SET {updates}",
689
695
  (*(row[c] for c in _COLUMNS), *kept_vals),
@@ -1277,6 +1283,7 @@ def reindex(log_dir: Path, records: list[dict[str, Any]]) -> int:
1277
1283
  "trigger": rec.get("trigger", ""),
1278
1284
  "skill": rec.get("skill", ""),
1279
1285
  "autonomy": rec.get("autonomy", ""),
1286
+ "label": rec.get("label"),
1280
1287
  "args": rec.get("invocation_args") or {},
1281
1288
  "events": rec.get("events"),
1282
1289
  "exception": exc,
@@ -316,7 +316,7 @@ def _handle_tools_call(
316
316
  # RUNSPEC_* vars on the child's stdin (the SPEC env frame) because sudo and
317
317
  # friends strip the environment — never on the command line.
318
318
  spec = exec_specs.get(name, {})
319
- final_cmd, extra_env, frame = _build_command(spec, argv, {"RUNSPEC_AGENT": "1", **runspec_env})
319
+ final_cmd, extra_env, frame = _build_command(spec, argv, {"RUNSPEC_AGENT": "1", "RUNSPEC_LABEL": "mcp", **runspec_env})
320
320
  env = {**os.environ, **extra_env}
321
321
  # The child must never inherit serve's stdin: that's the MCP JSON-RPC stream.
322
322
  feed: dict[str, Any] = {"input": frame.decode("ascii")} if frame else {"stdin": subprocess.DEVNULL}
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: runspec
3
- Version: 0.75.2
3
+ Version: 0.76.0
4
4
  Summary: A language-agnostic, TOML-based interface specification for anything runnable
5
5
  Author-email: Jason Finestone <jason@finestone.dev>
6
6
  Project-URL: Homepage, https://runspec.app
@@ -154,6 +154,7 @@ def test_render_module_is_valid_python():
154
154
  assert "from ansible.module_utils.basic import AnsibleModule" in src
155
155
  assert "supports_check_mode=True" in src
156
156
  assert "RUNSPEC_AUTONOMY" in src # non-interactive
157
+ assert 'env["RUNSPEC_LABEL"] = "ansible"' in src # the run is labelled ansible
157
158
 
158
159
 
159
160
  def test_render_module_secret_via_env_not_argv():
@@ -26,6 +26,7 @@ def logs_dir(tmp_path, monkeypatch):
26
26
  d.mkdir()
27
27
  monkeypatch.setattr(runexec, "_resolve_log_dir", lambda: d)
28
28
  monkeypatch.delenv("RUNSPEC_TRIGGER", raising=False)
29
+ monkeypatch.delenv("RUNSPEC_LABEL", raising=False)
29
30
  return d
30
31
 
31
32
 
@@ -119,6 +120,44 @@ class TestPlainChild:
119
120
  assert list(logs_dir.glob("demo.*.log"))
120
121
 
121
122
 
123
+ class TestLabel:
124
+ """The run's label: --label, else the launcher's RUNSPEC_LABEL, else "cli" —
125
+ recorded on the row and handed to the child as RUNSPEC_LABEL."""
126
+
127
+ _BODY = "import os\nprint(os.environ.get('RUNSPEC_LABEL', '<unset>'))\n"
128
+
129
+ def test_unlabelled_run_is_cli(self, logs_dir, tmp_path, monkeypatch):
130
+ _install(monkeypatch, tmp_path, self._BODY)
131
+ _, out, _ = _run()
132
+ [row] = _rows(logs_dir)
133
+ assert row["label"] == "cli" and out.strip() == "cli"
134
+
135
+ def test_launcher_label_from_env(self, logs_dir, tmp_path, monkeypatch):
136
+ _install(monkeypatch, tmp_path, self._BODY)
137
+ monkeypatch.setenv("RUNSPEC_LABEL", "console")
138
+ _, out, _ = _run()
139
+ [row] = _rows(logs_dir)
140
+ assert row["label"] == "console" and out.strip() == "console"
141
+
142
+ def test_explicit_label_wins(self, logs_dir, tmp_path, monkeypatch):
143
+ _install(monkeypatch, tmp_path, self._BODY)
144
+ monkeypatch.setenv("RUNSPEC_LABEL", "console")
145
+ _, out, _ = _run(label="tui")
146
+ [row] = _rows(logs_dir)
147
+ assert row["label"] == "tui" and out.strip() == "tui"
148
+
149
+ def test_unlabelled_runs_do_not_skip_each_other(self, logs_dir, tmp_path, monkeypatch):
150
+ # --skip-if-running keys on --label only, so the "cli" default never
151
+ # makes one unlabelled runnable wait on another.
152
+ _install(monkeypatch, tmp_path, "print('ran')\n")
153
+ runsdb.begin_run(
154
+ logs_dir,
155
+ {"run_id": "live1", "runnable": "other", "status": "running", "pid": os.getpid(), "host": runsdb._hostname(), "proc_start": runsdb.proc_start_token(), "label": "cli"},
156
+ )
157
+ _, out, _ = _run(skip_if_running=True)
158
+ assert "ran" in out
159
+
160
+
122
161
  class TestNotRunnable:
123
162
  def test_not_installed_is_recorded(self, logs_dir, monkeypatch):
124
163
  monkeypatch.setattr(runexec, "find_runnable", lambda name: None)
@@ -191,6 +230,26 @@ class TestHandoff:
191
230
  assert row["command"] == "sub" # the child's own detail kept
192
231
  assert row["events"] == '{"INFO": 2}' # the wrapper's finalize kept the child's events
193
232
 
233
+ def test_child_label_does_not_replace_the_wrappers(self, logs_dir, tmp_path, monkeypatch):
234
+ # A child that records its own label (an older wrapper never handed one
235
+ # down, so parse() fell back to "cli") must not overwrite --label.
236
+ body = f"""
237
+ import os, sys
238
+ sys.path.insert(0, {str(Path(runsdb.__file__).parent.parent)!r})
239
+ from pathlib import Path
240
+ from runspec import runsdb
241
+ from runspec.logging_setup import adopt_exec_run_id
242
+ rid = adopt_exec_run_id()
243
+ d = Path({str(logs_dir)!r})
244
+ runsdb.begin_run(d, {{"run_id": rid, "runnable": "demo", "status": "running", "label": "cli",
245
+ "pid": os.getpid(), "host": runsdb._hostname()}})
246
+ runsdb.finish_run(d, rid, exit_code=0, duration_ms=1, status="completed")
247
+ """
248
+ _install(monkeypatch, tmp_path, body)
249
+ _run(label="sched:x")
250
+ [row] = _rows(logs_dir)
251
+ assert row["label"] == "sched:x"
252
+
194
253
  def test_older_child_row_is_adopted(self, logs_dir, tmp_path, monkeypatch):
195
254
  # What a <= 0.61 child does: ignore RUNSPEC_RUN_ID and mint its own id.
196
255
  body = f"""
@@ -101,6 +101,11 @@ class TestParser:
101
101
  assert row["label"] == "sched:x" and row["command"] == "sub" and row["args"] == {"a": "b"}
102
102
  assert row["exec"]["exit_code"] == 137
103
103
 
104
+ def test_label_comes_from_the_summary_when_the_start_has_none(self):
105
+ rid = _rid()
106
+ row = inbox.row_from_records(rid, [json.loads(_rec(rid, "run_start", runnable="demo")), json.loads(_rec(rid, "run_summary", exit_code=0, label="console"))])
107
+ assert row is not None and row["label"] == "console"
108
+
104
109
  def test_history_db_false_marker(self):
105
110
  rid = _rid()
106
111
  row = inbox.row_from_records(rid, [json.loads(_rec(rid, "run_start", history_db=False)), json.loads(_rec(rid, "run_summary", exit_code=0))])
@@ -256,7 +256,7 @@ class TestRunSpecArgAccess:
256
256
 
257
257
  def test_get_absent_with_default(self):
258
258
  rs = self._make_with_args(region=arg("eu", name="region"))
259
- assert rs.get("symbol", "VOD.L").value == "VOD.L"
259
+ assert rs.get("symbol", "ABC123").value == "ABC123"
260
260
 
261
261
  def test_get_never_raises_supports_safe_value_access(self):
262
262
  # The boilerplate-killer: no up-front guard needed.
@@ -747,11 +747,11 @@ class TestSubcommandGlobals:
747
747
  self._write(tmp_path, monkeypatch)
748
748
  result = runspec.parse(
749
749
  script_name="sample-tool",
750
- argv=["-r", "europe", "-e", "qa", "show", "--symbol", "VOD.L"],
750
+ argv=["-r", "europe", "-e", "qa", "show", "--symbol", "ABC123"],
751
751
  )
752
752
  assert result.region.value == "europe"
753
753
  assert result.env.value == "qa"
754
- assert result.symbol.value == "VOD.L"
754
+ assert result.symbol.value == "ABC123"
755
755
  assert result.runspec_command_path == ["show"]
756
756
  assert result.runspec_command == "show"
757
757
 
@@ -159,6 +159,16 @@ class TestEmit:
159
159
  s = next(obj for obj in lines if obj["logger"] == "runspec.runsummary")
160
160
  assert s["extra"]["skill"] == ""
161
161
 
162
+ def test_start_and_summary_record_the_label(self, tmp_path):
163
+ # `label` is where the run was launched from (RUNSPEC_LABEL, "cli" when
164
+ # unset — parse() fills that default); the run records carry it.
165
+ configure_logging(_cfg(), runnable_name="myscript", label="console")
166
+ _emit_run_summary()
167
+ lines = [json.loads(line) for line in (tmp_path / "logs" / "myscript.log").read_text().strip().splitlines()]
168
+ recs = [obj["extra"] for obj in lines if obj["logger"] == "runspec.runsummary"]
169
+ assert "run_summary" in {r["event"] for r in recs}
170
+ assert all(r["label"] == "console" for r in recs)
171
+
162
172
  def test_summary_console_writes_line_to_stderr(self, capsys):
163
173
  configure_logging(_cfg(summary_console=True), runnable_name="myscript")
164
174
  logging.getLogger("test.stderr").info("ran")
@@ -362,6 +372,35 @@ class TestEarlyExcepthookCoverage:
362
372
  assert seen["hook"] is not sys.__excepthook__
363
373
 
364
374
 
375
+ class TestParseProvenance:
376
+ """parse() reads the launcher's provenance env vars and hands them to
377
+ configure_logging."""
378
+
379
+ def _kwargs(self, tmp_path, monkeypatch):
380
+ import runspec.parser as parser
381
+
382
+ cfg = tmp_path / "runspec.toml"
383
+ cfg.write_text("[demo]\n")
384
+ seen: dict[str, object] = {}
385
+ monkeypatch.setattr(parser, "configure_logging", lambda *a, **kw: seen.update(kw))
386
+ parser.parse(script_name="demo", argv=[], config_path=cfg)
387
+ return seen
388
+
389
+ def test_unlabelled_run_is_cli(self, tmp_path, monkeypatch):
390
+ monkeypatch.delenv("RUNSPEC_LABEL", raising=False)
391
+ assert self._kwargs(tmp_path, monkeypatch)["label"] == "cli"
392
+
393
+ def test_launcher_label(self, tmp_path, monkeypatch):
394
+ monkeypatch.setenv("RUNSPEC_LABEL", " console ")
395
+ assert self._kwargs(tmp_path, monkeypatch)["label"] == "console"
396
+
397
+ def test_runbook_env_is_the_skill(self, tmp_path, monkeypatch):
398
+ # The console sends RUNSPEC_RUNBOOK; RUNSPEC_SKILL is the older spelling.
399
+ monkeypatch.delenv("RUNSPEC_SKILL", raising=False)
400
+ monkeypatch.setenv("RUNSPEC_RUNBOOK", "restart-web")
401
+ assert self._kwargs(tmp_path, monkeypatch)["skill"] == "restart-web"
402
+
403
+
365
404
  class TestExcStructuredHelpers:
366
405
  def test_build_exc_structured_shape(self):
367
406
  try:
@@ -517,6 +556,13 @@ class TestRunsIndexHook:
517
556
  assert done["status"] == "completed" and done["exit_code"] == 0
518
557
  assert isinstance(done["duration_ms"], int)
519
558
 
559
+ def test_row_carries_the_label(self, tmp_path):
560
+ from runspec import runsdb
561
+
562
+ configure_logging(_cfg(summary=True), runnable_name="demo", history_cfg={"db": True}, label="console")
563
+ _emit_run_summary()
564
+ assert runsdb.list_runs(tmp_path / "logs", live=False)[0]["label"] == "console"
565
+
520
566
  def test_failure_finalizes_as_failed(self, tmp_path):
521
567
  from runspec import runsdb
522
568
 
@@ -184,10 +184,13 @@ def test_script_no_args_just_execs():
184
184
  def test_script_run_as_sends_env_frame_on_stdin_to_venv_python():
185
185
  s = rundeckgen.render_script("deploy", ["start"], {"token": {"type": "password"}}, "/opt/app/.venv/bin/deploy", run_as="svc", venv="/opt/app/.venv")
186
186
  assert "rs_user=svc\n" in s
187
- assert 'rs_frame RUNSPEC_DEPLOY_ARG_TOKEN | sudo -n -u "$rs_user" /opt/app/.venv/bin/python -c "$RS_PRELUDE" /opt/app/.venv/bin/deploy start' in s
188
- # sudo resets the environment, so values ride stdin; the only export is the
189
- # no-sudo branch taken when the Rundeck user already is the account.
190
- assert s.count("export") == 1
187
+ assert "\nRUNSPEC_LABEL=rundeck\n" in s
188
+ assert 'rs_frame RUNSPEC_DEPLOY_ARG_TOKEN RUNSPEC_LABEL | sudo -n -u "$rs_user" /opt/app/.venv/bin/python -c "$RS_PRELUDE" /opt/app/.venv/bin/deploy start' in s
189
+ # sudo resets the environment, so values (and the label) ride stdin; the only
190
+ # exports are in the no-sudo branch taken when the Rundeck user already is
191
+ # the account.
192
+ assert s.count("export") == 2
193
+ assert " export RUNSPEC_LABEL=rundeck\n" in s
191
194
  assert 'if [ "$rs_user" = "$(id -un)" ]; then' in s
192
195
 
193
196
 
@@ -197,9 +200,19 @@ def test_script_run_as_without_venv_uses_shell_prelude():
197
200
  assert "command -v" not in s # sudo's PATH isn't the Rundeck user's
198
201
 
199
202
 
200
- def test_script_run_as_no_args_execs_sudo():
203
+ def test_script_run_as_no_args_sends_only_the_label():
204
+ """With the venv known the venv-python prelude is covered by a venv `bin/`
205
+ sudoers rule, so even a job with no options sends its label in the frame."""
201
206
  s = rundeckgen.render_script("deploy", [], {}, "/opt/v/bin/deploy", run_as="svc", venv="/opt/v")
202
- assert s.strip().endswith('exec sudo -n -u "$rs_user" /opt/v/bin/deploy')
207
+ assert s.strip().endswith('rs_frame RUNSPEC_LABEL | sudo -n -u "$rs_user" /opt/v/bin/python -c "$RS_PRELUDE" /opt/v/bin/deploy')
208
+
209
+
210
+ def test_script_run_as_no_args_no_venv_execs_sudo():
211
+ """Without the venv the prelude would be `sh`, which a sudoers rule may
212
+ refuse, so a job with no options runs the binary directly (label "cli")."""
213
+ s = rundeckgen.render_script("deploy", [], {}, "deploy", run_as="svc")
214
+ assert s.strip().endswith('exec sudo -n -u "$rs_user" deploy')
215
+ assert "RS_PRELUDE" not in s
203
216
 
204
217
 
205
218
  def test_script_run_as_rejects_shell_metacharacters():
@@ -227,7 +240,7 @@ def _show_binary(tmp_path):
227
240
 
228
241
  show = tmp_path / "bin" / "show"
229
242
  show.parent.mkdir(exist_ok=True)
230
- show.write_text(f"#!{sys.executable}\nimport json, os, sys\nprint(json.dumps([{{k: v for k, v in os.environ.items() if k.startswith('RUNSPEC_SHOW_')}}, sys.argv[1:]]))\n")
243
+ show.write_text(f"#!{sys.executable}\nimport json, os, sys\nprint(json.dumps([{{k: v for k, v in os.environ.items() if k.startswith('RUNSPEC_SHOW_') or k == 'RUNSPEC_LABEL'}}, sys.argv[1:]]))\n")
231
244
  show.chmod(0o755)
232
245
  return show
233
246
 
@@ -257,7 +270,7 @@ def test_script_values_round_trip_through_bash(tmp_path):
257
270
  out = _run_substituted(tmp_path, script, values)
258
271
  assert out.returncode == 0, out.stderr
259
272
  env, argv = json.loads(out.stdout)
260
- assert env == {"RUNSPEC_SHOW_ARG_PLAIN": "3", "RUNSPEC_SHOW_ARG_TRICKY": values["tricky"], "RUNSPEC_SHOW_ARG_DELIM": "RUNSPEC_VALUE"}
273
+ assert env == {"RUNSPEC_SHOW_ARG_PLAIN": "3", "RUNSPEC_SHOW_ARG_TRICKY": values["tricky"], "RUNSPEC_SHOW_ARG_DELIM": "RUNSPEC_VALUE", "RUNSPEC_LABEL": "rundeck"}
261
274
  assert argv == ["sub"]
262
275
  assert not (tmp_path / "pwned").exists()
263
276
 
@@ -324,7 +337,7 @@ def test_script_guard_tolerates_blank_lines(tmp_path):
324
337
  script = rundeckgen.render_script("show", [], {"a": {"type": "str"}}, str(show)) + "\n\n"
325
338
  out = _run_substituted(tmp_path, script, {"a": "\n"})
326
339
  assert out.returncode == 0, out.stderr
327
- assert json.loads(out.stdout)[0] == {}
340
+ assert json.loads(out.stdout)[0] == {"RUNSPEC_LABEL": "rundeck"}
328
341
 
329
342
 
330
343
  def test_script_guard_count_matches_rendered_script():
@@ -380,7 +393,7 @@ def test_script_owner_runs_directly_when_already_the_owner(tmp_path):
380
393
  env = {**os.environ, "PATH": f"{tmp_path}/bin:/usr/bin:/bin"}
381
394
  out = _run_substituted(tmp_path, script, {"a": "v"}, env=env)
382
395
  assert out.returncode == 0, out.stderr
383
- assert json.loads(out.stdout) == [{"RUNSPEC_SHOW_ARG_A": "v"}, ["sub"]]
396
+ assert json.loads(out.stdout) == [{"RUNSPEC_SHOW_ARG_A": "v", "RUNSPEC_LABEL": "rundeck"}, ["sub"]]
384
397
 
385
398
 
386
399
  def test_script_owner_escalates_to_the_venv_owner(tmp_path):
@@ -404,7 +417,7 @@ def test_script_owner_escalates_to_the_venv_owner(tmp_path):
404
417
  script = rundeckgen.render_script("show", ["sub"], {"a": {"type": "str"}, "b": {"type": "password"}}, invocation, run_as="@owner", venv=venv)
405
418
  out = _run_substituted(tmp_path, script, {"a": "v", "b": "s3cret"}, env=env)
406
419
  assert out.returncode == 0, out.stderr
407
- assert json.loads(out.stdout) == [{"RUNSPEC_SHOW_ARG_A": "v", "RUNSPEC_SHOW_ARG_B": "s3cret"}, ["sub"]]
420
+ assert json.loads(out.stdout) == [{"RUNSPEC_SHOW_ARG_A": "v", "RUNSPEC_SHOW_ARG_B": "s3cret", "RUNSPEC_LABEL": "rundeck"}, ["sub"]]
408
421
  sudo_argv = (tmp_path / "sudo.log").read_text()
409
422
  assert sudo_argv.startswith("-n -u svc-owner ")
410
423
  assert "s3cret" not in sudo_argv and str(show) in sudo_argv
@@ -210,6 +210,7 @@ class TestReindex:
210
210
  "trigger": "schedule",
211
211
  "skill": "",
212
212
  "autonomy": "autonomous",
213
+ "label": "rundeck",
213
214
  "exception": None,
214
215
  "user": "svc",
215
216
  "invocation_args": {},
@@ -230,7 +231,8 @@ class TestReindex:
230
231
  assert n == 2
231
232
  rows = {r["run_id"]: r for r in runsdb.list_runs(logs_dir, live=False)}
232
233
  assert rows["h1"]["status"] == "completed" and rows["h1"]["agent"] == 1
233
- assert rows["h1"]["trigger"] == "schedule"
234
+ assert rows["h1"]["trigger"] == "schedule" and rows["h1"]["label"] == "rundeck"
235
+ assert rows["h2"]["label"] is None # a summary from before labels
234
236
  assert rows["h2"]["status"] == "failed" and rows["h2"]["command"] == "push"
235
237
 
236
238
  def test_reindex_skips_records_without_run_id(self, logs_dir):
@@ -316,6 +316,7 @@ def test_tools_call_no_run_as_does_not_escalate(monkeypatch, tmp_path):
316
316
  # The env rides the child's environment; the child never inherits serve's
317
317
  # stdin (the MCP JSON-RPC stream).
318
318
  assert captured["kw"]["env"]["RUNSPEC_AGENT"] == "1"
319
+ assert captured["kw"]["env"]["RUNSPEC_LABEL"] == "mcp"
319
320
  assert captured["kw"]["stdin"] is subprocess.DEVNULL
320
321
 
321
322
 
@@ -2,15 +2,15 @@
2
2
  Tests for runspec.testing.ParseHarness — and a worked example of how a
3
3
  developer scaffolds tests for a real, subcommand-heavy runnable.
4
4
 
5
- The `static-data` fixture is the migrated trading-symbol runnable from
6
- docs/design/new_example_toml.md: ~11 subcommands, parent-level args
5
+ The `catalog` fixture (tests/fixtures/catalog.toml) is a large catalogue
6
+ runnable: ~11 subcommands, parent-level args
7
7
  (region/env/source/jira), patterns, multiple values, and require-command.
8
8
  It stands in for any large migrated runnable so the patterns below transfer
9
9
  directly.
10
10
 
11
11
  Two layers are demonstrated:
12
12
 
13
- Layer 1 — wiring/parsing, no mocks (TestStaticDataWiring, TestSmoke).
13
+ Layer 1 — wiring/parsing, no mocks (TestCatalogWiring, TestSmoke).
14
14
  Layer 2 — the actual work, mocking only the I/O boundary (TestLayerTwoPattern).
15
15
  """
16
16
 
@@ -28,50 +28,50 @@ from runspec.testing import ParseHarness, harness, no_run_as_check
28
28
  posix_only = pytest.mark.skipif(not hasattr(os, "geteuid"), reason="POSIX uid model required")
29
29
 
30
30
  FIXTURES = Path(__file__).parent / "fixtures"
31
- STATIC_DATA = FIXTURES / "static_data.toml"
31
+ CATALOG = FIXTURES / "catalog.toml"
32
32
 
33
33
 
34
34
  @pytest.fixture()
35
35
  def h() -> ParseHarness:
36
- return ParseHarness(script_name="static-data", config_path=STATIC_DATA)
36
+ return ParseHarness(script_name="catalog", config_path=CATALOG)
37
37
 
38
38
 
39
39
  # ── Layer 1: wiring / parsing (no mocks) ──────────────────────────────────────
40
40
 
41
41
 
42
- class TestStaticDataWiring:
42
+ class TestCatalogWiring:
43
43
  def test_subcommand_is_detected(self, h: ParseHarness) -> None:
44
- spec = h.expect_ok(["create", "-s", "AAPL,MSFT", "--region", "americas", "--env", "prod"])
44
+ spec = h.expect_ok(["create", "-s", "WIDGET1,GADGET2", "--region", "americas", "--env", "prod"])
45
45
  assert spec.runspec_command == "create"
46
46
  assert spec.runspec_command_path == ["create"]
47
47
 
48
48
  def test_types_are_coerced(self, h: ParseHarness) -> None:
49
- spec = h.expect_ok(["create", "-s", "AAPL", "--price", "12.5", "--region", "americas", "--env", "prod"])
50
- assert spec.symbols.value == ["AAPL"] # multiple + delimiter -> list
49
+ spec = h.expect_ok(["create", "-s", "WIDGET1", "--price", "12.5", "--region", "americas", "--env", "prod"])
50
+ assert spec.skus.value == ["WIDGET1"] # multiple + delimiter -> list
51
51
  assert spec.price.value == 12.5 # float
52
52
  assert isinstance(spec.price.value, float)
53
53
 
54
54
  def test_flag_defaults_false(self, h: ParseHarness) -> None:
55
- spec = h.expect_ok(["create", "-s", "AAPL", "--region", "americas", "--env", "prod"])
55
+ spec = h.expect_ok(["create", "-s", "WIDGET1", "--region", "americas", "--env", "prod"])
56
56
  assert spec.persist.value is False
57
57
 
58
- def test_missing_required_symbols_exits(self, h: ParseHarness) -> None:
59
- # `symbols` is required on `create`; omit it.
58
+ def test_missing_required_skus_exits(self, h: ParseHarness) -> None:
59
+ # `skus` is required on `create`; omit it.
60
60
  out = h.expect_exit(["create", "--region", "americas", "--env", "prod"])
61
- assert "symbol" in out.lower() or "-s" in out
61
+ assert "sku" in out.lower() or "-s" in out
62
62
 
63
63
  def test_bad_choice_names_the_flag(self, h: ParseHarness) -> None:
64
64
  h.expect_exit(
65
- ["create", "-s", "AAPL", "--region", "mars", "--env", "prod"],
65
+ ["create", "-s", "WIDGET1", "--region", "mars", "--env", "prod"],
66
66
  contains="--region",
67
67
  )
68
68
 
69
69
  def test_pattern_violation_is_rejected(self, h: ParseHarness) -> None:
70
- # symbols pattern forbids whitespace / lowercase.
71
- h.expect_exit(["show", "-s", "not a symbol", "--region", "americas", "--env", "prod"])
70
+ # skus pattern forbids whitespace / lowercase.
71
+ h.expect_exit(["show", "-s", "not a sku", "--region", "americas", "--env", "prod"])
72
72
 
73
73
  def test_require_command_enforced(self, h: ParseHarness) -> None:
74
- # static-data sets require-command = true.
74
+ # catalog sets require-command = true.
75
75
  h.expect_exit([])
76
76
 
77
77
  def test_help_exits_zero(self, h: ParseHarness) -> None:
@@ -92,12 +92,12 @@ class TestSmoke:
92
92
  values={
93
93
  "region": "americas",
94
94
  "env": "prod",
95
- "symbols": "AAPL",
95
+ "skus": "WIDGET1",
96
96
  "price": "12.5",
97
- "adv": "1000",
98
- "trades": "50",
99
- "ipo_date": "20240101",
100
- "volume": "100",
97
+ "avg_sales": "1000",
98
+ "avg_orders": "50",
99
+ "launch_date": "20240101",
100
+ "quantity": "100",
101
101
  "field": "SomeField",
102
102
  "value": "x",
103
103
  }
@@ -111,7 +111,7 @@ class TestSmoke:
111
111
  def test_command_discovery(self, h: ParseHarness) -> None:
112
112
  names = {p[-1] for p in h.command_paths()}
113
113
  assert {"show", "create", "clone", "refresh"} <= names
114
- assert h.required_args(["create"]) == ["symbols"]
114
+ assert h.required_args(["create"]) == ["skus"]
115
115
  assert h.requires_command() is True
116
116
 
117
117
 
@@ -301,22 +301,22 @@ class TestLayerTwoPattern:
301
301
  # Stand-in for your real module: a pure logic function + the one I/O call.
302
302
  calls: list[tuple] = []
303
303
 
304
- def fake_store_call(region: str, action: str, symbols: list[str], **fields):
305
- calls.append((region, action, tuple(symbols), fields))
306
- return {"ok": True, "applied": list(symbols)}
304
+ def fake_store_call(region: str, action: str, skus: list[str], **fields):
305
+ calls.append((region, action, tuple(skus), fields))
306
+ return {"ok": True, "applied": list(skus)}
307
307
 
308
- def create_symbols(region, symbols, *, price=None, _call=fake_store_call):
308
+ def create_items(region, skus, *, price=None, _call=fake_store_call):
309
309
  # the kind of plain function your main() should delegate to
310
- return _call(region, "create", symbols, price=price)
310
+ return _call(region, "create", skus, price=price)
311
311
 
312
312
  # The harness gives you the parsed RunSpec to feed the logic function.
313
- h = ParseHarness(script_name="static-data", config_path=STATIC_DATA)
314
- spec = h.expect_ok(["create", "-s", "AAPL,MSFT", "--price", "12.5", "--region", "americas", "--env", "prod"])
313
+ h = ParseHarness(script_name="catalog", config_path=CATALOG)
314
+ spec = h.expect_ok(["create", "-s", "WIDGET1,GADGET2", "--price", "12.5", "--region", "americas", "--env", "prod"])
315
315
 
316
- result = create_symbols(spec.region.value, spec.symbols.value, price=spec.price.value)
316
+ result = create_items(spec.region.value, spec.skus.value, price=spec.price.value)
317
317
 
318
- assert result == {"ok": True, "applied": ["AAPL", "MSFT"]}
319
- assert calls == [("americas", "create", ("AAPL", "MSFT"), {"price": 12.5})]
318
+ assert result == {"ok": True, "applied": ["WIDGET1", "GADGET2"]}
319
+ assert calls == [("americas", "create", ("WIDGET1", "GADGET2"), {"price": 12.5})]
320
320
 
321
321
 
322
322
  # ── Harness behaviour itself ──────────────────────────────────────────────────
@@ -352,7 +352,7 @@ class TestHarnessApi:
352
352
  with pytest.raises(ValueError):
353
353
  ParseHarness(script_name="greet")
354
354
  with pytest.raises(ValueError):
355
- ParseHarness(script_name="greet", toml=GREET, config_path=STATIC_DATA)
355
+ ParseHarness(script_name="greet", toml=GREET, config_path=CATALOG)
356
356
 
357
357
  def test_unknown_runnable_raises(self) -> None:
358
358
  with pytest.raises(KeyError):
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes