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.
- sonata_engine-0.6.0/LICENSE +21 -0
- sonata_engine-0.6.0/PKG-INFO +404 -0
- sonata_engine-0.6.0/README.md +382 -0
- sonata_engine-0.6.0/pyproject.toml +168 -0
- sonata_engine-0.6.0/setup.cfg +4 -0
- sonata_engine-0.6.0/src/sonata_engine/__init__.py +101 -0
- sonata_engine-0.6.0/src/sonata_engine/core/__init__.py +38 -0
- sonata_engine-0.6.0/src/sonata_engine/core/compiled.py +116 -0
- sonata_engine-0.6.0/src/sonata_engine/core/inputs.py +98 -0
- sonata_engine-0.6.0/src/sonata_engine/core/outcome.py +37 -0
- sonata_engine-0.6.0/src/sonata_engine/core/resource_task.py +77 -0
- sonata_engine-0.6.0/src/sonata_engine/core/selection.py +35 -0
- sonata_engine-0.6.0/src/sonata_engine/core/slug.py +15 -0
- sonata_engine-0.6.0/src/sonata_engine/core/step_scope.py +28 -0
- sonata_engine-0.6.0/src/sonata_engine/core/steps.py +88 -0
- sonata_engine-0.6.0/src/sonata_engine/core/task.py +70 -0
- sonata_engine-0.6.0/src/sonata_engine/core/workflow.py +765 -0
- sonata_engine-0.6.0/src/sonata_engine/errors.py +83 -0
- sonata_engine-0.6.0/src/sonata_engine/journal.py +500 -0
- sonata_engine-0.6.0/src/sonata_engine/py.typed +0 -0
- sonata_engine-0.6.0/src/sonata_engine/retention.py +140 -0
- sonata_engine-0.6.0/src/sonata_engine/workflow/__init__.py +28 -0
- sonata_engine-0.6.0/src/sonata_engine/workflow/context.py +79 -0
- sonata_engine-0.6.0/src/sonata_engine/workflow/event_builders.py +109 -0
- sonata_engine-0.6.0/src/sonata_engine/workflow/events.py +73 -0
- sonata_engine-0.6.0/src/sonata_engine/workflow/models.py +57 -0
- sonata_engine-0.6.0/src/sonata_engine/workflow/observers.py +41 -0
- sonata_engine-0.6.0/src/sonata_engine/workflow/reporting.py +170 -0
- sonata_engine-0.6.0/src/sonata_engine.egg-info/PKG-INFO +404 -0
- sonata_engine-0.6.0/src/sonata_engine.egg-info/SOURCES.txt +36 -0
- sonata_engine-0.6.0/src/sonata_engine.egg-info/dependency_links.txt +1 -0
- sonata_engine-0.6.0/src/sonata_engine.egg-info/top_level.txt +1 -0
- sonata_engine-0.6.0/tests/test_examples.py +63 -0
- sonata_engine-0.6.0/tests/test_journal.py +527 -0
- sonata_engine-0.6.0/tests/test_package_boundaries.py +84 -0
- sonata_engine-0.6.0/tests/test_resume.py +571 -0
- sonata_engine-0.6.0/tests/test_retention.py +152 -0
- 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).
|