runspec 0.76.0__tar.gz → 0.76.2__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.76.0 → runspec-0.76.2}/PKG-INFO +1 -1
  2. {runspec-0.76.0 → runspec-0.76.2}/pyproject.toml +1 -1
  3. {runspec-0.76.0 → runspec-0.76.2}/runspec/cli.py +5 -0
  4. {runspec-0.76.0 → runspec-0.76.2}/runspec/logs.py +68 -1
  5. {runspec-0.76.0 → runspec-0.76.2}/runspec/runspec.toml +2 -1
  6. {runspec-0.76.0 → runspec-0.76.2}/runspec.egg-info/PKG-INFO +1 -1
  7. {runspec-0.76.0 → runspec-0.76.2}/tests/test_logs.py +31 -0
  8. {runspec-0.76.0 → runspec-0.76.2}/runspec/__init__.py +0 -0
  9. {runspec-0.76.0 → runspec-0.76.2}/runspec/ansiblegen.py +0 -0
  10. {runspec-0.76.0 → runspec-0.76.2}/runspec/auditlog.py +0 -0
  11. {runspec-0.76.0 → runspec-0.76.2}/runspec/become.py +0 -0
  12. {runspec-0.76.0 → runspec-0.76.2}/runspec/denylist.py +0 -0
  13. {runspec-0.76.0 → runspec-0.76.2}/runspec/discovery.py +0 -0
  14. {runspec-0.76.0 → runspec-0.76.2}/runspec/emitutil.py +0 -0
  15. {runspec-0.76.0 → runspec-0.76.2}/runspec/env.py +0 -0
  16. {runspec-0.76.0 → runspec-0.76.2}/runspec/errors.py +0 -0
  17. {runspec-0.76.0 → runspec-0.76.2}/runspec/execute.py +0 -0
  18. {runspec-0.76.0 → runspec-0.76.2}/runspec/finder.py +0 -0
  19. {runspec-0.76.0 → runspec-0.76.2}/runspec/inbox.py +0 -0
  20. {runspec-0.76.0 → runspec-0.76.2}/runspec/inference.py +0 -0
  21. {runspec-0.76.0 → runspec-0.76.2}/runspec/jump.py +0 -0
  22. {runspec-0.76.0 → runspec-0.76.2}/runspec/loader.py +0 -0
  23. {runspec-0.76.0 → runspec-0.76.2}/runspec/logging_setup.py +0 -0
  24. {runspec-0.76.0 → runspec-0.76.2}/runspec/mock.py +0 -0
  25. {runspec-0.76.0 → runspec-0.76.2}/runspec/models.py +0 -0
  26. {runspec-0.76.0 → runspec-0.76.2}/runspec/parser.py +0 -0
  27. {runspec-0.76.0 → runspec-0.76.2}/runspec/py.typed +0 -0
  28. {runspec-0.76.0 → runspec-0.76.2}/runspec/rebrand.py +0 -0
  29. {runspec-0.76.0 → runspec-0.76.2}/runspec/rundeckgen.py +0 -0
  30. {runspec-0.76.0 → runspec-0.76.2}/runspec/runexec.py +0 -0
  31. {runspec-0.76.0 → runspec-0.76.2}/runspec/runsdb.py +0 -0
  32. {runspec-0.76.0 → runspec-0.76.2}/runspec/schemacheck.py +0 -0
  33. {runspec-0.76.0 → runspec-0.76.2}/runspec/serve.py +0 -0
  34. {runspec-0.76.0 → runspec-0.76.2}/runspec/stubgen.py +0 -0
  35. {runspec-0.76.0 → runspec-0.76.2}/runspec/testing.py +0 -0
  36. {runspec-0.76.0 → runspec-0.76.2}/runspec/types.py +0 -0
  37. {runspec-0.76.0 → runspec-0.76.2}/runspec/validator.py +0 -0
  38. {runspec-0.76.0 → runspec-0.76.2}/runspec.egg-info/SOURCES.txt +0 -0
  39. {runspec-0.76.0 → runspec-0.76.2}/runspec.egg-info/dependency_links.txt +0 -0
  40. {runspec-0.76.0 → runspec-0.76.2}/runspec.egg-info/entry_points.txt +0 -0
  41. {runspec-0.76.0 → runspec-0.76.2}/runspec.egg-info/requires.txt +0 -0
  42. {runspec-0.76.0 → runspec-0.76.2}/runspec.egg-info/top_level.txt +0 -0
  43. {runspec-0.76.0 → runspec-0.76.2}/setup.cfg +0 -0
  44. {runspec-0.76.0 → runspec-0.76.2}/tests/__init__.py +0 -0
  45. {runspec-0.76.0 → runspec-0.76.2}/tests/conftest.py +0 -0
  46. {runspec-0.76.0 → runspec-0.76.2}/tests/test_agent.py +0 -0
  47. {runspec-0.76.0 → runspec-0.76.2}/tests/test_ansiblegen.py +0 -0
  48. {runspec-0.76.0 → runspec-0.76.2}/tests/test_audit.py +0 -0
  49. {runspec-0.76.0 → runspec-0.76.2}/tests/test_become.py +0 -0
  50. {runspec-0.76.0 → runspec-0.76.2}/tests/test_cli_discover.py +0 -0
  51. {runspec-0.76.0 → runspec-0.76.2}/tests/test_cli_emit.py +0 -0
  52. {runspec-0.76.0 → runspec-0.76.2}/tests/test_cli_init.py +0 -0
  53. {runspec-0.76.0 → runspec-0.76.2}/tests/test_cli_test.py +0 -0
  54. {runspec-0.76.0 → runspec-0.76.2}/tests/test_denylist.py +0 -0
  55. {runspec-0.76.0 → runspec-0.76.2}/tests/test_discovery.py +0 -0
  56. {runspec-0.76.0 → runspec-0.76.2}/tests/test_env.py +0 -0
  57. {runspec-0.76.0 → runspec-0.76.2}/tests/test_exec.py +0 -0
  58. {runspec-0.76.0 → runspec-0.76.2}/tests/test_finder.py +0 -0
  59. {runspec-0.76.0 → runspec-0.76.2}/tests/test_inbox.py +0 -0
  60. {runspec-0.76.0 → runspec-0.76.2}/tests/test_inference.py +0 -0
  61. {runspec-0.76.0 → runspec-0.76.2}/tests/test_jump.py +0 -0
  62. {runspec-0.76.0 → runspec-0.76.2}/tests/test_loader.py +0 -0
  63. {runspec-0.76.0 → runspec-0.76.2}/tests/test_log_resilience.py +0 -0
  64. {runspec-0.76.0 → runspec-0.76.2}/tests/test_logging_setup.py +0 -0
  65. {runspec-0.76.0 → runspec-0.76.2}/tests/test_mock.py +0 -0
  66. {runspec-0.76.0 → runspec-0.76.2}/tests/test_models.py +0 -0
  67. {runspec-0.76.0 → runspec-0.76.2}/tests/test_parser.py +0 -0
  68. {runspec-0.76.0 → runspec-0.76.2}/tests/test_password.py +0 -0
  69. {runspec-0.76.0 → runspec-0.76.2}/tests/test_rebrand.py +0 -0
  70. {runspec-0.76.0 → runspec-0.76.2}/tests/test_run_as_enforce.py +0 -0
  71. {runspec-0.76.0 → runspec-0.76.2}/tests/test_run_summary.py +0 -0
  72. {runspec-0.76.0 → runspec-0.76.2}/tests/test_rundeckgen.py +0 -0
  73. {runspec-0.76.0 → runspec-0.76.2}/tests/test_runsdb.py +0 -0
  74. {runspec-0.76.0 → runspec-0.76.2}/tests/test_runsdb_events.py +0 -0
  75. {runspec-0.76.0 → runspec-0.76.2}/tests/test_runsdb_sync.py +0 -0
  76. {runspec-0.76.0 → runspec-0.76.2}/tests/test_schemacheck.py +0 -0
  77. {runspec-0.76.0 → runspec-0.76.2}/tests/test_secret_argv.py +0 -0
  78. {runspec-0.76.0 → runspec-0.76.2}/tests/test_serve.py +0 -0
  79. {runspec-0.76.0 → runspec-0.76.2}/tests/test_serve_e2e.py +0 -0
  80. {runspec-0.76.0 → runspec-0.76.2}/tests/test_stubgen.py +0 -0
  81. {runspec-0.76.0 → runspec-0.76.2}/tests/test_testing.py +0 -0
  82. {runspec-0.76.0 → runspec-0.76.2}/tests/test_types.py +0 -0
  83. {runspec-0.76.0 → runspec-0.76.2}/tests/test_typing.py +0 -0
  84. {runspec-0.76.0 → runspec-0.76.2}/tests/test_validator.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: runspec
3
- Version: 0.76.0
3
+ Version: 0.76.2
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.76.0"
3
+ version = "0.76.2"
4
4
  description = "A language-agnostic, TOML-based interface specification for anything runnable"
5
5
  readme = "README.md"
6
6
  license = { file = "../../LICENSE" }
@@ -1188,6 +1188,11 @@ def cmd_logs(args: list[str]) -> None:
1188
1188
  if verb == "view":
1189
1189
  if not runnable:
1190
1190
  print("✗ A runnable is required: runspec logs <runnable>")
1191
+ names = _logs.runnables_with_logs(_logs.scope_dirs(parsed.scope.value, destructive=False) or _logs.log_dirs())
1192
+ if names:
1193
+ print(" Runnables with logs: " + ", ".join(names))
1194
+ else:
1195
+ print(" No runnable has logs here yet.")
1191
1196
  sys.exit(1)
1192
1197
  since = _logs.parse_duration(parsed.since.value) if parsed.since.value else None
1193
1198
  _logs.view(
@@ -266,7 +266,8 @@ def collect_records(
266
266
  ) -> list[tuple[dict[str, Any], str]]:
267
267
  """Merge a runnable's files into one timestamp-sorted list of (record, raw).
268
268
 
269
- Filters: ``since`` (records newer than now-since), ``run`` (one run_id),
269
+ Filters: ``since`` (records newer than now-since), ``run`` (one run_id, or
270
+ a unique prefix of one — the 8 characters the text view prints are enough),
270
271
  ``user`` (every record of any run that user launched — resolved from the
271
272
  run_summary's ``extra.user``).
272
273
  """
@@ -293,6 +294,9 @@ def collect_records(
293
294
  user_of_run[extra["run_id"]] = extra.get("user", "")
294
295
  parsed.append((rec, ln))
295
296
 
297
+ if run:
298
+ run = resolve_run_id(run, (_line_extra(rec).get("run_id", "") for rec, _ in parsed), runnable)
299
+
296
300
  cutoff = datetime.now(timezone.utc) - since if since else None
297
301
 
298
302
  def keep(rec: dict[str, Any]) -> bool:
@@ -313,6 +317,65 @@ def collect_records(
313
317
  return parsed
314
318
 
315
319
 
320
+ def _line_extra(rec: dict[str, Any]) -> dict[str, Any]:
321
+ extra = rec.get("extra")
322
+ return extra if isinstance(extra, dict) else {}
323
+
324
+
325
+ def resolve_run_id(run: str, run_ids: Iterable[str], runnable: str | None = None) -> str:
326
+ """The full run id ``run`` names among ``run_ids``: an exact id, else the one
327
+ id it is a prefix of (case-insensitive, like the hex it is). Raises
328
+ ``ValueError`` when the prefix matches several runs (naming them) or none."""
329
+ ids = {rid for rid in run_ids if rid}
330
+ if run in ids:
331
+ return run
332
+ want = run.strip().lower()
333
+ matches = sorted(rid for rid in ids if want and rid.lower().startswith(want))
334
+ if len(matches) == 1:
335
+ return matches[0]
336
+ where = f" in {runnable}'s logs" if runnable else ""
337
+ if not matches:
338
+ raise ValueError(f"no run matching {run!r}{where}")
339
+ shown = ", ".join(matches[:10]) + (f" (+{len(matches) - 10} more)" if len(matches) > 10 else "")
340
+ raise ValueError(f"{run!r} matches {len(matches)} runs{where}: {shown} — give more of the id")
341
+
342
+
343
+ def runnables_with_logs(dirs: Iterable[Path]) -> list[str]:
344
+ """Sorted names of the runnables that have log files in ``dirs`` (per-run,
345
+ archive or single-mode, including the inbox) — what ``runspec logs`` can show.
346
+
347
+ ``~/logs`` is shared with whatever else logs there, so a plain ``{name}.log``
348
+ counts only when it holds runspec records (see :func:`_is_runspec_log`);
349
+ per-run files and archives are known by their names."""
350
+ names: set[str] = set()
351
+ for f in _discover(dirs, None, include_inbox=True):
352
+ name = _runnable_of(f)
353
+ if name and name not in names and (_is_managed(f) or _is_runspec_log(f)):
354
+ names.add(name)
355
+ return sorted(names)
356
+
357
+
358
+ def _is_runspec_log(path: Path, *, max_lines: int = 5) -> bool:
359
+ """True when one of the first ``max_lines`` lines of ``path`` is a runspec
360
+ JSON record — an object with ``ts``/``level``/``message`` and an
361
+ ``extra.run_id``, which runspec stamps on every record it writes."""
362
+ try:
363
+ with path.open("r", encoding="utf-8", errors="replace") as fh:
364
+ for _ in range(max_lines):
365
+ line = fh.readline(65536)
366
+ if not line:
367
+ return False
368
+ try:
369
+ rec = json.loads(line)
370
+ except ValueError:
371
+ continue
372
+ if isinstance(rec, dict) and {"ts", "level", "message"} <= rec.keys() and _line_extra(rec).get("run_id"):
373
+ return True
374
+ except OSError:
375
+ pass
376
+ return False
377
+
378
+
316
379
  def _file_run_id(name: str) -> str | None:
317
380
  from .inbox import file_run_id
318
381
 
@@ -411,6 +474,10 @@ def view(
411
474
  out.flush()
412
475
  if not follow:
413
476
  return
477
+ if run and records:
478
+ # Keep following the run the prefix named, even if a later run's id
479
+ # happens to share it.
480
+ run = _line_extra(records[0][0]).get("run_id") or run
414
481
  seen = {(r.get("ts", ""), r.get("extra", {}).get("run_id", ""), r.get("message", "")) for r, _ in records}
415
482
  while True:
416
483
  time.sleep(poll_interval)
@@ -276,6 +276,7 @@ autonomy = "autonomous"
276
276
  examples = [
277
277
  {cmd = "runspec logs deploy", description = "Merged view of every invocation, as one stream"},
278
278
  {cmd = "runspec logs deploy --since 1h --user alice", description = "Filter by recency and operator"},
279
+ {cmd = "runspec logs deploy --run 3f2a9c1e", description = "One invocation, by the run id prefix shown after run="},
279
280
  {cmd = "runspec logs deploy --follow", description = "Live tail across invocations"},
280
281
  {cmd = "runspec logs deploy --json", description = "Raw JSON lines (for jq)"},
281
282
  {cmd = "runspec logs status", description = "Per-runnable file count + disk usage inventory"},
@@ -295,7 +296,7 @@ runnable = {type = "str", description = "Runnable to status/prune/compact
295
296
  follow = {type = "flag", description = "Live tail across invocations (view)", short = "-f", default = false}
296
297
  since = {type = "str", description = "Only show records newer than this (view), e.g. 1h, 30m, 7d", required = false}
297
298
  user = {type = "str", description = "Only show runs launched by this user (view)", short = "-u", required = false}
298
- run = {type = "str", description = "Only show one invocation by run_id (view)", required = false}
299
+ run = {type = "str", description = "Only show one invocation (view): its run id, or a unique prefix of it such as the 8 characters after run= in the output", required = false}
299
300
  json = {type = "flag", description = "Emit JSON instead of formatted text (view / status / prune / compact / share)", default = false}
300
301
  older-than = {type = "str", description = "Age threshold for prune/compact, e.g. 7d, 90d, 24h", required = false}
301
302
  max-files = {type = "int", description = "Keep only the newest N files per runnable (prune)", required = false}
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: runspec
3
- Version: 0.76.0
3
+ Version: 0.76.2
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
@@ -112,6 +112,37 @@ class TestView:
112
112
  recs = logs.collect_records([tmp_path], "deploy", run=_id("r2"))
113
113
  assert [r["extra"]["run_id"] for r, _ in recs] == [_id("r2")]
114
114
 
115
+ def test_run_filter_accepts_the_printed_prefix(self, tmp_path):
116
+ _write_run(tmp_path, "deploy", "r1", "20260601T100000Z", _summary("r1", "deploy", ts="2026-06-01T10:00:00+00:00"))
117
+ _write_run(tmp_path, "deploy", "r2", "20260601T110000Z", _summary("r2", "deploy", ts="2026-06-01T11:00:00+00:00"))
118
+ recs = logs.collect_records([tmp_path], "deploy", run=_id("r2")[:8].upper())
119
+ assert [r["extra"]["run_id"] for r, _ in recs] == [_id("r2")]
120
+
121
+ def test_run_filter_unknown_prefix_is_an_error(self, tmp_path):
122
+ _write_run(tmp_path, "deploy", "r1", "20260601T100000Z", _summary("r1", "deploy", ts="2026-06-01T10:00:00+00:00"))
123
+ with pytest.raises(ValueError, match="no run matching 'zzzz' in deploy's logs"):
124
+ logs.collect_records([tmp_path], "deploy", run="zzzz")
125
+
126
+ def test_resolve_run_id(self):
127
+ ids = ["3f2a9c1e-aaaa", "3f2a0000-bbbb", "77777777-cccc"]
128
+ assert logs.resolve_run_id("77", ids) == "77777777-cccc"
129
+ assert logs.resolve_run_id("3f2a9", ids) == "3f2a9c1e-aaaa"
130
+ assert logs.resolve_run_id("3f2a0000-bbbb", ids) == "3f2a0000-bbbb"
131
+ with pytest.raises(ValueError, match="matches 2 runs: 3f2a0000-bbbb, 3f2a9c1e-aaaa"):
132
+ logs.resolve_run_id("3f2a", ids)
133
+
134
+ def test_runnables_with_logs(self, tmp_path):
135
+ _write_run(tmp_path, "deploy", "r1", "20260601T100000Z", _summary("r1", "deploy", ts="2026-06-01T10:00:00+00:00"))
136
+ _write_run(tmp_path, "backup.web", "r2", "20260601T100000Z", _summary("r2", "backup.web", ts="2026-06-01T10:00:00+00:00"))
137
+ (tmp_path / "single.log").write_text(_line(_id("r3"), "hi", ts="2026-06-01T10:00:00+00:00") + "\n", encoding="utf-8")
138
+ (tmp_path / "audit.jsonl").write_text("", encoding="utf-8")
139
+ # Other scripts' logs (shared ~/logs) aren't runnables: plain text, JSON
140
+ # without a run id, empty.
141
+ (tmp_path / "backup-script.log").write_text("2026-06-01 10:00 starting backup\n", encoding="utf-8")
142
+ (tmp_path / "other.log").write_text(json.dumps({"ts": "x", "level": "INFO", "message": "m"}) + "\n", encoding="utf-8")
143
+ (tmp_path / "empty.log").write_text("", encoding="utf-8")
144
+ assert logs.runnables_with_logs([tmp_path]) == ["backup.web", "deploy", "single"]
145
+
115
146
  def test_json_passthrough(self, tmp_path):
116
147
  raw = _summary(_id("r1"), "deploy", ts="2026-06-01T10:00:00+00:00")
117
148
  _write_run(tmp_path, "deploy", "r1", "20260601T100000Z", raw)
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
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