sonata-engine 0.6.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 (38) hide show
  1. sonata_engine-0.6.0/LICENSE +21 -0
  2. sonata_engine-0.6.0/PKG-INFO +404 -0
  3. sonata_engine-0.6.0/README.md +382 -0
  4. sonata_engine-0.6.0/pyproject.toml +168 -0
  5. sonata_engine-0.6.0/setup.cfg +4 -0
  6. sonata_engine-0.6.0/src/sonata_engine/__init__.py +101 -0
  7. sonata_engine-0.6.0/src/sonata_engine/core/__init__.py +38 -0
  8. sonata_engine-0.6.0/src/sonata_engine/core/compiled.py +116 -0
  9. sonata_engine-0.6.0/src/sonata_engine/core/inputs.py +98 -0
  10. sonata_engine-0.6.0/src/sonata_engine/core/outcome.py +37 -0
  11. sonata_engine-0.6.0/src/sonata_engine/core/resource_task.py +77 -0
  12. sonata_engine-0.6.0/src/sonata_engine/core/selection.py +35 -0
  13. sonata_engine-0.6.0/src/sonata_engine/core/slug.py +15 -0
  14. sonata_engine-0.6.0/src/sonata_engine/core/step_scope.py +28 -0
  15. sonata_engine-0.6.0/src/sonata_engine/core/steps.py +88 -0
  16. sonata_engine-0.6.0/src/sonata_engine/core/task.py +70 -0
  17. sonata_engine-0.6.0/src/sonata_engine/core/workflow.py +765 -0
  18. sonata_engine-0.6.0/src/sonata_engine/errors.py +83 -0
  19. sonata_engine-0.6.0/src/sonata_engine/journal.py +500 -0
  20. sonata_engine-0.6.0/src/sonata_engine/py.typed +0 -0
  21. sonata_engine-0.6.0/src/sonata_engine/retention.py +140 -0
  22. sonata_engine-0.6.0/src/sonata_engine/workflow/__init__.py +28 -0
  23. sonata_engine-0.6.0/src/sonata_engine/workflow/context.py +79 -0
  24. sonata_engine-0.6.0/src/sonata_engine/workflow/event_builders.py +109 -0
  25. sonata_engine-0.6.0/src/sonata_engine/workflow/events.py +73 -0
  26. sonata_engine-0.6.0/src/sonata_engine/workflow/models.py +57 -0
  27. sonata_engine-0.6.0/src/sonata_engine/workflow/observers.py +41 -0
  28. sonata_engine-0.6.0/src/sonata_engine/workflow/reporting.py +170 -0
  29. sonata_engine-0.6.0/src/sonata_engine.egg-info/PKG-INFO +404 -0
  30. sonata_engine-0.6.0/src/sonata_engine.egg-info/SOURCES.txt +36 -0
  31. sonata_engine-0.6.0/src/sonata_engine.egg-info/dependency_links.txt +1 -0
  32. sonata_engine-0.6.0/src/sonata_engine.egg-info/top_level.txt +1 -0
  33. sonata_engine-0.6.0/tests/test_examples.py +63 -0
  34. sonata_engine-0.6.0/tests/test_journal.py +527 -0
  35. sonata_engine-0.6.0/tests/test_package_boundaries.py +84 -0
  36. sonata_engine-0.6.0/tests/test_resume.py +571 -0
  37. sonata_engine-0.6.0/tests/test_retention.py +152 -0
  38. sonata_engine-0.6.0/tests/test_workflow_e2e.py +212 -0
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 miciav
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.
@@ -0,0 +1,404 @@
1
+ Metadata-Version: 2.4
2
+ Name: sonata-engine
3
+ Version: 0.6.0
4
+ Summary: A small, product-independent workflow engine for Python
5
+ Author-email: miciav <5889596+miciav@users.noreply.github.com>
6
+ License-Expression: MIT
7
+ Project-URL: Homepage, https://github.com/miciav/sonata
8
+ Project-URL: Repository, https://github.com/miciav/sonata
9
+ Project-URL: Issues, https://github.com/miciav/sonata/issues
10
+ Keywords: workflow,workflow-engine,orchestration,durable-execution
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.12
16
+ Classifier: Topic :: Software Development :: Libraries :: Python Modules
17
+ Classifier: Topic :: System :: Distributed Computing
18
+ Requires-Python: >=3.12
19
+ Description-Content-Type: text/markdown
20
+ License-File: LICENSE
21
+ Dynamic: license-file
22
+
23
+ # Sonata
24
+
25
+ Sonata is a small workflow engine for Python. You describe work as tasks; Sonata
26
+ orders them, gives each task typed access to the values and resources it
27
+ declared, and records enough to resume a run — or to run only a slice of it —
28
+ without repeating work that already succeeded.
29
+
30
+ It is product-independent: no CLI, no cloud provider, no configuration files, and
31
+ no opinion about what your tasks do. The engine is stdlib-only.
32
+
33
+ `sonata_engine` · Python 3.12+ · zero runtime dependencies · typed (`py.typed`)
34
+
35
+ ## Install
36
+
37
+ ```bash
38
+ pip install sonata-engine
39
+ ```
40
+
41
+ ## Quickstart
42
+
43
+ A task is a `Task[T]` whose `run` returns a `TaskOutcome[T]`. Inside a composite,
44
+ each step receives the value the step before it produced:
45
+
46
+ ```python
47
+ from sonata_engine import Steps, Task, TaskInputs, TaskOutcome, Workflow
48
+
49
+
50
+ class Build(Task[str]):
51
+ title = "Build image"
52
+
53
+ def run(self, inputs: TaskInputs) -> TaskOutcome[str]:
54
+ return TaskOutcome(value="registry.example/api:v1")
55
+
56
+
57
+ class Deploy(Task[str]):
58
+ title = "Deploy"
59
+
60
+ def run(self, inputs: TaskInputs) -> TaskOutcome[str]:
61
+ image = inputs.upstream()
62
+ return TaskOutcome(value=f"{image} -> staging")
63
+
64
+
65
+ workflow = Workflow(workflow_id="release")
66
+ workflow.add(Steps(title="Release", steps=(Build(), Deploy())))
67
+
68
+ result = workflow.run()
69
+ unit = result.tasks[0]
70
+ print(unit.task_id, "->", unit.outcome.value)
71
+ ```
72
+
73
+ ```
74
+ 001.release -> registry.example/api:v1 -> staging
75
+ ```
76
+
77
+ ## The mental model
78
+
79
+ **Tasks have no IDs of their own.** `Workflow.run()` compiles the workflow and
80
+ assigns each unit a deterministic ordinal and slug — `001.release`. Results,
81
+ journal records and events are all keyed by that compiled ID, so nothing in your
82
+ code has to invent, carry or hardcode one.
83
+
84
+ **A composite is one unit.** `Steps` runs its steps in order and is itself a
85
+ single compiled unit: one ordinal, one journal entry, one fate. Its value is the
86
+ last step's value. The steps are journalled individually, which is what lets a
87
+ resumed run skip the ones that already finished.
88
+
89
+ **Values flow between steps, not between units.** Each task passed to
90
+ `workflow.add(...)` is an independent unit: it receives no value from whatever
91
+ ran before it, and calling `inputs.upstream()` there raises
92
+ `NoUpstreamValueError`. Values move along the steps of a composite; units share
93
+ resources and the journal, not values.
94
+
95
+ **`TaskInputs` is capability-limited.** It is a task's access to the values it
96
+ declared — its upstream value and its resource values, and nothing else. It is
97
+ not `WorkflowContext`, which carries flow and task identifiers for reporting and
98
+ event correlation. A task receives `TaskInputs`, never `WorkflowContext`.
99
+
100
+ **Outcomes carry evidence.** Besides its value, a `TaskOutcome` holds `evidence`,
101
+ which is what makes a task skippable on a later run. See *Reusing work*.
102
+
103
+ ## Watching a run
104
+
105
+ Bind a sink and the engine reports progress through it. The contract is small:
106
+ `emit` for events, and a `status` context manager for whatever progress display
107
+ you already have. Nothing is bound by default, and with no sink bound `subtask`
108
+ is a no-op.
109
+
110
+ ```python
111
+ from contextlib import contextmanager
112
+
113
+ from sonata_engine import WorkflowEvent, bind_workflow_sink
114
+
115
+
116
+ class ConsoleSink:
117
+ def emit(self, event: WorkflowEvent) -> None:
118
+ depth = 1 if event.parent_task_id else 0
119
+ print(f"{' ' * depth}{event.task_id:<32} {event.kind}")
120
+
121
+ @contextmanager
122
+ def status(self, label: str):
123
+ yield
124
+
125
+
126
+ with bind_workflow_sink(ConsoleSink()):
127
+ workflow.run()
128
+ ```
129
+
130
+ A composite of three steps reports as:
131
+
132
+ ```
133
+ 001.release task.started
134
+ 001.release/build-image task.started
135
+ 001.release/build-image task.passed
136
+ 001.release/scan-image task.started
137
+ 001.release/scan-image task.passed
138
+ 001.release/deploy task.started
139
+ 001.release/deploy task.passed
140
+ 001.release task.passed
141
+ ```
142
+
143
+ The steps are named by the engine, from their titles, under the compiled unit, so
144
+ you choose titles and nothing else.
145
+
146
+ ## Assembling a task from steps
147
+
148
+ `Steps` is for when the operations *are* the task. A task made of steps needs no
149
+ `run()` of its own: `Steps` takes them and runs them in order, feeding each the
150
+ value the one before produced.
151
+
152
+ ```python
153
+ from sonata_engine import Steps
154
+
155
+ workflow.add(
156
+ Steps(
157
+ title="Deploy the chart",
158
+ steps=(HelmInstall(chart), WaitRollout(), ResolveEndpoint()),
159
+ )
160
+ )
161
+ ```
162
+
163
+ Each step is an ordinary `Task`, so anything already written serves as one, and a
164
+ step that needs no input simply never calls `inputs.upstream()`.
165
+
166
+ Because its steps are journalled individually, a resumed run skips the ones
167
+ already finished — what may be skipped is decided exactly as for any other unit,
168
+ by a `ReusableTask` whose evidence still verifies. Build five images, have the
169
+ fifth fail, resume, and only the fifth runs again. A step's only legal value is
170
+ `None`, which the engine reconstructs when the step is skipped.
171
+
172
+ ## Reporting progress inside one task
173
+
174
+ A hand-written `run()` can report progress without turning that work into
175
+ journalled steps. `subtask` emits the same events a compiled step does, nested
176
+ under whichever task is running:
177
+
178
+ ```python
179
+ class PublishImages(Task[str]):
180
+ title = "Publish images"
181
+
182
+ def __init__(self, slug: str, images: tuple[str, ...]) -> None:
183
+ self._slug = slug
184
+ self._images = images
185
+
186
+ def run(self, inputs: TaskInputs) -> TaskOutcome[str]:
187
+ for image in self._images:
188
+ with subtask(task_id=f"{self._slug}/build/{image}", title=f"Build {image}"):
189
+ ... # build it
190
+
191
+ with subtask(task_id=f"{self._slug}/scan", title="Scan for vulnerabilities"):
192
+ ... # scan everything built above
193
+
194
+ with subtask(task_id=f"{self._slug}/push", title="Push the tags"):
195
+ digest = ... # push, and keep what the registry answered
196
+
197
+ return TaskOutcome(value=digest)
198
+ ```
199
+
200
+ Subtasks need not come from a loop and need not resemble each other — those are
201
+ three different kinds of step, and the last one produces the value the whole task
202
+ returns.
203
+
204
+ The step stays one compiled unit: one ordinal, one journal entry, one thing a
205
+ `Selection` can name. If the scan fails the whole unit fails, because sharing one
206
+ fate is what makes these one unit rather than four. Subtasks exist in the event
207
+ stream only, so a consumer's UI can show progress through a long step, and a
208
+ resumed run restarts that step from its beginning. If the reported operations are
209
+ the task's actual sequential steps, prefer `Steps` above: it names and journals
210
+ them for you.
211
+
212
+ Pick `task_id` yourself and keep it unique within the run. A consumer keys child
213
+ phases by it, so a repeat merges two steps into one. Do not imitate the engine's
214
+ `NNN.slug` IDs: those are the engine's, and a task is not told its own. `slug` is
215
+ a constructor argument rather than a hardcoded literal because two instances of
216
+ the same task class (two `PublishImages` in one workflow) need something to tell
217
+ their subtask IDs apart.
218
+
219
+ Open subtasks sequentially, on the thread running the task. The parent is
220
+ resolved through a context shared as a fallback for worker threads, which start
221
+ with none of their own; subtasks opened concurrently from worker threads — or one
222
+ left open past its `with` block while another opens — nest under each other
223
+ instead of under the unit, silently.
224
+
225
+ ## Resources
226
+
227
+ A `Resource[T]` acquires a runtime value of type `T` and receives that same typed
228
+ value back when it is released. Consumers declare what they need; the compiler
229
+ splices acquire and release units around them, and cleanup runs in reverse
230
+ acquisition order after success or failure.
231
+
232
+ ```python
233
+ from sonata_engine import Resource, TaskInputs
234
+
235
+
236
+ def start_builder(inputs: TaskInputs) -> Builder:
237
+ return Builder()
238
+
239
+
240
+ def stop_builder(inputs: TaskInputs, builder: Builder) -> None:
241
+ builder.stop()
242
+
243
+
244
+ builder: Resource[Builder] = Resource(
245
+ title="Acquire builder",
246
+ acquire=start_builder,
247
+ release=stop_builder,
248
+ acquire_idempotent=True,
249
+ )
250
+
251
+ workflow.add(Build(), requires=(builder,))
252
+ ```
253
+
254
+ Inside `Build.run`, `inputs.resource(builder)` returns the acquired value. A
255
+ resource may itself declare `requires=(other_resource,)`; those dependencies are
256
+ acquired first, stay available to their lifecycle callbacks, and are released
257
+ after their dependents. Declare only the resources you use directly. A cycle
258
+ fails compilation with `ResourceDependencyCycleError`.
259
+
260
+ `acquire_idempotent=False` is the safe default: a failed or interrupted acquire is
261
+ ambiguous, and resume refuses to retry it automatically.
262
+
263
+ ### Retention
264
+
265
+ With `Workflow(workflow_id=..., keep=True)`, every resource is retained except
266
+ those declaring `always_release=True`. Retention is opt-out because the two
267
+ questions it used to conflate are unrelated: whether a resource is expensive to
268
+ rebuild, and whether it is safe to leave behind. A resource holding a secret — a
269
+ staged token, a signing key, an open credential lease — declares
270
+ `always_release`, so leaving one behind stops being something a caller can cause
271
+ by forgetting to classify it.
272
+
273
+ Each retained resource is written to the journal with its acquired value, and a
274
+ later process releases them with:
275
+
276
+ ```python
277
+ from sonata_engine import release_retained
278
+
279
+ release_retained({vm.title: vm}, JournalConfig(path))
280
+ ```
281
+
282
+ Values are journaled as JSON, so a resource whose value cannot be encoded is
283
+ released rather than retained — a retention the journal cannot record is a
284
+ promise it cannot keep. `Resource.revive` rebuilds a dataclass value from its
285
+ record, since a release written against one would break on a dict.
286
+
287
+ ## Reusing work
288
+
289
+ A `ReusableTask` may be skipped only when it returned non-empty evidence and every
290
+ evidence item has a successful verifier. It must also expose a deterministic
291
+ `reuse_key` that changes whenever its semantic inputs change; that key
292
+ participates in the workflow fingerprint. Reusable tasks cannot return a runtime
293
+ value.
294
+
295
+ Sonata ships one generic verifier, `file-digest`. Domain evidence — an OCI
296
+ artifact, a deployed release — must be verified by a verifier you inject:
297
+
298
+ ```python
299
+ result = workflow.run(
300
+ journal=JournalConfig(Path("run/journal.jsonl")),
301
+ resume=True,
302
+ verifiers={"oci-image": verify_oci_image},
303
+ )
304
+ ```
305
+
306
+ ## Running a slice
307
+
308
+ `Selection` narrows a run to some of its consumer tasks, addressed by title slug:
309
+
310
+ ```python
311
+ from sonata_engine import Selection
312
+
313
+ workflow.run(select=Selection(only="build-image"))
314
+ workflow.run(select=Selection(start="build-image", until="publish-manifest"))
315
+ ```
316
+
317
+ Selection names tasks by slug rather than by compiled `task_id`, because ordinals
318
+ renumber over the survivors — an ID is not a stable handle for the very operation
319
+ that changes it. Resources are not selectable: the compiler re-splices acquire and
320
+ release around whichever consumers survive, retaining every transitive resource
321
+ dependency those consumers need, so a slice keeps its setup and cleanup. That
322
+ renumbering makes a sliced run a different topology, so `resume` across one fails
323
+ closed.
324
+
325
+ ## Journal and resume
326
+
327
+ Passing `journal=` writes an optional JSON Lines journal, created from the full
328
+ compiled topology. Each task starts at attempt `0`, status `pending`; later
329
+ attempts append lifecycle records. Every record carries a deterministic workflow
330
+ fingerprint, and resume fails if the ordered task topology or a task's type
331
+ changed. A torn final line is removed before continuing, while any complete
332
+ malformed record raises `CorruptJournalError`.
333
+
334
+ Runtime values — `TaskOutcome.value` and acquired resource values — are in-process
335
+ only: they are not journaled and are not reconstructed by resume. Make resumed
336
+ work depend on durable, verifier-backed evidence rather than on a value from a
337
+ previous run. Resource acquire callbacks run again on resume, yielding fresh
338
+ in-process values for the resumed run.
339
+
340
+ ## Upgrading
341
+
342
+ **A Sonata upgrade invalidates existing journals.** The fingerprint is derived
343
+ from the compiled topology, which includes the engine's own internal shape: for
344
+ example, adding resource-dependency edges in `0.2.0` changed the fingerprint of
345
+ every workflow, even ones that declare no resources. With `resume=True` a
346
+ fingerprint mismatch raises `WorkflowTopologyMismatchError` — loud and correct.
347
+ Without `resume` (a plain `journal=` run), old records for a different fingerprint
348
+ are simply ignored and a new topology is appended to the *same file*; this emits a
349
+ `UserWarning` (Sonata adds no logging dependency) but the run itself proceeds.
350
+ Start a fresh journal file after upgrading if you don't want mixed topologies
351
+ accumulating in one file.
352
+
353
+ **`0.2.0` changed two call signatures with no compatibility shim.** Pre-existing
354
+ code must be migrated before upgrading:
355
+
356
+ - `Task.run(self)` → `Task.run(self, inputs: TaskInputs)`. Every concrete task's
357
+ `run` now takes the workflow's `TaskInputs` as its one argument.
358
+ - `Resource(acquire=lambda: ..., release=lambda: ...)` →
359
+ `Resource(acquire=lambda inputs: ..., release=lambda inputs, value: ...)`.
360
+ `acquire` now takes `TaskInputs` and returns the resource's runtime value;
361
+ `release` now takes `TaskInputs` and that same value.
362
+
363
+ Old-shape code raises `TypeError` at call time (`run() takes 1 positional argument
364
+ but 2 were given`) rather than silently misbehaving, so the break is loud. It is
365
+ what makes `TaskInputs`/`Resource` dependencies possible at all.
366
+
367
+ ## Related
368
+
369
+ This repository also builds **`sonata-tasks`**, a reusable task catalogue layered
370
+ on the engine. Its public contracts, extras and journal migration rules are
371
+ documented in
372
+ [`docs/task-catalog.md`](https://github.com/miciav/sonata/blob/main/docs/task-catalog.md);
373
+ an independent executable consumer lives in
374
+ [`examples/shared_tasks_client.py`](https://github.com/miciav/sonata/blob/main/examples/shared_tasks_client.py).
375
+
376
+ ## Development
377
+
378
+ The repository is a uv workspace of two packages, so most checks run per package
379
+ rather than over the whole tree:
380
+
381
+ ```bash
382
+ uv sync --all-packages --all-groups --all-extras # install the whole workspace
383
+
384
+ # Engine
385
+ uv run pytest -c pyproject.toml tests
386
+ uv run ruff check src tests
387
+ uv run basedpyright --project .
388
+
389
+ # Catalogue
390
+ uv run pytest -c packages/sonata-tasks/pyproject.toml packages/sonata-tasks/tests
391
+ uv run ruff check --config packages/sonata-tasks/pyproject.toml packages/sonata-tasks
392
+ uv run basedpyright --project packages/sonata-tasks
393
+ uv run lint-imports --config packages/sonata-tasks/.importlinter --no-cache
394
+
395
+ # Everything CI runs, in one go
396
+ uv run pre-commit run --all-files
397
+ ```
398
+
399
+ ruff, basedpyright, bandit and import-linter are all wired into pre-commit, so
400
+ local and CI results cannot drift, and `pytest` enforces the coverage gate
401
+ declared in `[tool.coverage.report]`.
402
+
403
+ The v2 design and the migration sequence it came from are documented in
404
+ [`docs/plans/2026-07-24-release-on-workflow-engine.md`](https://github.com/miciav/sonata/blob/main/docs/plans/2026-07-24-release-on-workflow-engine.md).