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.
- {runspec-0.76.0 → runspec-0.76.2}/PKG-INFO +1 -1
- {runspec-0.76.0 → runspec-0.76.2}/pyproject.toml +1 -1
- {runspec-0.76.0 → runspec-0.76.2}/runspec/cli.py +5 -0
- {runspec-0.76.0 → runspec-0.76.2}/runspec/logs.py +68 -1
- {runspec-0.76.0 → runspec-0.76.2}/runspec/runspec.toml +2 -1
- {runspec-0.76.0 → runspec-0.76.2}/runspec.egg-info/PKG-INFO +1 -1
- {runspec-0.76.0 → runspec-0.76.2}/tests/test_logs.py +31 -0
- {runspec-0.76.0 → runspec-0.76.2}/runspec/__init__.py +0 -0
- {runspec-0.76.0 → runspec-0.76.2}/runspec/ansiblegen.py +0 -0
- {runspec-0.76.0 → runspec-0.76.2}/runspec/auditlog.py +0 -0
- {runspec-0.76.0 → runspec-0.76.2}/runspec/become.py +0 -0
- {runspec-0.76.0 → runspec-0.76.2}/runspec/denylist.py +0 -0
- {runspec-0.76.0 → runspec-0.76.2}/runspec/discovery.py +0 -0
- {runspec-0.76.0 → runspec-0.76.2}/runspec/emitutil.py +0 -0
- {runspec-0.76.0 → runspec-0.76.2}/runspec/env.py +0 -0
- {runspec-0.76.0 → runspec-0.76.2}/runspec/errors.py +0 -0
- {runspec-0.76.0 → runspec-0.76.2}/runspec/execute.py +0 -0
- {runspec-0.76.0 → runspec-0.76.2}/runspec/finder.py +0 -0
- {runspec-0.76.0 → runspec-0.76.2}/runspec/inbox.py +0 -0
- {runspec-0.76.0 → runspec-0.76.2}/runspec/inference.py +0 -0
- {runspec-0.76.0 → runspec-0.76.2}/runspec/jump.py +0 -0
- {runspec-0.76.0 → runspec-0.76.2}/runspec/loader.py +0 -0
- {runspec-0.76.0 → runspec-0.76.2}/runspec/logging_setup.py +0 -0
- {runspec-0.76.0 → runspec-0.76.2}/runspec/mock.py +0 -0
- {runspec-0.76.0 → runspec-0.76.2}/runspec/models.py +0 -0
- {runspec-0.76.0 → runspec-0.76.2}/runspec/parser.py +0 -0
- {runspec-0.76.0 → runspec-0.76.2}/runspec/py.typed +0 -0
- {runspec-0.76.0 → runspec-0.76.2}/runspec/rebrand.py +0 -0
- {runspec-0.76.0 → runspec-0.76.2}/runspec/rundeckgen.py +0 -0
- {runspec-0.76.0 → runspec-0.76.2}/runspec/runexec.py +0 -0
- {runspec-0.76.0 → runspec-0.76.2}/runspec/runsdb.py +0 -0
- {runspec-0.76.0 → runspec-0.76.2}/runspec/schemacheck.py +0 -0
- {runspec-0.76.0 → runspec-0.76.2}/runspec/serve.py +0 -0
- {runspec-0.76.0 → runspec-0.76.2}/runspec/stubgen.py +0 -0
- {runspec-0.76.0 → runspec-0.76.2}/runspec/testing.py +0 -0
- {runspec-0.76.0 → runspec-0.76.2}/runspec/types.py +0 -0
- {runspec-0.76.0 → runspec-0.76.2}/runspec/validator.py +0 -0
- {runspec-0.76.0 → runspec-0.76.2}/runspec.egg-info/SOURCES.txt +0 -0
- {runspec-0.76.0 → runspec-0.76.2}/runspec.egg-info/dependency_links.txt +0 -0
- {runspec-0.76.0 → runspec-0.76.2}/runspec.egg-info/entry_points.txt +0 -0
- {runspec-0.76.0 → runspec-0.76.2}/runspec.egg-info/requires.txt +0 -0
- {runspec-0.76.0 → runspec-0.76.2}/runspec.egg-info/top_level.txt +0 -0
- {runspec-0.76.0 → runspec-0.76.2}/setup.cfg +0 -0
- {runspec-0.76.0 → runspec-0.76.2}/tests/__init__.py +0 -0
- {runspec-0.76.0 → runspec-0.76.2}/tests/conftest.py +0 -0
- {runspec-0.76.0 → runspec-0.76.2}/tests/test_agent.py +0 -0
- {runspec-0.76.0 → runspec-0.76.2}/tests/test_ansiblegen.py +0 -0
- {runspec-0.76.0 → runspec-0.76.2}/tests/test_audit.py +0 -0
- {runspec-0.76.0 → runspec-0.76.2}/tests/test_become.py +0 -0
- {runspec-0.76.0 → runspec-0.76.2}/tests/test_cli_discover.py +0 -0
- {runspec-0.76.0 → runspec-0.76.2}/tests/test_cli_emit.py +0 -0
- {runspec-0.76.0 → runspec-0.76.2}/tests/test_cli_init.py +0 -0
- {runspec-0.76.0 → runspec-0.76.2}/tests/test_cli_test.py +0 -0
- {runspec-0.76.0 → runspec-0.76.2}/tests/test_denylist.py +0 -0
- {runspec-0.76.0 → runspec-0.76.2}/tests/test_discovery.py +0 -0
- {runspec-0.76.0 → runspec-0.76.2}/tests/test_env.py +0 -0
- {runspec-0.76.0 → runspec-0.76.2}/tests/test_exec.py +0 -0
- {runspec-0.76.0 → runspec-0.76.2}/tests/test_finder.py +0 -0
- {runspec-0.76.0 → runspec-0.76.2}/tests/test_inbox.py +0 -0
- {runspec-0.76.0 → runspec-0.76.2}/tests/test_inference.py +0 -0
- {runspec-0.76.0 → runspec-0.76.2}/tests/test_jump.py +0 -0
- {runspec-0.76.0 → runspec-0.76.2}/tests/test_loader.py +0 -0
- {runspec-0.76.0 → runspec-0.76.2}/tests/test_log_resilience.py +0 -0
- {runspec-0.76.0 → runspec-0.76.2}/tests/test_logging_setup.py +0 -0
- {runspec-0.76.0 → runspec-0.76.2}/tests/test_mock.py +0 -0
- {runspec-0.76.0 → runspec-0.76.2}/tests/test_models.py +0 -0
- {runspec-0.76.0 → runspec-0.76.2}/tests/test_parser.py +0 -0
- {runspec-0.76.0 → runspec-0.76.2}/tests/test_password.py +0 -0
- {runspec-0.76.0 → runspec-0.76.2}/tests/test_rebrand.py +0 -0
- {runspec-0.76.0 → runspec-0.76.2}/tests/test_run_as_enforce.py +0 -0
- {runspec-0.76.0 → runspec-0.76.2}/tests/test_run_summary.py +0 -0
- {runspec-0.76.0 → runspec-0.76.2}/tests/test_rundeckgen.py +0 -0
- {runspec-0.76.0 → runspec-0.76.2}/tests/test_runsdb.py +0 -0
- {runspec-0.76.0 → runspec-0.76.2}/tests/test_runsdb_events.py +0 -0
- {runspec-0.76.0 → runspec-0.76.2}/tests/test_runsdb_sync.py +0 -0
- {runspec-0.76.0 → runspec-0.76.2}/tests/test_schemacheck.py +0 -0
- {runspec-0.76.0 → runspec-0.76.2}/tests/test_secret_argv.py +0 -0
- {runspec-0.76.0 → runspec-0.76.2}/tests/test_serve.py +0 -0
- {runspec-0.76.0 → runspec-0.76.2}/tests/test_serve_e2e.py +0 -0
- {runspec-0.76.0 → runspec-0.76.2}/tests/test_stubgen.py +0 -0
- {runspec-0.76.0 → runspec-0.76.2}/tests/test_testing.py +0 -0
- {runspec-0.76.0 → runspec-0.76.2}/tests/test_types.py +0 -0
- {runspec-0.76.0 → runspec-0.76.2}/tests/test_typing.py +0 -0
- {runspec-0.76.0 → runspec-0.76.2}/tests/test_validator.py +0 -0
|
@@ -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
|
|
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}
|
|
@@ -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
|
|
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
|