pylinxi 1.0.0__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.
pylinxi/__init__.py ADDED
@@ -0,0 +1,25 @@
1
+ """pylinxi -- durable, self-healing agent flows as plain Python. Zero dependencies."""
2
+ from .flow import Context, ContinueAsNew, Flow, ReplayedError, RunResult, Suspended, flow, idempotency_key, step
3
+ from .journal import (
4
+ Codec,
5
+ Entry,
6
+ FileJournal,
7
+ Journal,
8
+ JournalCorrupt,
9
+ MemoryJournal,
10
+ RunLocked,
11
+ SQLiteJournal,
12
+ default_codec,
13
+ open_journal,
14
+ redact,
15
+ )
16
+ from .policy import CircuitBreaker, CircuitOpen, Policy, Retry, StepFailed, ValidationError
17
+ from .worker import TickReport, Worker
18
+
19
+ __version__ = "1.0.0"
20
+ __all__ = [
21
+ "flow", "step", "idempotency_key", "Flow", "Context", "RunResult", "Suspended", "ContinueAsNew", "ReplayedError",
22
+ "Policy", "Retry", "CircuitBreaker", "CircuitOpen", "StepFailed", "ValidationError",
23
+ "Journal", "MemoryJournal", "FileJournal", "SQLiteJournal", "RunLocked", "JournalCorrupt", "Entry",
24
+ "Codec", "default_codec", "open_journal", "redact", "Worker", "TickReport",
25
+ ]
pylinxi/__main__.py ADDED
@@ -0,0 +1,5 @@
1
+ import sys
2
+
3
+ from .cli import main
4
+
5
+ sys.exit(main())
pylinxi/cli.py ADDED
@@ -0,0 +1,202 @@
1
+ """pylinxi command line: inspect, verify and manage run journals.
2
+
3
+ pylinxi --journal sqlite:///runs.db runs
4
+ pylinxi runs --status failed # journal from $PYLINXI_JOURNAL
5
+ pylinxi show ticket-1001
6
+ pylinxi state ticket-1001 --seq 3 # time travel
7
+ pylinxi verify --all --require-signed # exit code 1 on any failure (CI / audits)
8
+ pylinxi timers --due
9
+ pylinxi delete ticket-1001 --yes
10
+
11
+ Signing keys come from --key kid=secret (repeatable) or $PYLINXI_KEYS="k1=...,k2=...".
12
+ """
13
+ from __future__ import annotations
14
+
15
+ import argparse
16
+ import datetime as _dt
17
+ import json
18
+ import os
19
+ import sys
20
+ import time
21
+ from typing import Any, Dict, List, Optional, Sequence
22
+
23
+ from .journal import Entry, Journal, JournalCorrupt, open_journal
24
+
25
+
26
+ def _parse_keys(pairs: Sequence[str]) -> Dict[str, bytes]:
27
+ keys: Dict[str, bytes] = {}
28
+ for p in pairs:
29
+ if "=" not in p:
30
+ raise SystemExit(f"bad key {p!r}: expected kid=secret")
31
+ kid, secret = p.split("=", 1)
32
+ keys[kid.strip()] = secret.encode()
33
+ return keys
34
+
35
+
36
+ def run_status(entries: List[Entry]) -> str:
37
+ """completed | continued | failed | sleeping (timer) | waiting (signal) | open (running or crashed)"""
38
+ if not entries:
39
+ return "missing"
40
+ last = entries[-1]
41
+ if last.kind == "done":
42
+ return "continued" if last.meta.get("continued_as") else "completed"
43
+ if last.kind == "failed" and last.key == "flow":
44
+ return "failed"
45
+ if last.kind == "suspend":
46
+ return "sleeping" if (last.result or {}).get("wake_at") is not None else "waiting"
47
+ return "open"
48
+
49
+
50
+ def _fmt_ts(ts: float) -> str:
51
+ return _dt.datetime.fromtimestamp(ts).strftime("%Y-%m-%d %H:%M:%S")
52
+
53
+
54
+ def _preview(v: Any, width: int = 48) -> str:
55
+ s = json.dumps(v, default=str) if not isinstance(v, str) else v
56
+ s = s.replace("\n", " ")
57
+ return s if len(s) <= width else s[: width - 1] + "…"
58
+
59
+
60
+ def _table(rows: List[List[str]], headers: List[str]) -> str:
61
+ widths = [max(len(str(x)) for x in col) for col in zip(headers, *rows)]
62
+ line = lambda r: " ".join(str(c).ljust(w) for c, w in zip(r, widths)).rstrip() # noqa: E731
63
+ return "\n".join([line(headers), line(["-" * w for w in widths])] + [line(r) for r in rows])
64
+
65
+
66
+ def _out(args: argparse.Namespace, data: Any, text: str) -> None:
67
+ print(json.dumps(data, indent=2, default=str) if args.json else text)
68
+
69
+
70
+ def cmd_runs(j: Journal, args: argparse.Namespace) -> int:
71
+ rows: List[Dict[str, Any]] = []
72
+ for rid in sorted(j.runs()):
73
+ try:
74
+ es = j.load(rid)
75
+ st = run_status(es)
76
+ except JournalCorrupt:
77
+ es, st = [], "corrupt"
78
+ if args.status and st != args.status:
79
+ continue
80
+ rows.append({"run_id": rid, "status": st, "entries": len(es),
81
+ "updated": es[-1].ts if es else None})
82
+ rows.sort(key=lambda r: r["updated"] or 0, reverse=True)
83
+ rows = rows[: args.limit]
84
+ _out(args, rows, _table([[r["run_id"], r["status"], str(r["entries"]),
85
+ _fmt_ts(r["updated"]) if r["updated"] else ""] for r in rows],
86
+ ["RUN", "STATUS", "ENTRIES", "UPDATED"]) if rows else "no runs")
87
+ return 0
88
+
89
+
90
+ def cmd_show(j: Journal, args: argparse.Namespace) -> int:
91
+ try:
92
+ es = j.load(args.run_id)
93
+ except JournalCorrupt as e:
94
+ print(f"run {args.run_id!r} is corrupt: {e}", file=sys.stderr)
95
+ return 1
96
+ if not es:
97
+ print(f"run {args.run_id!r} not found", file=sys.stderr)
98
+ return 1
99
+ data: List[Dict[str, Any]] = [{"seq": e.seq, "kind": e.kind, "key": e.key, "ts": e.ts, "kid": e.kid,
100
+ "attempts": e.meta.get("attempts"), "healed": e.meta.get("healed"),
101
+ "error": e.meta.get("error"), "result": e.result if e.persisted else "[sensitive]",
102
+ "changed": sorted(e.delta) + [f"-{k}" for k in e.removed]} for e in es]
103
+ rows = [[str(d["seq"]), d["kind"], d["key"], _fmt_ts(d["ts"]),
104
+ "" if d["attempts"] in (None, 1) else str(d["attempts"]), d["healed"] or "",
105
+ ",".join(d["changed"]), _preview(d["error"] or d["result"])] for d in data]
106
+ head = f"run {args.run_id} status={run_status(es)} entries={len(es)}\n\n"
107
+ _out(args, data, head + _table(rows, ["SEQ", "KIND", "KEY", "TIME", "TRIES", "HEALED", "STATE", "RESULT"]))
108
+ return 0
109
+
110
+
111
+ def cmd_state(j: Journal, args: argparse.Namespace) -> int:
112
+ if not j.load(args.run_id):
113
+ print(f"run {args.run_id!r} not found", file=sys.stderr)
114
+ return 1
115
+ print(json.dumps(j.state_at(args.run_id, args.seq), indent=2, default=str))
116
+ return 0
117
+
118
+
119
+ def cmd_verify(j: Journal, args: argparse.Namespace) -> int:
120
+ ids = sorted(j.runs()) if args.all else args.run_ids
121
+ if not ids:
122
+ print("nothing to verify (give run ids or --all)", file=sys.stderr)
123
+ return 2
124
+ results = {rid: j.verify(rid, require_signed=args.require_signed) for rid in ids}
125
+ bad = [r for r, ok in results.items() if not ok]
126
+ _out(args, results, "\n".join(f"{'OK ' if ok else 'FAIL'} {rid}" for rid, ok in results.items())
127
+ + f"\n\n{len(ids) - len(bad)}/{len(ids)} verified")
128
+ return 1 if bad else 0
129
+
130
+
131
+ def cmd_timers(j: Journal, args: argparse.Namespace) -> int:
132
+ now = time.time()
133
+ timers = j.pending_timers(due_before=now if args.due else None)
134
+ data: List[Dict[str, Any]] = [{"run_id": r, "wake_at": w, "due": w <= now} for r, w in timers]
135
+ _out(args, data, _table([[d["run_id"], _fmt_ts(d["wake_at"]), "yes" if d["due"] else ""] for d in data],
136
+ ["RUN", "WAKE AT", "DUE"]) if data else "no pending timers")
137
+ return 0
138
+
139
+
140
+ def cmd_delete(j: Journal, args: argparse.Namespace) -> int:
141
+ if not args.yes:
142
+ print("refusing to delete without --yes (this is permanent)", file=sys.stderr)
143
+ return 2
144
+ for rid in args.run_ids:
145
+ j.delete(rid)
146
+ print(f"deleted {rid}")
147
+ return 0
148
+
149
+
150
+ def build_parser() -> argparse.ArgumentParser:
151
+ p = argparse.ArgumentParser(prog="pylinxi", description="Inspect and manage pylinxi run journals.")
152
+ p.add_argument("--journal", default=os.environ.get("PYLINXI_JOURNAL"),
153
+ help="journal URL (memory://, file://DIR, sqlite:///FILE, postgresql://...); "
154
+ "default $PYLINXI_JOURNAL")
155
+ p.add_argument("--key", action="append", default=[], metavar="KID=SECRET",
156
+ help="signing key for verify (repeatable); default $PYLINXI_KEYS")
157
+ p.add_argument("--json", action="store_true", help="machine-readable output")
158
+ sub = p.add_subparsers(dest="cmd", required=True)
159
+
160
+ s = sub.add_parser("runs", help="list runs")
161
+ s.add_argument("--status", choices=["completed", "continued", "failed", "sleeping", "waiting", "open"])
162
+ s.add_argument("--limit", type=int, default=100)
163
+ s.set_defaults(fn=cmd_runs)
164
+
165
+ s = sub.add_parser("show", help="show a run's journal")
166
+ s.add_argument("run_id")
167
+ s.set_defaults(fn=cmd_show)
168
+
169
+ s = sub.add_parser("state", help="rebuild state, optionally as of an entry")
170
+ s.add_argument("run_id")
171
+ s.add_argument("--seq", type=int)
172
+ s.set_defaults(fn=cmd_state)
173
+
174
+ s = sub.add_parser("verify", help="verify hash chain and signatures")
175
+ s.add_argument("run_ids", nargs="*")
176
+ s.add_argument("--all", action="store_true")
177
+ s.add_argument("--require-signed", action="store_true")
178
+ s.set_defaults(fn=cmd_verify)
179
+
180
+ s = sub.add_parser("timers", help="list runs waiting on timers")
181
+ s.add_argument("--due", action="store_true", help="only timers that are due now")
182
+ s.set_defaults(fn=cmd_timers)
183
+
184
+ s = sub.add_parser("delete", help="permanently delete runs")
185
+ s.add_argument("run_ids", nargs="+")
186
+ s.add_argument("--yes", action="store_true")
187
+ s.set_defaults(fn=cmd_delete)
188
+ return p
189
+
190
+
191
+ def main(argv: Optional[Sequence[str]] = None) -> int:
192
+ args = build_parser().parse_args(argv)
193
+ if not args.journal:
194
+ print("no journal: pass --journal URL or set PYLINXI_JOURNAL", file=sys.stderr)
195
+ return 2
196
+ keys = _parse_keys(args.key or [k for k in os.environ.get("PYLINXI_KEYS", "").split(",") if k])
197
+ with open_journal(args.journal, secret=keys or None) as j:
198
+ return int(args.fn(j, args))
199
+
200
+
201
+ if __name__ == "__main__": # pragma: no cover
202
+ sys.exit(main())