forktex-flow 0.1.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 (36) hide show
  1. forktex_flow-0.1.0/LICENSE +45 -0
  2. forktex_flow-0.1.0/NOTICE +23 -0
  3. forktex_flow-0.1.0/PKG-INFO +357 -0
  4. forktex_flow-0.1.0/README.md +322 -0
  5. forktex_flow-0.1.0/pyproject.toml +148 -0
  6. forktex_flow-0.1.0/src/forktex_flow/__init__.py +106 -0
  7. forktex_flow-0.1.0/src/forktex_flow/domain/__init__.py +76 -0
  8. forktex_flow-0.1.0/src/forktex_flow/domain/config.py +88 -0
  9. forktex_flow-0.1.0/src/forktex_flow/domain/definition.py +125 -0
  10. forktex_flow-0.1.0/src/forktex_flow/domain/filter.py +71 -0
  11. forktex_flow-0.1.0/src/forktex_flow/domain/node.py +137 -0
  12. forktex_flow-0.1.0/src/forktex_flow/domain/state.py +107 -0
  13. forktex_flow-0.1.0/src/forktex_flow/domain/types.py +106 -0
  14. forktex_flow-0.1.0/src/forktex_flow/errors.py +108 -0
  15. forktex_flow-0.1.0/src/forktex_flow/extension.py +81 -0
  16. forktex_flow-0.1.0/src/forktex_flow/flow.py +345 -0
  17. forktex_flow-0.1.0/src/forktex_flow/persist/__init__.py +50 -0
  18. forktex_flow-0.1.0/src/forktex_flow/persist/mappers.py +73 -0
  19. forktex_flow-0.1.0/src/forktex_flow/persist/migrations/__init__.py +68 -0
  20. forktex_flow-0.1.0/src/forktex_flow/persist/migrations/v0001__schema.sql +125 -0
  21. forktex_flow-0.1.0/src/forktex_flow/persist/models.py +206 -0
  22. forktex_flow-0.1.0/src/forktex_flow/persist/runs.py +231 -0
  23. forktex_flow-0.1.0/src/forktex_flow/persist/steps.py +284 -0
  24. forktex_flow-0.1.0/src/forktex_flow/persist/store.py +66 -0
  25. forktex_flow-0.1.0/src/forktex_flow/py.typed +0 -0
  26. forktex_flow-0.1.0/src/forktex_flow/read/__init__.py +49 -0
  27. forktex_flow-0.1.0/src/forktex_flow/read/instance.py +347 -0
  28. forktex_flow-0.1.0/src/forktex_flow/read/query.py +231 -0
  29. forktex_flow-0.1.0/src/forktex_flow/runtime/__init__.py +51 -0
  30. forktex_flow-0.1.0/src/forktex_flow/runtime/compiler.py +168 -0
  31. forktex_flow-0.1.0/src/forktex_flow/runtime/context.py +53 -0
  32. forktex_flow-0.1.0/src/forktex_flow/runtime/ctx.py +99 -0
  33. forktex_flow-0.1.0/src/forktex_flow/runtime/driver.py +312 -0
  34. forktex_flow-0.1.0/src/forktex_flow/runtime/execution.py +363 -0
  35. forktex_flow-0.1.0/src/forktex_flow/runtime/registry.py +73 -0
  36. forktex_flow-0.1.0/src/forktex_flow/testing.py +153 -0
@@ -0,0 +1,45 @@
1
+ ForkTex Python -- Dual License
2
+
3
+ Copyright (C) 2026 FORKTEX S.R.L.
4
+
5
+ This software is licensed under a dual-license model:
6
+
7
+ ============================================================================
8
+
9
+ 1. OPEN-SOURCE LICENSE -- GNU Affero General Public License v3.0 (AGPL-3.0)
10
+
11
+ You may use, modify, and redistribute this software under the terms of the
12
+ GNU Affero General Public License v3.0 or later, as published by the Free
13
+ Software Foundation. The full license text is available at:
14
+ https://www.gnu.org/licenses/agpl-3.0.html
15
+
16
+ Key obligations under AGPL-3.0:
17
+ - Any modified version MUST be released under the same AGPL-3.0 license.
18
+ - If you run a modified version on a server and let users interact with it
19
+ over a network, you MUST make the complete source code available to those
20
+ users under AGPL-3.0.
21
+ - You MUST preserve all copyright notices and attribution to FORKTEX S.R.L.
22
+
23
+ ============================================================================
24
+
25
+ 2. COMMERCIAL LICENSE
26
+
27
+ If you wish to use this software without the obligations of AGPL-3.0 --
28
+ for example, to keep your modifications proprietary, to embed this software
29
+ in a closed-source product, or to operate it as part of a commercial service
30
+ without releasing your source code -- you MUST obtain a commercial license
31
+ from FORKTEX S.R.L.
32
+
33
+ Commercial licensing includes, but is not limited to:
34
+ - SaaS / hosted deployments generating revenue
35
+ - Embedding in proprietary products or platforms
36
+ - Redistribution in closed-source form
37
+ - Use by organizations exceeding the AGPL-3.0 compliance scope
38
+
39
+ For commercial licensing inquiries, contact:
40
+ info@forktex.com
41
+
42
+ ============================================================================
43
+
44
+ Unless you have obtained a separate commercial license from FORKTEX S.R.L.,
45
+ your use of this software is governed exclusively by the AGPL-3.0 license.
@@ -0,0 +1,23 @@
1
+ ForkTex Python
2
+ Copyright (C) 2026 FORKTEX S.R.L.
3
+
4
+ ForkTex Python is the unified command-line interface and agent runtime for
5
+ the ForkTex self-managed software factory. It bundles the local developer
6
+ agent, FSD (ForkTex Standard for Delivery) compliance tooling, architecture
7
+ discovery and audit, the menu-driven root loop, and orchestration glue
8
+ across the cloud, intelligence, and network services.
9
+
10
+ This program is free software: you can redistribute it and/or modify
11
+ it under the terms of the GNU Affero General Public License as published by
12
+ the Free Software Foundation, either version 3 of the License, or
13
+ (at your option) any later version.
14
+
15
+ This program is distributed in the hope that it will be useful,
16
+ but WITHOUT ANY WARRANTY; without even the implied warranty of
17
+ MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
18
+ GNU Affero General Public License for more details.
19
+
20
+ You should have received a copy of the GNU Affero General Public License
21
+ along with this program. If not, see <https://www.gnu.org/licenses/>.
22
+
23
+ For commercial licensing inquiries, contact: info@forktex.com
@@ -0,0 +1,357 @@
1
+ Metadata-Version: 2.4
2
+ Name: forktex-flow
3
+ Version: 0.1.0
4
+ Summary: Postgres-native durable workflow execution — pipelines and cron-scheduled runs, built on the forktex substrate.
5
+ License-Expression: AGPL-3.0-or-later OR LicenseRef-ForkTex-Commercial
6
+ License-File: LICENSE
7
+ License-File: NOTICE
8
+ Author: FORKTEX
9
+ Author-email: info@forktex.com
10
+ Requires-Python: >=3.14,<4.0
11
+ Classifier: Development Status :: 4 - Beta
12
+ Classifier: Intended Audience :: Developers
13
+ Classifier: Operating System :: OS Independent
14
+ Classifier: Programming Language :: Python :: 3
15
+ Classifier: Programming Language :: Python :: 3 :: Only
16
+ Classifier: Programming Language :: Python :: 3.14
17
+ Classifier: Topic :: Database
18
+ Classifier: Topic :: Software Development
19
+ Classifier: Topic :: System :: Distributed Computing
20
+ Classifier: Typing :: Typed
21
+ Provides-Extra: testing
22
+ Requires-Dist: asyncpg (>=0.31)
23
+ Requires-Dist: croniter (>=2.0)
24
+ Requires-Dist: forktex (>=0.11,<1)
25
+ Requires-Dist: pydantic (>=2.0)
26
+ Requires-Dist: sqlalchemy[asyncio] (>=2.0)
27
+ Requires-Dist: testcontainers[postgres] (>=4.0) ; extra == "testing"
28
+ Project-URL: Bug Tracker, https://github.com/forktex/forktex-flow/issues
29
+ Project-URL: Changelog, https://github.com/forktex/forktex-flow/blob/master/CHANGELOG.md
30
+ Project-URL: Documentation, https://github.com/forktex/forktex-flow/tree/master/docs
31
+ Project-URL: Homepage, https://forktex.com
32
+ Project-URL: Repository, https://github.com/forktex/forktex-flow
33
+ Description-Content-Type: text/markdown
34
+
35
+ # forktex-flow
36
+
37
+ Durable workflows in Postgres. No broker, no scheduler process, no separate
38
+ control plane — the database you already run, plus your own worker processes, is
39
+ the whole infrastructure.
40
+
41
+ ```bash
42
+ pip install forktex-flow
43
+ ```
44
+
45
+ Python 3.14, PostgreSQL 14+. Built on the
46
+ [`forktex`](https://github.com/forktex/forktex-py) substrate, which pip resolves
47
+ for you.
48
+
49
+ ## What it does
50
+
51
+ You write ordinary async functions. The library makes them survive a crash.
52
+
53
+ ```python
54
+ import operator
55
+ from typing import Annotated, TypedDict
56
+
57
+ from forktex_flow import Ctx, Flow, step
58
+
59
+ flow = Flow(database_url="postgresql+asyncpg://user:pass@host/db")
60
+
61
+
62
+ class DeployState(TypedDict):
63
+ logs: Annotated[list[str], operator.add] # accumulates across steps
64
+ server_id: str # last write wins
65
+ dns_enabled: bool
66
+
67
+
68
+ async def provision(ctx: Ctx, state: DeployState) -> dict:
69
+ server = await hetzner.create_server()
70
+ return {"server_id": server.id, "logs": [f"provisioned {server.id}"]}
71
+
72
+
73
+ async def configure_dns(ctx: Ctx, state: DeployState) -> dict:
74
+ await dns.point(state["server_id"])
75
+ return {"logs": ["dns configured"]}
76
+
77
+
78
+ @flow.pipeline("deploy.apply", version=1, state=DeployState)
79
+ class DeployApply:
80
+ steps = [provision, step(configure_dns, when=lambda s: s["dns_enabled"])]
81
+ ```
82
+
83
+ Start a worker and dispatch — Postgres must be running and reachable at that DSN:
84
+
85
+ ```python
86
+ await flow.start_driver() # migrates the schema, then competes for leadership
87
+
88
+ instance = await flow.run("deploy.apply", state={"dns_enabled": True, "logs": []})
89
+ final = await instance.wait(timeout=600)
90
+ print(final.status, final.state["logs"])
91
+ ```
92
+
93
+ If the worker dies after `provision` and before `configure_dns`, another worker
94
+ picks the run up, `provision` is **not** re-run — its recorded output is
95
+ returned — and `configure_dns` executes for the first time. No second server
96
+ gets created.
97
+
98
+ ## The model, in four sentences
99
+
100
+ A workflow is an **ordered list of steps**. A step returns the *delta* it wants
101
+ merged into the run's state, not the whole state. Every step is memoised on
102
+ `(run_id, step, input state)`, so a run picked up after a crash re-executes from
103
+ the top and skips straight past work that already happened. There is no graph, no
104
+ router and no signal inbox: a run visits every step once, in order, executing the
105
+ ones whose guard passes.
106
+
107
+ That is deliberately less than a general workflow engine offers. It is what a
108
+ deploy pipeline, a nightly reconcile or an ingest job actually needs, and it fits
109
+ in one file you can read.
110
+
111
+ ## Installing
112
+
113
+ ```bash
114
+ pip install forktex-flow
115
+ ```
116
+
117
+ To test your own workflows, take the extra — it brings in `testcontainers`:
118
+
119
+ ```bash
120
+ pip install "forktex-flow[testing]"
121
+ ```
122
+
123
+ **Install with Python 3.14.** `forktex` and `forktex-flow` both declare
124
+ `requires-python = ">=3.14"`, and pip hides a release whose floor your
125
+ interpreter does not meet rather than saying why — so an older interpreter
126
+ reports the newest `forktex` as `0.2.4`, an unrelated lineage, and the
127
+ dependency looks unsatisfiable. It is not; the interpreter is.
128
+
129
+ Developing against a local checkout of the substrate:
130
+
131
+ ```bash
132
+ pip install -e ../forktex-py
133
+ pip install -e .
134
+ ```
135
+
136
+ ## Wiring it into your app
137
+
138
+ The library runs inside a process you already have. In FastAPI that is the
139
+ lifespan.
140
+
141
+ ```python
142
+ from contextlib import asynccontextmanager
143
+
144
+ from fastapi import FastAPI
145
+ from forktex_flow import Flow
146
+
147
+ flow = Flow(database_url=settings.database_url)
148
+ import myapp.workflows # noqa: F401 — importing runs the @flow.pipeline decorators
149
+
150
+
151
+ @asynccontextmanager
152
+ async def lifespan(app: FastAPI):
153
+ await flow.start_driver() # migrates, then competes for the leader lock
154
+ yield
155
+ await flow.close() # stops driving, releases the pool
156
+
157
+
158
+ app = FastAPI(lifespan=lifespan)
159
+ ```
160
+
161
+ Run this on **every** replica. Exactly one wins the advisory lock and executes;
162
+ the rest stand by and take over if it dies. Workflows must be imported before
163
+ `start_driver()` — a decorator only runs when its module does.
164
+
165
+ `start_driver()` calls `init()` for you. Call `init()` alone when you want the
166
+ schema migrated by something that does not execute anything, such as a deploy
167
+ hook.
168
+
169
+ ## How consumers use it
170
+
171
+ One example and one sentence each; **[`docs/flow.md`](docs/flow.md) has the exact
172
+ semantics.**
173
+
174
+ ### Conditional steps
175
+
176
+ `when=` is a predicate over state, evaluated immediately before dispatch.
177
+
178
+ ```python
179
+ @flow.pipeline("deploy.apply", version=4, state=DeployState)
180
+ class DeployApply:
181
+ steps = [
182
+ load_manifest,
183
+ step(provision_vps, when=lambda s: s["is_new_server"]),
184
+ deploy_services,
185
+ step(ssl_provision, when=lambda s: s["ssl_enabled"]),
186
+ health_check,
187
+ ]
188
+ ```
189
+
190
+ ### Accumulating state
191
+
192
+ Any callable in the `Annotated` metadata is the reducer; a field without one is
193
+ last-write-wins.
194
+
195
+ ```python
196
+ class State(TypedDict):
197
+ logs: Annotated[list[str], operator.add] # every step appends
198
+ server_id: str # each step overwrites
199
+ ```
200
+
201
+ ### Cron workflows
202
+
203
+ ```python
204
+ @flow.scheduled("lifecycle.reconcile", version=1, cron="*/30 * * * *", state=State)
205
+ async def reconcile(ctx: Ctx, state: State) -> dict:
206
+ return {"logs": ["reconciled"]}
207
+ ```
208
+
209
+ Registered on every worker; fired once, by whichever holds the lock.
210
+
211
+ ### Per-step retries
212
+
213
+ ```python
214
+ @step(max_attempts=5, backoff=(30.0, 120.0, 300.0))
215
+ async def flaky(ctx: Ctx, state: State) -> dict: ...
216
+ ```
217
+
218
+ ### Progress from inside a long step
219
+
220
+ ```python
221
+ await ctx.emit("progress", {"done": i, "total": len(chunks)})
222
+ ```
223
+
224
+ ### Stopping a run
225
+
226
+ ```python
227
+ from forktex_flow import WorkflowCancelled
228
+
229
+ async def check_target(ctx: Ctx, state: State) -> dict:
230
+ if not await target_exists(state["server_id"]):
231
+ raise WorkflowCancelled("the target was deleted while we were deploying")
232
+ return {}
233
+ ```
234
+
235
+ From outside, `await instance.cancel()` stops the run before its **next** step
236
+ and returns whether it was still stoppable. Work already done stays done.
237
+
238
+ ### Finding runs
239
+
240
+ ```python
241
+ page = await (
242
+ flow.query()
243
+ .workflow("deploy.apply", version=4)
244
+ .status("running", "pending")
245
+ .metadata(org_id=org)
246
+ .limit(25)
247
+ .fetch(cursor)
248
+ )
249
+ stuck = await flow.query().current_node("provision_vps").count()
250
+ ```
251
+
252
+ ### Testing your workflows
253
+
254
+ ```python
255
+ from forktex_flow import isolated_flow, make_ctx, run_to_completion
256
+
257
+ # A step is an ordinary callable — no database needed.
258
+ assert await provision(make_ctx(), {"logs": []}) == {"server_id": "srv-1", ...}
259
+
260
+ # Or run the whole pipeline against a throwaway schema.
261
+ async with isolated_flow(postgres_url) as flow:
262
+ register_my_workflows(flow)
263
+ final = await run_to_completion(flow, "deploy.apply", state={"logs": []})
264
+ assert final.state["logs"] == ["provisioned", "dns configured"]
265
+ ```
266
+
267
+ ## Where it flexes
268
+
269
+ - **Share your pool** — pass a `Database` instead of a URL and `close()` leaves
270
+ it alone, because it is yours.
271
+ - **Many schemas in one process** — `Flow(url, schema="flow_tenant_a")`. Two
272
+ instances see nothing of each other.
273
+ - **Stamp context onto every run** — a `FlowExtension` with `before_start`
274
+ returns a dict merged into metadata, which reaches every step via
275
+ `Ctx.metadata` and is queryable through `.metadata(**kv)` against a GIN index.
276
+ - **React to outcomes** — `after_complete` / `after_fail`, awaited, with a
277
+ failure in one isolated from the run.
278
+ - **Tune retries and timings** — `RuntimeConfig(poll_interval=…,
279
+ election_interval=…, heartbeat_interval=…, stale_threshold=…, max_attempts=…)`.
280
+ - **Read the engine's own tables** — `flow.session()` is public, because tailing
281
+ `run_event` for a progress stream should not need a second pool.
282
+
283
+ ## What you get for free
284
+
285
+ - **Steps are memoised.** Side effects happen once, across any number of resumes.
286
+ - **A stalled step is reclaimed** and re-dispatched; the run holding it is swept
287
+ back so it can be claimed again.
288
+ - **One leader executes.** The holder's death drops its connection, releases the
289
+ lock, and the next election has a new leader — the entire failover mechanism.
290
+ - **Work is partitioned, not duplicated.** Two leaders overlapping at handover
291
+ divide the queue rather than racing over it.
292
+
293
+ Each is asserted by a test against a real Postgres, with two independent
294
+ connection pools where contention is the point.
295
+
296
+ ## Errors
297
+
298
+ | Class | From | Raised when |
299
+ |---|---|---|
300
+ | `FlowError` | `forktex_flow` | The library itself failed |
301
+ | `StepFailed` | `forktex_flow` | A step body raised past its retries |
302
+ | `WorkflowFailed` | `forktex_flow` | A run reached terminal `failed` |
303
+ | `WorkflowCancelled` | `forktex_flow` | A run was cancelled |
304
+ | `NotFoundError` | **`forktex.error`** | Unregistered workflow, or unknown run id |
305
+ | `AlreadyExistsError` | **`forktex.error`** | A `(name, version)` registered twice |
306
+ | `BadRequestError` | **`forktex.error`** | A bad argument — each method's docs say which |
307
+
308
+ The last three come from the substrate, not this package:
309
+
310
+ ```python
311
+ from forktex.error import AlreadyExistsError, BadRequestError, NotFoundError
312
+ ```
313
+
314
+ All of them derive from `forktex.error.AppError`, so a transport that renders an
315
+ `AppError` renders these with the right code rather than a masked 500.
316
+
317
+ ## Stability
318
+
319
+ **The public surface is `forktex_flow.__all__`** — 26 names, asserted as an exact
320
+ set by `tests/test_architecture/test_public_surface.py`. Adding or removing one
321
+ is a deliberate edit to that test. Anything reachable but not in `__all__` is
322
+ internal and may change in any release.
323
+
324
+ **This is 0.x, but not a free-for-all.** A public name is removed only after one
325
+ minor release in which it still works and its docstring says it is deprecated.
326
+ Behaviour changes a correct consumer would notice are called out under *Changed*
327
+ in `CHANGELOG.md`.
328
+
329
+ **`FlowExtension`'s hook signatures are frozen.** New lifecycle points arrive as
330
+ new optional methods, never as new parameters on existing ones — so an extension
331
+ written today keeps working.
332
+
333
+ **Migrations are forward-only, and an applied one is never edited.** A schema
334
+ change is a new `v000N`; `tests/test_persist/test_migration_immutability.py`
335
+ pins the content hash of every shipped migration, and
336
+ `test_schema_drift.py` proves the ORM and the SQL describe the same columns.
337
+
338
+ **Two things outside the Python API are also contracts:** the `forktex_flow`
339
+ schema name, and `run_event(id, ts, event_type, payload, run_id)`, which
340
+ consumers read directly by SQL without importing this package.
341
+
342
+ **Deliberately absent, and not returning by oversight:** graph workflows,
343
+ `step_template`, config-defined workflows, signals, `parallel`, and child
344
+ workflows. Each was measured to have zero call sites before it was cut; see
345
+ *Removed* in `CHANGELOG.md`.
346
+
347
+ ## Documentation
348
+
349
+ [`docs/flow.md`](docs/flow.md) — the reference: every public name, exact
350
+ durability and cancellation semantics, operating notes, and the gotchas.
351
+ [`docs/development.md`](docs/development.md) — contributing.
352
+
353
+ ## Licence
354
+
355
+ AGPL-3.0-or-later, or a commercial licence from FORKTEX S.R.L.
356
+ (info@forktex.com). See `LICENSE` and `NOTICE`.
357
+