pylinxi 1.0.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.
- pylinxi-1.0.0/LICENSE +21 -0
- pylinxi-1.0.0/PKG-INFO +316 -0
- pylinxi-1.0.0/README.md +281 -0
- pylinxi-1.0.0/pylinxi/__init__.py +25 -0
- pylinxi-1.0.0/pylinxi/__main__.py +5 -0
- pylinxi-1.0.0/pylinxi/cli.py +202 -0
- pylinxi-1.0.0/pylinxi/flow.py +653 -0
- pylinxi-1.0.0/pylinxi/journal.py +607 -0
- pylinxi-1.0.0/pylinxi/otel.py +97 -0
- pylinxi-1.0.0/pylinxi/policy.py +240 -0
- pylinxi-1.0.0/pylinxi/postgres.py +125 -0
- pylinxi-1.0.0/pylinxi/py.typed +0 -0
- pylinxi-1.0.0/pylinxi/worker.py +112 -0
- pylinxi-1.0.0/pylinxi.egg-info/PKG-INFO +316 -0
- pylinxi-1.0.0/pylinxi.egg-info/SOURCES.txt +26 -0
- pylinxi-1.0.0/pylinxi.egg-info/dependency_links.txt +1 -0
- pylinxi-1.0.0/pylinxi.egg-info/entry_points.txt +2 -0
- pylinxi-1.0.0/pylinxi.egg-info/requires.txt +13 -0
- pylinxi-1.0.0/pylinxi.egg-info/top_level.txt +1 -0
- pylinxi-1.0.0/pyproject.toml +64 -0
- pylinxi-1.0.0/setup.cfg +4 -0
- pylinxi-1.0.0/tests/test_cli.py +95 -0
- pylinxi-1.0.0/tests/test_core.py +271 -0
- pylinxi-1.0.0/tests/test_features.py +270 -0
- pylinxi-1.0.0/tests/test_otel.py +129 -0
- pylinxi-1.0.0/tests/test_postgres.py +132 -0
- pylinxi-1.0.0/tests/test_regressions.py +528 -0
- pylinxi-1.0.0/tests/test_worker.py +97 -0
pylinxi-1.0.0/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 pylinxi contributors
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
pylinxi-1.0.0/PKG-INFO
ADDED
|
@@ -0,0 +1,316 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: pylinxi
|
|
3
|
+
Version: 1.0.0
|
|
4
|
+
Summary: Durable, self-healing agent flows as plain Python. Zero dependencies.
|
|
5
|
+
License-Expression: MIT
|
|
6
|
+
Project-URL: Homepage, https://github.com/karawash-projects/pylinxi
|
|
7
|
+
Project-URL: Repository, https://github.com/karawash-projects/pylinxi
|
|
8
|
+
Project-URL: Issues, https://github.com/karawash-projects/pylinxi/issues
|
|
9
|
+
Project-URL: Changelog, https://github.com/karawash-projects/pylinxi/blob/main/CHANGELOG.md
|
|
10
|
+
Keywords: agents,llm,workflow,durable-execution,langgraph-alternative,orchestration
|
|
11
|
+
Classifier: Development Status :: 4 - Beta
|
|
12
|
+
Classifier: Intended Audience :: Developers
|
|
13
|
+
Classifier: Programming Language :: Python :: 3
|
|
14
|
+
Classifier: Programming Language :: Python :: 3.9
|
|
15
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
16
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
17
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
18
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
19
|
+
Classifier: Framework :: AsyncIO
|
|
20
|
+
Classifier: Typing :: Typed
|
|
21
|
+
Requires-Python: >=3.9
|
|
22
|
+
Description-Content-Type: text/markdown
|
|
23
|
+
License-File: LICENSE
|
|
24
|
+
Provides-Extra: postgres
|
|
25
|
+
Requires-Dist: psycopg[binary]>=3.1; extra == "postgres"
|
|
26
|
+
Provides-Extra: otel
|
|
27
|
+
Requires-Dist: opentelemetry-api>=1.20; extra == "otel"
|
|
28
|
+
Provides-Extra: dev
|
|
29
|
+
Requires-Dist: psycopg[binary]>=3.1; extra == "dev"
|
|
30
|
+
Requires-Dist: opentelemetry-sdk>=1.20; extra == "dev"
|
|
31
|
+
Requires-Dist: ruff>=0.5; extra == "dev"
|
|
32
|
+
Requires-Dist: mypy>=1.10; extra == "dev"
|
|
33
|
+
Requires-Dist: build; extra == "dev"
|
|
34
|
+
Dynamic: license-file
|
|
35
|
+
|
|
36
|
+
# pylinxi
|
|
37
|
+
|
|
38
|
+
[](https://github.com/karawash-projects/pylinxi/actions/workflows/ci.yml)
|
|
39
|
+
|
|
40
|
+
Durable, self-healing agent flows written as **plain Python**. No graph to wire. The core has zero dependencies; Postgres and OpenTelemetry are optional extras. Python 3.9+.
|
|
41
|
+
|
|
42
|
+
```python
|
|
43
|
+
import asyncio
|
|
44
|
+
from pylinxi import flow, step, idempotency_key, Policy, Retry, SQLiteJournal
|
|
45
|
+
|
|
46
|
+
@step(policy=Policy(retry=Retry(attempts=4), validate=is_json, repair=feed_error_back))
|
|
47
|
+
async def classify(message):
|
|
48
|
+
return await llm(f"Classify: {message}")
|
|
49
|
+
|
|
50
|
+
@step
|
|
51
|
+
def refund(order):
|
|
52
|
+
return billing.refund(order, idempotency_key=idempotency_key())
|
|
53
|
+
|
|
54
|
+
@flow(journal=SQLiteJournal("runs.db", secret=KEY))
|
|
55
|
+
async def support(ctx, message, order):
|
|
56
|
+
intent = await classify(message)
|
|
57
|
+
if intent == "refund": # branching is just `if`
|
|
58
|
+
charges, history = await asyncio.gather(lookup(order), past_tickets(order))
|
|
59
|
+
if not await ctx.wait_for("approval", timeout=86400, default=False):
|
|
60
|
+
return "escalated"
|
|
61
|
+
return await refund(order)
|
|
62
|
+
return await answer_faq(message)
|
|
63
|
+
|
|
64
|
+
r = await support.run("charged twice", "A-1001", run_id="t-1001") # suspends at approval
|
|
65
|
+
r = await support.resume("t-1001", signals={"approval": True}) # hours later, any process
|
|
66
|
+
```
|
|
67
|
+
|
|
68
|
+
```bash
|
|
69
|
+
pip install pylinxi # core
|
|
70
|
+
pip install "pylinxi[postgres,otel]" # production extras
|
|
71
|
+
```
|
|
72
|
+
|
|
73
|
+
---
|
|
74
|
+
|
|
75
|
+
## Why
|
|
76
|
+
|
|
77
|
+
| Gap in graph frameworks | pylinxi |
|
|
78
|
+
|---|---|
|
|
79
|
+
| **Manual graph wiring** for branching | The flow *is* the code: `if`, `match`, loops, `asyncio.gather`. `ctx.route()` replaces conditional edges in one line. Steps are plain functions outside a flow, so they unit-test without a harness. |
|
|
80
|
+
| **Weak real-time self-healing** | One declarative policy per step: retry → validate → **repair** (rewrite inputs between attempts) → fallback → circuit breaker. Crashes heal by **resume**: finished steps replay from the journal. A `Worker` resumes due timers and transient failures fleet-wide. |
|
|
81
|
+
| **Heavy state serialization** | Append-only **delta** journal: each entry stores one step's result plus only the state keys that changed. Rebuild any point in time with `state_at()`. Long loops roll over with `continue_as_new()`. |
|
|
82
|
+
| **Enterprise security** | HMAC-signed hash chain with key rotation, automatic redaction, never-persisted sensitive steps, per-run locks, retention deletes, idempotency keys, OTel tracing, a `verify` CLI for audits. |
|
|
83
|
+
|
|
84
|
+
---
|
|
85
|
+
|
|
86
|
+
## Concepts in 60 seconds
|
|
87
|
+
|
|
88
|
+
- A **flow** is an `async def f(ctx, ...)` decorated with `@flow`. Run it with `await f.run(...)`.
|
|
89
|
+
- A **step** is any function decorated with `@step` (sync, async, or a generator for streaming). Calling it inside a flow journals its result.
|
|
90
|
+
- A **journal** stores each run as a signed list of entries. On `resume(run_id)` the flow function runs again from the top; completed steps return their recorded result instantly and execution continues at the first unfinished step.
|
|
91
|
+
- **The one rule:** code *outside* steps must be deterministic. Put I/O, LLM calls, randomness and clocks inside steps, or use `ctx.now()` / `ctx.uuid()`. If flow code changes incompatibly between a crash and a resume, pylinxi emits a `divergence` event; use `ctx.patched()` to change code safely.
|
|
92
|
+
|
|
93
|
+
### Replay guarantees
|
|
94
|
+
|
|
95
|
+
- **Concurrency.** Tasks created by flow code (`asyncio.gather`, `create_task`, `TaskGroup`) each get their own key scope (`t0/`, `t1/`, …) in creation order, so steps inside concurrent branches replay correctly whichever branch finishes first. When a run suspends, fails or finishes, branches still running are cancelled before it returns, so they can't act twice.
|
|
96
|
+
- **Handled failures stay handled.** If flow code catches a `StepFailed` and carries on, resuming re-raises the same failure (wrapping a `ReplayedError`) instead of re-running the step. A failure that ended the run is retried on resume.
|
|
97
|
+
- **Nested steps.** A `@step` called from inside another step is a plain call; the outer step's result is the checkpoint.
|
|
98
|
+
- **Suspension can't be swallowed.** `Suspended` and `ContinueAsNew` derive from `BaseException`, so `except Exception` in flow code won't catch them.
|
|
99
|
+
- **One executor per run.** `run`/`resume` hold a per-run lock (in-process for every journal, cross-process for file/SQLite on POSIX, database locks for Postgres). A second caller gets `RunLocked`.
|
|
100
|
+
- **Inputs are journaled in clear** so runs can resume. pylinxi therefore refuses flow inputs, initial state, signals or `continue_as_new` payloads containing secret-looking keys; pass secrets by reference and load them in a `@step(sensitive=True)`.
|
|
101
|
+
|
|
102
|
+
---
|
|
103
|
+
|
|
104
|
+
## Self-healing
|
|
105
|
+
|
|
106
|
+
```python
|
|
107
|
+
from pylinxi import Policy, Retry, CircuitBreaker
|
|
108
|
+
|
|
109
|
+
crm = CircuitBreaker(threshold=5, reset_after=30) # share one per dependency
|
|
110
|
+
|
|
111
|
+
@step(policy=Policy(
|
|
112
|
+
retry=Retry(attempts=4, backoff=0.5, factor=2, max_backoff=10, on=(ConnectionError, TimeoutError)),
|
|
113
|
+
timeout=20, # per attempt
|
|
114
|
+
validate=lambda out: "intent" in out, # False -> ValidationError -> retried
|
|
115
|
+
repair=lambda err, args, kwargs, attempt: ((args[0] + f"\nFix: {err}",), kwargs),
|
|
116
|
+
fallback=lambda *a, **k: {"intent": "unknown"}, # after retries, or when breaker is open
|
|
117
|
+
breaker=crm,
|
|
118
|
+
))
|
|
119
|
+
def classify(prompt): ...
|
|
120
|
+
```
|
|
121
|
+
|
|
122
|
+
| Field | Meaning |
|
|
123
|
+
|---|---|
|
|
124
|
+
| `retry` | `Retry(attempts, backoff, factor, max_backoff, jitter, on)`. Default: 1 attempt. |
|
|
125
|
+
| `timeout` | Seconds per attempt. Sync steps run in a thread, so a timed-out thread can't be killed; prefer async clients for hard timeouts. |
|
|
126
|
+
| `validate` | `fn(result) -> bool` (may be async). Validation failures are always retried. |
|
|
127
|
+
| `repair` | `fn(error, args, kwargs, attempt) -> (args, kwargs) | None` (may be async). Runs between attempts. |
|
|
128
|
+
| `fallback` | `fn(*args, **kwargs)` used when attempts are exhausted or the breaker is open. |
|
|
129
|
+
| `breaker` | `CircuitBreaker(threshold, reset_after)`; closed → open → half-open. |
|
|
130
|
+
| `aggregate` | Streaming steps: `fn(chunks) -> result`. Default joins strings, else keeps the list. |
|
|
131
|
+
| `thread` | Run plain sync steps in a worker thread (default `True`). |
|
|
132
|
+
|
|
133
|
+
Each completed step's journal entry records `attempts`, `healed` (`"retry"` / `"fallback"`) and the errors seen.
|
|
134
|
+
|
|
135
|
+
### Fleet-level healing: `Worker`
|
|
136
|
+
|
|
137
|
+
```python
|
|
138
|
+
from pylinxi import Worker
|
|
139
|
+
|
|
140
|
+
worker = Worker(journal, [support, onboarding], retry_failed=True, max_failures=5, backoff=30)
|
|
141
|
+
await worker.run_forever(interval=5) # or: report = await worker.tick()
|
|
142
|
+
```
|
|
143
|
+
|
|
144
|
+
Each tick resumes runs whose timers are due and (optionally) runs that *ended* in an unhandled failure, with exponential backoff and a give-up limit. Failures your flow caught are left alone. One run that errors while resuming is reported in `TickReport.errors` and never blocks the others. Run several workers against a `PostgresJournal`; per-run locks make them skip runs another worker owns.
|
|
145
|
+
|
|
146
|
+
---
|
|
147
|
+
|
|
148
|
+
## Flow API (`ctx`)
|
|
149
|
+
|
|
150
|
+
| Call | What it does |
|
|
151
|
+
|---|---|
|
|
152
|
+
| `await my_step(...)` | Journaled call of a `@step` function. |
|
|
153
|
+
| `await ctx.step(fn, *args, name=, policy=, sensitive=)` | Journaled call of any callable (lambdas, third-party functions). |
|
|
154
|
+
| `await ctx.map(fn, items, concurrency=8)` | Parallel fan-out; each item is its own checkpoint. Siblings finish before an error surfaces, so a resume re-runs only failed items. |
|
|
155
|
+
| `await ctx.route(label, {"a": step_a, ...}, *args, default=)` | Dispatch by label. |
|
|
156
|
+
| `await ctx.wait_for(signal, timeout=None, default=None)` | Wait for `run/resume(..., signals={signal: value})`. Signals are journaled on delivery, so early ones aren't lost and repeats queue in order. With `timeout`, returns `default` once it expires. |
|
|
157
|
+
| `await ctx.sleep(seconds, inline_max=1.0)` | Durable sleep. Short waits sleep in-process; longer ones suspend and free the worker. |
|
|
158
|
+
| `await ctx.now()` / `await ctx.uuid()` | Clock / random id recorded once, identical on replay. |
|
|
159
|
+
| `ctx.patched("change-id")` | `True` for runs that reach this point on new code, `False` for runs already past it. |
|
|
160
|
+
| `ctx.continue_as_new(*args, **kwargs)` | End this run; continue as `run_id.g1`, `.g2`… with the current state. |
|
|
161
|
+
| `ctx.state` | Dict of flow state. Changes are journaled as deltas at the next checkpoint. |
|
|
162
|
+
| `ctx.run_id`, `ctx.generation`, `ctx.replaying` | Run metadata. |
|
|
163
|
+
| `pylinxi.idempotency_key()` | Inside a step: `"<run_id>:<step_key>"`, stable across retries and resumes. |
|
|
164
|
+
|
|
165
|
+
`@step(sensitive=True)` keeps a step's result out of the journal entirely; it is recomputed on resume. Use it for tokens and credentials.
|
|
166
|
+
|
|
167
|
+
### Running
|
|
168
|
+
|
|
169
|
+
```python
|
|
170
|
+
r = await support.run(*args, run_id="t-1", state={"tier": "gold"}, signals={...}, **kwargs)
|
|
171
|
+
r = await support.resume("t-1", signals={"approval": True})
|
|
172
|
+
r = support.run_sync(...) # outside an event loop
|
|
173
|
+
|
|
174
|
+
r.status # "completed" | "suspended" | "failed"
|
|
175
|
+
r.output, r.state, r.waiting_for, r.wake_at, r.error, r.events
|
|
176
|
+
```
|
|
177
|
+
|
|
178
|
+
### Streaming
|
|
179
|
+
|
|
180
|
+
Generator steps stream chunks as events; the journal stores only the aggregate.
|
|
181
|
+
|
|
182
|
+
```python
|
|
183
|
+
@step
|
|
184
|
+
async def answer(prompt):
|
|
185
|
+
async for tok in llm.stream(prompt):
|
|
186
|
+
yield tok
|
|
187
|
+
|
|
188
|
+
@flow
|
|
189
|
+
async def chat(ctx, prompt):
|
|
190
|
+
return await answer(prompt)
|
|
191
|
+
|
|
192
|
+
async for ev in chat.stream("hello", run_id="chat-1"):
|
|
193
|
+
if ev["event"] == "chunk":
|
|
194
|
+
await websocket.send(ev["chunk"])
|
|
195
|
+
elif ev["event"] == "stream_reset": # a retry restarted the stream
|
|
196
|
+
await websocket.send({"reset": True})
|
|
197
|
+
elif ev["event"] == "result":
|
|
198
|
+
final = ev["result"] # RunResult
|
|
199
|
+
```
|
|
200
|
+
|
|
201
|
+
### Events
|
|
202
|
+
|
|
203
|
+
Pass `on_event=fn` (or a list) to `@flow`, `run`, or `resume`. Every event has `event`, `key`, `run_id`, `flow`:
|
|
204
|
+
|
|
205
|
+
`run_started`, `started`, `completed`, `replayed`, `attempt_failed`, `repaired`, `fallback`, `breaker_open`, `chunk`, `stream_reset`, `step_failed`, `step_crashed`, `signal_timeout`, `suspended`, `continued_as_new`, `divergence`, `failed`, `crashed`, `finished`.
|
|
206
|
+
|
|
207
|
+
A failing observer never breaks a run.
|
|
208
|
+
|
|
209
|
+
---
|
|
210
|
+
|
|
211
|
+
## Journals
|
|
212
|
+
|
|
213
|
+
```python
|
|
214
|
+
from pylinxi import MemoryJournal, FileJournal, SQLiteJournal, open_journal
|
|
215
|
+
from pylinxi.postgres import PostgresJournal
|
|
216
|
+
|
|
217
|
+
j = open_journal("sqlite:///runs.db", secret={"2026-q3": k1, "2026-q4": k2})
|
|
218
|
+
```
|
|
219
|
+
|
|
220
|
+
| Backend | Use for |
|
|
221
|
+
|---|---|
|
|
222
|
+
| `MemoryJournal()` | Tests. |
|
|
223
|
+
| `FileJournal(dir, fsync=True)` | One JSONL file per run; tolerant of torn writes after a crash. |
|
|
224
|
+
| `SQLiteJournal(path)` | Single machine (WAL mode). |
|
|
225
|
+
| `PostgresJournal(dsn, table="pylinxi_journal")` | Many workers. Appends run under a per-run advisory lock reading the tail from the database; `run`/`resume` hold a session lock so a second worker gets `RunLocked`. Payloads are `TEXT` so signatures verify byte-for-byte. |
|
|
226
|
+
|
|
227
|
+
Common methods: `load(run_id)`, `verify(run_id, require_signed=False)`, `state_at(run_id, seq=None)`, `runs()`, `runs_where_last(kind)`, `pending_timers(due_before=None)`, `delete(run_id)`, `close()` (also a context manager).
|
|
228
|
+
|
|
229
|
+
**Signing and rotation.** `secret=b"..."` or `{key_id: key}`. New entries are signed with `active_kid` (default: the last key). Old entries verify as long as their key id is still configured. `verify(..., require_signed=True)` also rejects unkeyed entries, so an attacker can't rewrite a run as an unkeyed chain.
|
|
230
|
+
|
|
231
|
+
**Integrity limits.** The chain detects edits, reordering and deletions *inside* a run. Dropping the newest entries can only be detected against an external anchor: store `journal.head(run_id)` elsewhere (an audit log, another database) and check with `verify(run_id, anchor=...)`. `verify` never raises; undecodable data makes it return `False`, and `load` raises `JournalCorrupt`.
|
|
232
|
+
|
|
233
|
+
**Concurrent writers.** Appends read the chain tail from storage under a storage-level lock (file lock, SQLite `BEGIN IMMEDIATE`, Postgres advisory lock), so any number of `Journal` instances or processes can share one store. A crash mid-write leaves a torn final line that the next append repairs.
|
|
234
|
+
|
|
235
|
+
**Redaction.** State deltas and metadata are redacted after encoding (so tuples, sets and dataclass fields are covered too). Key names are normalized (`apiKey`, `api-key` → `api_key`) and matched by word: `password`, `secret`, `token`, `api_key`, `access_key`, `private_key`, `auth`, `authorization`, `credential(s)`, `ssn`, `cookie`, `session_id` and similar — so `access_token` matches but `max_tokens` doesn't. Override with `redact_pattern=` (or `None`). Step *results* are not redacted (they are needed for replay); mark such steps `sensitive=True`. Validation errors never include the rejected value.
|
|
236
|
+
|
|
237
|
+
**Types.** The codec is lossless: tuples, sets, bytes, non-string dict keys, and dicts that happen to contain the internal tag key all round-trip exactly. Custom classes must be registered (`default_codec.register(MyDataclass)`, also pydantic models); unregistered ones raise instead of silently coming back as dicts on replay.
|
|
238
|
+
|
|
239
|
+
---
|
|
240
|
+
|
|
241
|
+
## OpenTelemetry
|
|
242
|
+
|
|
243
|
+
```python
|
|
244
|
+
from pylinxi.otel import OTelExporter
|
|
245
|
+
|
|
246
|
+
@flow(journal=j, on_event=OTelExporter()) # record_errors=False to keep error text out of traces
|
|
247
|
+
```
|
|
248
|
+
|
|
249
|
+
```
|
|
250
|
+
pylinxi.run support status, waiting_for, events: pylinxi.replayed
|
|
251
|
+
├─ pylinxi.step classify attempts=3 healed=retry, events: attempt_failed, repaired
|
|
252
|
+
├─ pylinxi.step lookup_charges events: breaker_open, fallback
|
|
253
|
+
└─ pylinxi.step send ERROR status if it finally failed
|
|
254
|
+
```
|
|
255
|
+
|
|
256
|
+
Uses only the OpenTelemetry API; configure any exporter (OTLP, Jaeger, Datadog, Honeycomb) in your app. Replayed steps appear as events, not spans, so traces show what actually executed.
|
|
257
|
+
|
|
258
|
+
---
|
|
259
|
+
|
|
260
|
+
## CLI
|
|
261
|
+
|
|
262
|
+
```bash
|
|
263
|
+
export PYLINXI_JOURNAL=postgresql://app@db/agents PYLINXI_KEYS="2026-q3=...,2026-q4=..."
|
|
264
|
+
|
|
265
|
+
pylinxi runs --status failed # completed|continued|failed|sleeping|waiting|open
|
|
266
|
+
pylinxi show t-1001 # every entry: kind, key, tries, healed, changed state, result
|
|
267
|
+
pylinxi state t-1001 --seq 3 # time travel
|
|
268
|
+
pylinxi verify --all --require-signed # exit 1 on any failure: use in CI or audits
|
|
269
|
+
pylinxi timers --due
|
|
270
|
+
pylinxi delete t-1001 --yes # retention / right-to-erasure
|
|
271
|
+
pylinxi --json runs # machine-readable
|
|
272
|
+
```
|
|
273
|
+
|
|
274
|
+
---
|
|
275
|
+
|
|
276
|
+
## Honest comparison with LangGraph
|
|
277
|
+
|
|
278
|
+
LangGraph is mature and also offers a functional API (`@entrypoint`/`@task`), retry policies, caching and many checkpointers, plus LangSmith and a hosted platform. Choose pylinxi when you want:
|
|
279
|
+
|
|
280
|
+
- a small surface (two decorators, no graph or reducer schema), zero core dependencies, and code you can read in an afternoon;
|
|
281
|
+
- per-step validate → repair → retry → fallback → breaker as one policy;
|
|
282
|
+
- delta journals with signing, rotation, redaction and a verify CLI built in;
|
|
283
|
+
- durable timers, `patched()` and `continue_as_new()` for long-lived agents.
|
|
284
|
+
|
|
285
|
+
Choose LangGraph when you need its integrations ecosystem, LangSmith's tracing UI, or a managed runtime.
|
|
286
|
+
|
|
287
|
+
On certifications: SOC 2 / ISO 27001 apply to organizations and hosted services, not libraries. pylinxi makes audits easier — signed journals, redaction, no network calls, no core dependencies to vet.
|
|
288
|
+
|
|
289
|
+
---
|
|
290
|
+
|
|
291
|
+
## Development
|
|
292
|
+
|
|
293
|
+
```bash
|
|
294
|
+
git clone https://github.com/karawash-projects/pylinxi && cd pylinxi
|
|
295
|
+
pip install -e ".[dev]"
|
|
296
|
+
python -m unittest discover -s tests -t . # Postgres tests skip without a DSN
|
|
297
|
+
PYLINXI_PG_DSN=postgresql://user@localhost/db python -m unittest discover -s tests -t .
|
|
298
|
+
ruff check pylinxi tests examples && mypy pylinxi
|
|
299
|
+
python examples/support_agent.py
|
|
300
|
+
```
|
|
301
|
+
|
|
302
|
+
CI runs lint, type checks, and the full suite with a Postgres service on Python 3.9–3.13.
|
|
303
|
+
|
|
304
|
+
### Releasing
|
|
305
|
+
|
|
306
|
+
Bump `version` in `pyproject.toml` and `__version__` in `pylinxi/__init__.py`, add a `## X.Y.Z` section to `CHANGELOG.md`, then:
|
|
307
|
+
|
|
308
|
+
```bash
|
|
309
|
+
git tag vX.Y.Z && git push origin vX.Y.Z
|
|
310
|
+
```
|
|
311
|
+
|
|
312
|
+
(or on GitHub: **Releases → Draft a new release → Choose a tag → type `vX.Y.Z` → Create new tag → Publish release**.)
|
|
313
|
+
|
|
314
|
+
The `release` workflow checks the tag matches the version, runs the tests, builds, publishes to PyPI via trusted publishing (no API token), and creates a GitHub Release with the changelog notes and built files.
|
|
315
|
+
|
|
316
|
+
MIT licensed.
|
pylinxi-1.0.0/README.md
ADDED
|
@@ -0,0 +1,281 @@
|
|
|
1
|
+
# pylinxi
|
|
2
|
+
|
|
3
|
+
[](https://github.com/karawash-projects/pylinxi/actions/workflows/ci.yml)
|
|
4
|
+
|
|
5
|
+
Durable, self-healing agent flows written as **plain Python**. No graph to wire. The core has zero dependencies; Postgres and OpenTelemetry are optional extras. Python 3.9+.
|
|
6
|
+
|
|
7
|
+
```python
|
|
8
|
+
import asyncio
|
|
9
|
+
from pylinxi import flow, step, idempotency_key, Policy, Retry, SQLiteJournal
|
|
10
|
+
|
|
11
|
+
@step(policy=Policy(retry=Retry(attempts=4), validate=is_json, repair=feed_error_back))
|
|
12
|
+
async def classify(message):
|
|
13
|
+
return await llm(f"Classify: {message}")
|
|
14
|
+
|
|
15
|
+
@step
|
|
16
|
+
def refund(order):
|
|
17
|
+
return billing.refund(order, idempotency_key=idempotency_key())
|
|
18
|
+
|
|
19
|
+
@flow(journal=SQLiteJournal("runs.db", secret=KEY))
|
|
20
|
+
async def support(ctx, message, order):
|
|
21
|
+
intent = await classify(message)
|
|
22
|
+
if intent == "refund": # branching is just `if`
|
|
23
|
+
charges, history = await asyncio.gather(lookup(order), past_tickets(order))
|
|
24
|
+
if not await ctx.wait_for("approval", timeout=86400, default=False):
|
|
25
|
+
return "escalated"
|
|
26
|
+
return await refund(order)
|
|
27
|
+
return await answer_faq(message)
|
|
28
|
+
|
|
29
|
+
r = await support.run("charged twice", "A-1001", run_id="t-1001") # suspends at approval
|
|
30
|
+
r = await support.resume("t-1001", signals={"approval": True}) # hours later, any process
|
|
31
|
+
```
|
|
32
|
+
|
|
33
|
+
```bash
|
|
34
|
+
pip install pylinxi # core
|
|
35
|
+
pip install "pylinxi[postgres,otel]" # production extras
|
|
36
|
+
```
|
|
37
|
+
|
|
38
|
+
---
|
|
39
|
+
|
|
40
|
+
## Why
|
|
41
|
+
|
|
42
|
+
| Gap in graph frameworks | pylinxi |
|
|
43
|
+
|---|---|
|
|
44
|
+
| **Manual graph wiring** for branching | The flow *is* the code: `if`, `match`, loops, `asyncio.gather`. `ctx.route()` replaces conditional edges in one line. Steps are plain functions outside a flow, so they unit-test without a harness. |
|
|
45
|
+
| **Weak real-time self-healing** | One declarative policy per step: retry → validate → **repair** (rewrite inputs between attempts) → fallback → circuit breaker. Crashes heal by **resume**: finished steps replay from the journal. A `Worker` resumes due timers and transient failures fleet-wide. |
|
|
46
|
+
| **Heavy state serialization** | Append-only **delta** journal: each entry stores one step's result plus only the state keys that changed. Rebuild any point in time with `state_at()`. Long loops roll over with `continue_as_new()`. |
|
|
47
|
+
| **Enterprise security** | HMAC-signed hash chain with key rotation, automatic redaction, never-persisted sensitive steps, per-run locks, retention deletes, idempotency keys, OTel tracing, a `verify` CLI for audits. |
|
|
48
|
+
|
|
49
|
+
---
|
|
50
|
+
|
|
51
|
+
## Concepts in 60 seconds
|
|
52
|
+
|
|
53
|
+
- A **flow** is an `async def f(ctx, ...)` decorated with `@flow`. Run it with `await f.run(...)`.
|
|
54
|
+
- A **step** is any function decorated with `@step` (sync, async, or a generator for streaming). Calling it inside a flow journals its result.
|
|
55
|
+
- A **journal** stores each run as a signed list of entries. On `resume(run_id)` the flow function runs again from the top; completed steps return their recorded result instantly and execution continues at the first unfinished step.
|
|
56
|
+
- **The one rule:** code *outside* steps must be deterministic. Put I/O, LLM calls, randomness and clocks inside steps, or use `ctx.now()` / `ctx.uuid()`. If flow code changes incompatibly between a crash and a resume, pylinxi emits a `divergence` event; use `ctx.patched()` to change code safely.
|
|
57
|
+
|
|
58
|
+
### Replay guarantees
|
|
59
|
+
|
|
60
|
+
- **Concurrency.** Tasks created by flow code (`asyncio.gather`, `create_task`, `TaskGroup`) each get their own key scope (`t0/`, `t1/`, …) in creation order, so steps inside concurrent branches replay correctly whichever branch finishes first. When a run suspends, fails or finishes, branches still running are cancelled before it returns, so they can't act twice.
|
|
61
|
+
- **Handled failures stay handled.** If flow code catches a `StepFailed` and carries on, resuming re-raises the same failure (wrapping a `ReplayedError`) instead of re-running the step. A failure that ended the run is retried on resume.
|
|
62
|
+
- **Nested steps.** A `@step` called from inside another step is a plain call; the outer step's result is the checkpoint.
|
|
63
|
+
- **Suspension can't be swallowed.** `Suspended` and `ContinueAsNew` derive from `BaseException`, so `except Exception` in flow code won't catch them.
|
|
64
|
+
- **One executor per run.** `run`/`resume` hold a per-run lock (in-process for every journal, cross-process for file/SQLite on POSIX, database locks for Postgres). A second caller gets `RunLocked`.
|
|
65
|
+
- **Inputs are journaled in clear** so runs can resume. pylinxi therefore refuses flow inputs, initial state, signals or `continue_as_new` payloads containing secret-looking keys; pass secrets by reference and load them in a `@step(sensitive=True)`.
|
|
66
|
+
|
|
67
|
+
---
|
|
68
|
+
|
|
69
|
+
## Self-healing
|
|
70
|
+
|
|
71
|
+
```python
|
|
72
|
+
from pylinxi import Policy, Retry, CircuitBreaker
|
|
73
|
+
|
|
74
|
+
crm = CircuitBreaker(threshold=5, reset_after=30) # share one per dependency
|
|
75
|
+
|
|
76
|
+
@step(policy=Policy(
|
|
77
|
+
retry=Retry(attempts=4, backoff=0.5, factor=2, max_backoff=10, on=(ConnectionError, TimeoutError)),
|
|
78
|
+
timeout=20, # per attempt
|
|
79
|
+
validate=lambda out: "intent" in out, # False -> ValidationError -> retried
|
|
80
|
+
repair=lambda err, args, kwargs, attempt: ((args[0] + f"\nFix: {err}",), kwargs),
|
|
81
|
+
fallback=lambda *a, **k: {"intent": "unknown"}, # after retries, or when breaker is open
|
|
82
|
+
breaker=crm,
|
|
83
|
+
))
|
|
84
|
+
def classify(prompt): ...
|
|
85
|
+
```
|
|
86
|
+
|
|
87
|
+
| Field | Meaning |
|
|
88
|
+
|---|---|
|
|
89
|
+
| `retry` | `Retry(attempts, backoff, factor, max_backoff, jitter, on)`. Default: 1 attempt. |
|
|
90
|
+
| `timeout` | Seconds per attempt. Sync steps run in a thread, so a timed-out thread can't be killed; prefer async clients for hard timeouts. |
|
|
91
|
+
| `validate` | `fn(result) -> bool` (may be async). Validation failures are always retried. |
|
|
92
|
+
| `repair` | `fn(error, args, kwargs, attempt) -> (args, kwargs) | None` (may be async). Runs between attempts. |
|
|
93
|
+
| `fallback` | `fn(*args, **kwargs)` used when attempts are exhausted or the breaker is open. |
|
|
94
|
+
| `breaker` | `CircuitBreaker(threshold, reset_after)`; closed → open → half-open. |
|
|
95
|
+
| `aggregate` | Streaming steps: `fn(chunks) -> result`. Default joins strings, else keeps the list. |
|
|
96
|
+
| `thread` | Run plain sync steps in a worker thread (default `True`). |
|
|
97
|
+
|
|
98
|
+
Each completed step's journal entry records `attempts`, `healed` (`"retry"` / `"fallback"`) and the errors seen.
|
|
99
|
+
|
|
100
|
+
### Fleet-level healing: `Worker`
|
|
101
|
+
|
|
102
|
+
```python
|
|
103
|
+
from pylinxi import Worker
|
|
104
|
+
|
|
105
|
+
worker = Worker(journal, [support, onboarding], retry_failed=True, max_failures=5, backoff=30)
|
|
106
|
+
await worker.run_forever(interval=5) # or: report = await worker.tick()
|
|
107
|
+
```
|
|
108
|
+
|
|
109
|
+
Each tick resumes runs whose timers are due and (optionally) runs that *ended* in an unhandled failure, with exponential backoff and a give-up limit. Failures your flow caught are left alone. One run that errors while resuming is reported in `TickReport.errors` and never blocks the others. Run several workers against a `PostgresJournal`; per-run locks make them skip runs another worker owns.
|
|
110
|
+
|
|
111
|
+
---
|
|
112
|
+
|
|
113
|
+
## Flow API (`ctx`)
|
|
114
|
+
|
|
115
|
+
| Call | What it does |
|
|
116
|
+
|---|---|
|
|
117
|
+
| `await my_step(...)` | Journaled call of a `@step` function. |
|
|
118
|
+
| `await ctx.step(fn, *args, name=, policy=, sensitive=)` | Journaled call of any callable (lambdas, third-party functions). |
|
|
119
|
+
| `await ctx.map(fn, items, concurrency=8)` | Parallel fan-out; each item is its own checkpoint. Siblings finish before an error surfaces, so a resume re-runs only failed items. |
|
|
120
|
+
| `await ctx.route(label, {"a": step_a, ...}, *args, default=)` | Dispatch by label. |
|
|
121
|
+
| `await ctx.wait_for(signal, timeout=None, default=None)` | Wait for `run/resume(..., signals={signal: value})`. Signals are journaled on delivery, so early ones aren't lost and repeats queue in order. With `timeout`, returns `default` once it expires. |
|
|
122
|
+
| `await ctx.sleep(seconds, inline_max=1.0)` | Durable sleep. Short waits sleep in-process; longer ones suspend and free the worker. |
|
|
123
|
+
| `await ctx.now()` / `await ctx.uuid()` | Clock / random id recorded once, identical on replay. |
|
|
124
|
+
| `ctx.patched("change-id")` | `True` for runs that reach this point on new code, `False` for runs already past it. |
|
|
125
|
+
| `ctx.continue_as_new(*args, **kwargs)` | End this run; continue as `run_id.g1`, `.g2`… with the current state. |
|
|
126
|
+
| `ctx.state` | Dict of flow state. Changes are journaled as deltas at the next checkpoint. |
|
|
127
|
+
| `ctx.run_id`, `ctx.generation`, `ctx.replaying` | Run metadata. |
|
|
128
|
+
| `pylinxi.idempotency_key()` | Inside a step: `"<run_id>:<step_key>"`, stable across retries and resumes. |
|
|
129
|
+
|
|
130
|
+
`@step(sensitive=True)` keeps a step's result out of the journal entirely; it is recomputed on resume. Use it for tokens and credentials.
|
|
131
|
+
|
|
132
|
+
### Running
|
|
133
|
+
|
|
134
|
+
```python
|
|
135
|
+
r = await support.run(*args, run_id="t-1", state={"tier": "gold"}, signals={...}, **kwargs)
|
|
136
|
+
r = await support.resume("t-1", signals={"approval": True})
|
|
137
|
+
r = support.run_sync(...) # outside an event loop
|
|
138
|
+
|
|
139
|
+
r.status # "completed" | "suspended" | "failed"
|
|
140
|
+
r.output, r.state, r.waiting_for, r.wake_at, r.error, r.events
|
|
141
|
+
```
|
|
142
|
+
|
|
143
|
+
### Streaming
|
|
144
|
+
|
|
145
|
+
Generator steps stream chunks as events; the journal stores only the aggregate.
|
|
146
|
+
|
|
147
|
+
```python
|
|
148
|
+
@step
|
|
149
|
+
async def answer(prompt):
|
|
150
|
+
async for tok in llm.stream(prompt):
|
|
151
|
+
yield tok
|
|
152
|
+
|
|
153
|
+
@flow
|
|
154
|
+
async def chat(ctx, prompt):
|
|
155
|
+
return await answer(prompt)
|
|
156
|
+
|
|
157
|
+
async for ev in chat.stream("hello", run_id="chat-1"):
|
|
158
|
+
if ev["event"] == "chunk":
|
|
159
|
+
await websocket.send(ev["chunk"])
|
|
160
|
+
elif ev["event"] == "stream_reset": # a retry restarted the stream
|
|
161
|
+
await websocket.send({"reset": True})
|
|
162
|
+
elif ev["event"] == "result":
|
|
163
|
+
final = ev["result"] # RunResult
|
|
164
|
+
```
|
|
165
|
+
|
|
166
|
+
### Events
|
|
167
|
+
|
|
168
|
+
Pass `on_event=fn` (or a list) to `@flow`, `run`, or `resume`. Every event has `event`, `key`, `run_id`, `flow`:
|
|
169
|
+
|
|
170
|
+
`run_started`, `started`, `completed`, `replayed`, `attempt_failed`, `repaired`, `fallback`, `breaker_open`, `chunk`, `stream_reset`, `step_failed`, `step_crashed`, `signal_timeout`, `suspended`, `continued_as_new`, `divergence`, `failed`, `crashed`, `finished`.
|
|
171
|
+
|
|
172
|
+
A failing observer never breaks a run.
|
|
173
|
+
|
|
174
|
+
---
|
|
175
|
+
|
|
176
|
+
## Journals
|
|
177
|
+
|
|
178
|
+
```python
|
|
179
|
+
from pylinxi import MemoryJournal, FileJournal, SQLiteJournal, open_journal
|
|
180
|
+
from pylinxi.postgres import PostgresJournal
|
|
181
|
+
|
|
182
|
+
j = open_journal("sqlite:///runs.db", secret={"2026-q3": k1, "2026-q4": k2})
|
|
183
|
+
```
|
|
184
|
+
|
|
185
|
+
| Backend | Use for |
|
|
186
|
+
|---|---|
|
|
187
|
+
| `MemoryJournal()` | Tests. |
|
|
188
|
+
| `FileJournal(dir, fsync=True)` | One JSONL file per run; tolerant of torn writes after a crash. |
|
|
189
|
+
| `SQLiteJournal(path)` | Single machine (WAL mode). |
|
|
190
|
+
| `PostgresJournal(dsn, table="pylinxi_journal")` | Many workers. Appends run under a per-run advisory lock reading the tail from the database; `run`/`resume` hold a session lock so a second worker gets `RunLocked`. Payloads are `TEXT` so signatures verify byte-for-byte. |
|
|
191
|
+
|
|
192
|
+
Common methods: `load(run_id)`, `verify(run_id, require_signed=False)`, `state_at(run_id, seq=None)`, `runs()`, `runs_where_last(kind)`, `pending_timers(due_before=None)`, `delete(run_id)`, `close()` (also a context manager).
|
|
193
|
+
|
|
194
|
+
**Signing and rotation.** `secret=b"..."` or `{key_id: key}`. New entries are signed with `active_kid` (default: the last key). Old entries verify as long as their key id is still configured. `verify(..., require_signed=True)` also rejects unkeyed entries, so an attacker can't rewrite a run as an unkeyed chain.
|
|
195
|
+
|
|
196
|
+
**Integrity limits.** The chain detects edits, reordering and deletions *inside* a run. Dropping the newest entries can only be detected against an external anchor: store `journal.head(run_id)` elsewhere (an audit log, another database) and check with `verify(run_id, anchor=...)`. `verify` never raises; undecodable data makes it return `False`, and `load` raises `JournalCorrupt`.
|
|
197
|
+
|
|
198
|
+
**Concurrent writers.** Appends read the chain tail from storage under a storage-level lock (file lock, SQLite `BEGIN IMMEDIATE`, Postgres advisory lock), so any number of `Journal` instances or processes can share one store. A crash mid-write leaves a torn final line that the next append repairs.
|
|
199
|
+
|
|
200
|
+
**Redaction.** State deltas and metadata are redacted after encoding (so tuples, sets and dataclass fields are covered too). Key names are normalized (`apiKey`, `api-key` → `api_key`) and matched by word: `password`, `secret`, `token`, `api_key`, `access_key`, `private_key`, `auth`, `authorization`, `credential(s)`, `ssn`, `cookie`, `session_id` and similar — so `access_token` matches but `max_tokens` doesn't. Override with `redact_pattern=` (or `None`). Step *results* are not redacted (they are needed for replay); mark such steps `sensitive=True`. Validation errors never include the rejected value.
|
|
201
|
+
|
|
202
|
+
**Types.** The codec is lossless: tuples, sets, bytes, non-string dict keys, and dicts that happen to contain the internal tag key all round-trip exactly. Custom classes must be registered (`default_codec.register(MyDataclass)`, also pydantic models); unregistered ones raise instead of silently coming back as dicts on replay.
|
|
203
|
+
|
|
204
|
+
---
|
|
205
|
+
|
|
206
|
+
## OpenTelemetry
|
|
207
|
+
|
|
208
|
+
```python
|
|
209
|
+
from pylinxi.otel import OTelExporter
|
|
210
|
+
|
|
211
|
+
@flow(journal=j, on_event=OTelExporter()) # record_errors=False to keep error text out of traces
|
|
212
|
+
```
|
|
213
|
+
|
|
214
|
+
```
|
|
215
|
+
pylinxi.run support status, waiting_for, events: pylinxi.replayed
|
|
216
|
+
├─ pylinxi.step classify attempts=3 healed=retry, events: attempt_failed, repaired
|
|
217
|
+
├─ pylinxi.step lookup_charges events: breaker_open, fallback
|
|
218
|
+
└─ pylinxi.step send ERROR status if it finally failed
|
|
219
|
+
```
|
|
220
|
+
|
|
221
|
+
Uses only the OpenTelemetry API; configure any exporter (OTLP, Jaeger, Datadog, Honeycomb) in your app. Replayed steps appear as events, not spans, so traces show what actually executed.
|
|
222
|
+
|
|
223
|
+
---
|
|
224
|
+
|
|
225
|
+
## CLI
|
|
226
|
+
|
|
227
|
+
```bash
|
|
228
|
+
export PYLINXI_JOURNAL=postgresql://app@db/agents PYLINXI_KEYS="2026-q3=...,2026-q4=..."
|
|
229
|
+
|
|
230
|
+
pylinxi runs --status failed # completed|continued|failed|sleeping|waiting|open
|
|
231
|
+
pylinxi show t-1001 # every entry: kind, key, tries, healed, changed state, result
|
|
232
|
+
pylinxi state t-1001 --seq 3 # time travel
|
|
233
|
+
pylinxi verify --all --require-signed # exit 1 on any failure: use in CI or audits
|
|
234
|
+
pylinxi timers --due
|
|
235
|
+
pylinxi delete t-1001 --yes # retention / right-to-erasure
|
|
236
|
+
pylinxi --json runs # machine-readable
|
|
237
|
+
```
|
|
238
|
+
|
|
239
|
+
---
|
|
240
|
+
|
|
241
|
+
## Honest comparison with LangGraph
|
|
242
|
+
|
|
243
|
+
LangGraph is mature and also offers a functional API (`@entrypoint`/`@task`), retry policies, caching and many checkpointers, plus LangSmith and a hosted platform. Choose pylinxi when you want:
|
|
244
|
+
|
|
245
|
+
- a small surface (two decorators, no graph or reducer schema), zero core dependencies, and code you can read in an afternoon;
|
|
246
|
+
- per-step validate → repair → retry → fallback → breaker as one policy;
|
|
247
|
+
- delta journals with signing, rotation, redaction and a verify CLI built in;
|
|
248
|
+
- durable timers, `patched()` and `continue_as_new()` for long-lived agents.
|
|
249
|
+
|
|
250
|
+
Choose LangGraph when you need its integrations ecosystem, LangSmith's tracing UI, or a managed runtime.
|
|
251
|
+
|
|
252
|
+
On certifications: SOC 2 / ISO 27001 apply to organizations and hosted services, not libraries. pylinxi makes audits easier — signed journals, redaction, no network calls, no core dependencies to vet.
|
|
253
|
+
|
|
254
|
+
---
|
|
255
|
+
|
|
256
|
+
## Development
|
|
257
|
+
|
|
258
|
+
```bash
|
|
259
|
+
git clone https://github.com/karawash-projects/pylinxi && cd pylinxi
|
|
260
|
+
pip install -e ".[dev]"
|
|
261
|
+
python -m unittest discover -s tests -t . # Postgres tests skip without a DSN
|
|
262
|
+
PYLINXI_PG_DSN=postgresql://user@localhost/db python -m unittest discover -s tests -t .
|
|
263
|
+
ruff check pylinxi tests examples && mypy pylinxi
|
|
264
|
+
python examples/support_agent.py
|
|
265
|
+
```
|
|
266
|
+
|
|
267
|
+
CI runs lint, type checks, and the full suite with a Postgres service on Python 3.9–3.13.
|
|
268
|
+
|
|
269
|
+
### Releasing
|
|
270
|
+
|
|
271
|
+
Bump `version` in `pyproject.toml` and `__version__` in `pylinxi/__init__.py`, add a `## X.Y.Z` section to `CHANGELOG.md`, then:
|
|
272
|
+
|
|
273
|
+
```bash
|
|
274
|
+
git tag vX.Y.Z && git push origin vX.Y.Z
|
|
275
|
+
```
|
|
276
|
+
|
|
277
|
+
(or on GitHub: **Releases → Draft a new release → Choose a tag → type `vX.Y.Z` → Create new tag → Publish release**.)
|
|
278
|
+
|
|
279
|
+
The `release` workflow checks the tag matches the version, runs the tests, builds, publishes to PyPI via trusted publishing (no API token), and creates a GitHub Release with the changelog notes and built files.
|
|
280
|
+
|
|
281
|
+
MIT licensed.
|
|
@@ -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
|
+
]
|