pangea-flows 0.3.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.
- pangea_flows-0.3.0/.gitignore +5 -0
- pangea_flows-0.3.0/PKG-INFO +306 -0
- pangea_flows-0.3.0/README.md +293 -0
- pangea_flows-0.3.0/pyproject.toml +31 -0
- pangea_flows-0.3.0/src/pangea_flows/__init__.py +48 -0
- pangea_flows-0.3.0/src/pangea_flows/capture.py +144 -0
- pangea_flows-0.3.0/src/pangea_flows/cli.py +240 -0
- pangea_flows-0.3.0/src/pangea_flows/client.py +340 -0
- pangea_flows-0.3.0/src/pangea_flows/errors.py +33 -0
- pangea_flows-0.3.0/src/pangea_flows/keys.py +149 -0
- pangea_flows-0.3.0/src/pangea_flows/models.py +128 -0
- pangea_flows-0.3.0/src/pangea_flows/planner.py +69 -0
- pangea_flows-0.3.0/src/pangea_flows/runner.py +540 -0
- pangea_flows-0.3.0/src/pangea_flows/serve/__init__.py +298 -0
- pangea_flows-0.3.0/src/pangea_flows/serve/api.py +88 -0
- pangea_flows-0.3.0/src/pangea_flows/serve/config.py +133 -0
- pangea_flows-0.3.0/src/pangea_flows/serve/sources.py +270 -0
- pangea_flows-0.3.0/src/pangea_flows/simple.py +143 -0
- pangea_flows-0.3.0/src/pangea_flows/template.py +123 -0
- pangea_flows-0.3.0/src/pangea_flows/workflow.py +284 -0
- pangea_flows-0.3.0/tests/README.md +16 -0
- pangea_flows-0.3.0/tests/test_capture.py +58 -0
- pangea_flows-0.3.0/tests/test_cli.py +72 -0
- pangea_flows-0.3.0/tests/test_client.py +62 -0
- pangea_flows-0.3.0/tests/test_integration.py +445 -0
- pangea_flows-0.3.0/tests/test_keys.py +76 -0
- pangea_flows-0.3.0/tests/test_serve.py +415 -0
- pangea_flows-0.3.0/tests/test_simple.py +177 -0
- pangea_flows-0.3.0/uv.lock +689 -0
|
@@ -0,0 +1,306 @@
|
|
|
1
|
+
Metadata-Version: 2.5
|
|
2
|
+
Name: pangea-flows
|
|
3
|
+
Version: 0.3.0
|
|
4
|
+
Summary: Write PANGEA processing workflows in Python
|
|
5
|
+
Author-email: Thanasis Georgiou <ageorgiou@noa.gr>
|
|
6
|
+
Requires-Python: >=3.10
|
|
7
|
+
Requires-Dist: httpx>=0.27
|
|
8
|
+
Requires-Dist: pydantic>=2.6
|
|
9
|
+
Provides-Extra: serve
|
|
10
|
+
Requires-Dist: croniter>=2; extra == 'serve'
|
|
11
|
+
Requires-Dist: pynacl>=1.5; extra == 'serve'
|
|
12
|
+
Description-Content-Type: text/markdown
|
|
13
|
+
|
|
14
|
+
# pangea-flows
|
|
15
|
+
|
|
16
|
+
Write PANGEA processing workflows in Python: format conversions, plots, uploads to other databases. You write a function that turns input files into output files. The platform keeps track of what is done, what is waiting and what failed, so your code doesn't have to.
|
|
17
|
+
|
|
18
|
+
## The quick way
|
|
19
|
+
|
|
20
|
+
```python
|
|
21
|
+
from pangea_flows import each_file
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
@each_file(inputs="pollyxt/raw", pattern="*.nc", output="pollyxt/quicklooks")
|
|
25
|
+
def quicklook(path, out_dir):
|
|
26
|
+
"""A plot of each raw file"""
|
|
27
|
+
plot(path, out_dir / f"{path.stem}.png") # Your code
|
|
28
|
+
```
|
|
29
|
+
|
|
30
|
+
That is a whole workflow. Every 15 minutes it looks for new files in `pollyxt/raw`, downloads each one, and calls `quicklook` with its path and an empty folder. Whatever files are in `out_dir` when it returns become one datum in `pollyxt/quicklooks`, with the input's time, linked to the input. Write nothing to produce nothing. Raise an exception and the file is tried again later.
|
|
31
|
+
|
|
32
|
+
For one output per hour or day, from all the files in it:
|
|
33
|
+
|
|
34
|
+
```python
|
|
35
|
+
from datetime import timedelta
|
|
36
|
+
from pangea_flows import each_window, hourly
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
@each_window(hourly(settle=timedelta(hours=2)), inputs="pollyxt/raw", pattern="*.nc", output="pollyxt/hourly")
|
|
40
|
+
def hourly_plot(paths, window, out_dir):
|
|
41
|
+
plot(paths, window.start, window.end, out_dir / f"{window.start:%Y%m%d_%H}.png")
|
|
42
|
+
```
|
|
43
|
+
|
|
44
|
+
`paths` are sorted by time. `settle` waits for files that arrive late. `daily()` or any `timedelta` work too.
|
|
45
|
+
|
|
46
|
+
Both take `schedule=` and the other options of `@workflow`, below. To start a project:
|
|
47
|
+
|
|
48
|
+
```bash
|
|
49
|
+
pangea-flows new my-flows # flows.py, pyproject.toml, a README and .env.example
|
|
50
|
+
cd my-flows && uv sync
|
|
51
|
+
pangea-flows try quicklook # Runs it on the last day's data, into ./out/
|
|
52
|
+
```
|
|
53
|
+
|
|
54
|
+
## The full form
|
|
55
|
+
|
|
56
|
+
When a workflow needs more, like several outputs, metadata or its own grouping of inputs, write it with `@workflow`. `each_file` above is this, written out:
|
|
57
|
+
|
|
58
|
+
```python
|
|
59
|
+
from pangea_flows import Context, Item, Output, per_datum, workflow
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
@workflow(inputs="pollyxt/raw", outputs="pollyxt/quicklooks", keys=per_datum())
|
|
63
|
+
def quicklook(item: Item, ctx: Context) -> list[Output]:
|
|
64
|
+
out_dir = ctx.tmpdir / "out"
|
|
65
|
+
out_dir.mkdir()
|
|
66
|
+
for path in ctx.fetch(item.inputs, pattern="*.nc"):
|
|
67
|
+
plot(path, out_dir / f"{path.stem}.png")
|
|
68
|
+
files = list(out_dir.iterdir())
|
|
69
|
+
return [Output("pollyxt/quicklooks", files, timestamp=item.inputs[0].timestamp)] if files else []
|
|
70
|
+
```
|
|
71
|
+
|
|
72
|
+
And with hours, two outputs and a setting of its own:
|
|
73
|
+
|
|
74
|
+
```python
|
|
75
|
+
from datetime import timedelta
|
|
76
|
+
from pangea_flows import Context, Item, Output, hourly, workflow
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
@workflow(
|
|
80
|
+
inputs="pollyxt/raw",
|
|
81
|
+
outputs="pollyxt/quicklooks",
|
|
82
|
+
keys=hourly(settle=timedelta(hours=2), span=timedelta(hours=1)),
|
|
83
|
+
schedule="0 * * * *",
|
|
84
|
+
)
|
|
85
|
+
def quicklooks(item: Item, ctx: Context) -> list[Output]:
|
|
86
|
+
"""Hourly backscatter plots"""
|
|
87
|
+
files = ctx.fetch(item.inputs, pattern="*.nc")
|
|
88
|
+
png = ctx.tmpdir / f"{item.key}.png"
|
|
89
|
+
plot(files, item.window, png) # Your code
|
|
90
|
+
return [Output("pollyxt/quicklooks", png, timestamp=item.window.start)]
|
|
91
|
+
```
|
|
92
|
+
|
|
93
|
+
## How it works
|
|
94
|
+
|
|
95
|
+
Every workflow has a **ledger** on the platform: one **item** per unit of work, such as an hour of data. Each run:
|
|
96
|
+
|
|
97
|
+
1. **Plans**: follows the uploads to the input datasets since the last run, and adds their datums to items. A datum only counts once all its files are uploaded.
|
|
98
|
+
2. **Claims** items that are ready, one at a time. Two runs never get the same item.
|
|
99
|
+
3. **Calls your function** with the item's inputs, in a temporary directory of its own.
|
|
100
|
+
4. **Registers the outputs** your function returns, uploads their files, and records where they came from.
|
|
101
|
+
5. **Marks the item** done, or failed with the error, to be retried later.
|
|
102
|
+
|
|
103
|
+
Each run is recorded on the platform with its log, and each attempt at an item with what the function logged and printed (the last 64 KB of each). A run that fails outside any item, for example because a dataset name is wrong, notifies the instrument's subscribers, once until it works again. Members can also ask for a run from the platform instead of waiting for the schedule.
|
|
104
|
+
|
|
105
|
+
A run that crashes or hangs loses its claim when the lease runs out, and another run takes the item over. When late data arrives for an item that is already done, the item runs again, and its outputs are updated in place rather than duplicated.
|
|
106
|
+
|
|
107
|
+
## Setting up a workflow
|
|
108
|
+
|
|
109
|
+
1. On the platform, a member of the instrument creates the workflow (`POST /workflows`). They choose which datasets it may write to, then issue its token (`POST /workflows/:id/token`).
|
|
110
|
+
2. Give the token to the runner as `PANGEA_TOKEN_<NAME>`, where `<NAME>` is the function's name in capitals (`PANGEA_TOKEN_QUICKLOOKS` above), and set `PANGEA_URL`.
|
|
111
|
+
3. Try it first with `pangea-flows try quicklooks`, which only reads (see Running). Then run it once for real: `pangea-flows run quicklooks`. The first run only records where to start from, so nothing happens until new data is uploaded. Pass `start=` to `@workflow` to also take data from earlier.
|
|
112
|
+
|
|
113
|
+
## Writing the function
|
|
114
|
+
|
|
115
|
+
It gets an `Item` and a `Context`, and returns a list of `Output`s, or nothing.
|
|
116
|
+
|
|
117
|
+
| | |
|
|
118
|
+
| --- | --- |
|
|
119
|
+
| `item.key` | The item, e.g. `2026-11-04T10:00Z` |
|
|
120
|
+
| `item.inputs` | Its datums (`.id`, `.timestamp`, `.metadata`, `.files`) |
|
|
121
|
+
| `item.window` | The time it covers (`.start`, `.end`), for time-based keys |
|
|
122
|
+
| `item.attempt` | 1 the first time, then 2, 3... |
|
|
123
|
+
| `ctx.fetch(datums, pattern="*.nc")` | Downloads files into `ctx.tmpdir` and returns their paths |
|
|
124
|
+
| `ctx.tmpdir` | A directory to work in, removed afterwards |
|
|
125
|
+
| `ctx.log` | A logger |
|
|
126
|
+
| `ctx.cancelled` | True if another run took the item over; you can stop early |
|
|
127
|
+
| `ctx.settings` | The workflow's settings, read-only (see below) |
|
|
128
|
+
| `ctx.datums(dataset, after=, before=, current=, ...)` | Datums of any dataset, with their files and metadata (see below) |
|
|
129
|
+
| `ctx.datum(id)` | One datum |
|
|
130
|
+
|
|
131
|
+
**Settings** are the extra keyword arguments of `@workflow`. They show on the platform, and the function reads them as `ctx.settings`:
|
|
132
|
+
|
|
133
|
+
```python
|
|
134
|
+
@workflow(inputs=..., outputs=..., keys=per_datum(), calibration_timeout_minutes=10, location="Antikythera")
|
|
135
|
+
def scc_sync(item: Item, ctx: Context) -> list[Output]:
|
|
136
|
+
timeout = ctx.settings["calibration_timeout_minutes"]
|
|
137
|
+
```
|
|
138
|
+
|
|
139
|
+
They change with a commit, not on the platform. `each_file` and `each_window` functions don't get a `ctx`, so use `@workflow` when the function needs settings or other datums.
|
|
140
|
+
|
|
141
|
+
**Other datums**: `ctx.datums(dataset, ...)` lists any dataset's datums, oldest first, with their files and metadata, so `ctx.fetch` works on them too. `dataset` is named like inputs. `after` and `before` (measurement time) and `created_after` and `created_before` (registered on the platform) leave out the bound itself. `current=True` leaves out outputs that a workflow no longer makes: when an item runs again and doesn't return an output it returned before, that output is superseded. Also `final=`, `order="desc"` and `limit=`. For example, to compare a calibration with the earlier ones that passed:
|
|
142
|
+
|
|
143
|
+
```python
|
|
144
|
+
history = [
|
|
145
|
+
d.metadata
|
|
146
|
+
for d in ctx.datums("pollyxt/scc-calibration", before=calibration_start, current=True)
|
|
147
|
+
if d.metadata
|
|
148
|
+
]
|
|
149
|
+
```
|
|
150
|
+
|
|
151
|
+
`PangeaClient.datums(...)` does the same outside a workflow.
|
|
152
|
+
|
|
153
|
+
`Output(dataset, files, timestamp, key=None, sources=None, metadata=None, final=False)`:
|
|
154
|
+
|
|
155
|
+
- `dataset` must be one of the workflow's `outputs`.
|
|
156
|
+
- `key` names the output within the item, and defaults to the dataset's slug. Give each output its own key when there are several in one dataset, like `key=f"calibration-{measurement_id}"`. When the item runs again, an output with the same key updates the same datum, so keep keys and filenames stable.
|
|
157
|
+
- `sources` defaults to all of the item's inputs. Name the ones you actually used, if fewer.
|
|
158
|
+
- `metadata` replaces the datum's metadata each time, and `None` clears it, so a rerun never leaves stale values.
|
|
159
|
+
- An item with nothing to produce returns `[]`. It is done, and won't run again.
|
|
160
|
+
|
|
161
|
+
**Errors**: any exception fails the attempt, and the item is retried later: after 10 minutes, then 20, and so on, up to `max_attempts` (3 by default). After that it is FAILED and the instrument's subscribers are notified. Raise `FailPermanently("why")` when retrying can't help, such as for a corrupt input. A member can reset FAILED items on the platform once the problem is fixed.
|
|
162
|
+
|
|
163
|
+
**Things outside PANGEA**: when a workflow also uploads somewhere else, it may run again for the same item, so check whether that system already has the data first.
|
|
164
|
+
|
|
165
|
+
## Keys: what an item is
|
|
166
|
+
|
|
167
|
+
| | One item per... | Keys |
|
|
168
|
+
| --- | --- | --- |
|
|
169
|
+
| `per_datum()` | input datum | `datum-16584` |
|
|
170
|
+
| `hourly()` | hour (UTC) | `2026-11-04T10:00Z` |
|
|
171
|
+
| `daily()` | day (UTC) | `2026-11-04` |
|
|
172
|
+
| `TimeWindows(timedelta(minutes=30))` | any window that divides a day | `2026-11-04T10:30Z` |
|
|
173
|
+
|
|
174
|
+
Time-based schemes take:
|
|
175
|
+
|
|
176
|
+
- `settle`: how long after a window ends to wait before running it, for data that arrives late.
|
|
177
|
+
- `span`: how long each datum covers, so that it goes to every window it overlaps. A `timedelta` from its timestamp, or a function returning `(start, end)`, e.g. from `datum.metadata`. By default a datum belongs only to the window of its timestamp.
|
|
178
|
+
|
|
179
|
+
For other rules, subclass `KeyScheme`: `keys_for(datum)` returns the item keys a datum belongs to, and `name` identifies the scheme. A workflow's scheme can never change, since its existing keys would mean something else; a different scheme needs a new workflow.
|
|
180
|
+
|
|
181
|
+
## Datasets
|
|
182
|
+
|
|
183
|
+
Name datasets as `"instrument/dataset"` by slug, by their slug alone if they belong to the workflow's own instrument, or by ID.
|
|
184
|
+
|
|
185
|
+
Changing a workflow's inputs or outputs is refused, in case the code was changed by mistake, or it is someone else's code with this token. Run once with `--accept-changes` when it is intended.
|
|
186
|
+
|
|
187
|
+
## Running
|
|
188
|
+
|
|
189
|
+
```bash
|
|
190
|
+
pangea-flows new my-flows # Start a project in a new folder
|
|
191
|
+
pangea-flows list # The workflows in flows.py, and their token variables
|
|
192
|
+
pangea-flows try quicklooks # Try it on the last day's uploads
|
|
193
|
+
pangea-flows try quicklooks --since 2026-11-01 --key 2026-11-01T10:00Z
|
|
194
|
+
pangea-flows run quicklooks # Plan and process up to max_items items, once
|
|
195
|
+
```
|
|
196
|
+
|
|
197
|
+
A workflow is named by its name in `flows.py`, or as `module`, `module:name` or `file.py:name`. `PANGEA_URL` and the tokens can also go in a `.env` file in the current folder.
|
|
198
|
+
|
|
199
|
+
`try` (also `dry-run`) doesn't touch the ledger: it plans from uploads since `--since`, a day ago by default, runs your function, and copies the outputs into `./out/<key>/<output key>/`. It only reads, so your personal API token works too (`PANGEA_TOKEN`).
|
|
200
|
+
|
|
201
|
+
### Running workflows from their repositories
|
|
202
|
+
|
|
203
|
+
On the platform, a member gives the workflow a **source**: the git repository, the branch and the workflow in it, like `flows.py:quicklook`. For a private repository, the workflow's page then shows a public key: add it to the repository as a read-only deploy key (on GitHub: the repository's Settings → Deploy keys). From then on, every push to the branch is picked up within a minute or so. A run that fails to build or import shows on the workflow's page with the error.
|
|
204
|
+
|
|
205
|
+
The repository needs a `uv.lock`, so the server installs exactly what you tested with: `pangea-flows new` sets that up. Runs get 4 GB of memory, 2 CPUs and 2 hours by default; ask for more in the decorator, up to what the server allows:
|
|
206
|
+
|
|
207
|
+
```python
|
|
208
|
+
@each_file(inputs="pollyxt/raw", output="pollyxt/quicklooks", memory="8g", cpus=4, timeout=timedelta(hours=6))
|
|
209
|
+
```
|
|
210
|
+
|
|
211
|
+
The server side is `pangea-flows serve` (`pip install "pangea-flows[serve]"`, Python 3.11+, git and podman). An admin creates a runner on the platform (`POST /runners`, then `POST /runners/:id/token`), and writes its settings:
|
|
212
|
+
|
|
213
|
+
```toml
|
|
214
|
+
api_url = "https://pangea.example.org/api"
|
|
215
|
+
token_file = "token" # Or $PANGEA_RUNNER_TOKEN
|
|
216
|
+
secrets_key_file = "secrets.key" # Or $PANGEA_RUNNER_SECRETS_KEY; see below
|
|
217
|
+
state_dir = "/var/lib/pangea-runner" # Clones, deploy keys, scratch space
|
|
218
|
+
|
|
219
|
+
[container]
|
|
220
|
+
api_url = "https://pangea.example.org/api" # What runs use; api_url by default
|
|
221
|
+
network = "" # podman --network for runs; its default if empty
|
|
222
|
+
# With podman's default network, a run can't reach this host by its own address.
|
|
223
|
+
# If the API is on the same host, point its name at the host instead:
|
|
224
|
+
add_hosts = ["pangea.example.org:host-gateway"]
|
|
225
|
+
|
|
226
|
+
[limits] # For runs that don't ask
|
|
227
|
+
memory = "4g"
|
|
228
|
+
cpus = 2
|
|
229
|
+
timeout_minutes = 120
|
|
230
|
+
|
|
231
|
+
[max] # The most a run may ask for
|
|
232
|
+
memory = "16g"
|
|
233
|
+
cpus = 4
|
|
234
|
+
timeout_minutes = 1440
|
|
235
|
+
```
|
|
236
|
+
|
|
237
|
+
`secrets_key_file` holds the private key, base64, that [secrets](#secrets) are sealed to; the API has its public key as `WORKFLOW_SECRETS_PUBLIC_KEY` (see `packages/pangea-api/docs/runners.md` for making the pair). The runner opens each run's secrets and passes them to its container by name, with the values only in podman's environment, and masks them in the output it keeps. Without the key, runs of workflows that have secrets fail with a message saying so.
|
|
238
|
+
|
|
239
|
+
Then `pangea-flows serve --config runner.toml`. It builds an image for each new commit (from `ghcr.io/astral-sh/uv`, with `uv sync --frozen`), and runs each workflow in a container with a read-only filesystem, a scratch `/tmp` on the host, its memory and CPU limits, and a token for that run only. See `packages/pangea-api/docs/runners.md` for what it does on the platform.
|
|
240
|
+
|
|
241
|
+
### From Python
|
|
242
|
+
|
|
243
|
+
```python
|
|
244
|
+
from pangea_flows import run_once
|
|
245
|
+
|
|
246
|
+
report = run_once(quicklooks) # Or run_once(quicklooks, client=PangeaClient(url, token))
|
|
247
|
+
print(report.done, report.failed, report.run_id)
|
|
248
|
+
```
|
|
249
|
+
|
|
250
|
+
## Secrets
|
|
251
|
+
|
|
252
|
+
Credentials for systems outside PANGEA, such as an SCC login, are **secrets**. Members set them on the workflow's page on the platform, and runs get each as an environment variable. Declare the ones the code needs:
|
|
253
|
+
|
|
254
|
+
```python
|
|
255
|
+
@workflow(..., secrets=["SCC_USERNAME", "SCC_PASSWORD"])
|
|
256
|
+
def scc_sync(item: Item, ctx: Context) -> list[Output]:
|
|
257
|
+
login(os.environ["SCC_USERNAME"], os.environ["SCC_PASSWORD"])
|
|
258
|
+
```
|
|
259
|
+
|
|
260
|
+
- If one isn't set, the run fails at the start with `Missing secrets: SCC_PASSWORD`, instead of every item failing. The platform shows which are missing.
|
|
261
|
+
- Names are like `SCC_PASSWORD`. `PANGEA_*`, `HOME`, `TMPDIR`, `PATH` and `PYTHONUNBUFFERED` are the runner's.
|
|
262
|
+
- Values are masked as `***` in the logs and errors sent to the platform. Values shorter than 6 characters aren't masked.
|
|
263
|
+
- `pangea-flows run` and `try` read them from the environment or `.env`, like the token. Keep `.env` out of git.
|
|
264
|
+
- On the platform, nobody can read a value back, not even the workflow's own token.
|
|
265
|
+
|
|
266
|
+
## Order
|
|
267
|
+
|
|
268
|
+
Items with an input from a dataset in `first` are handled before the others, in the order of the list. Everything else goes oldest first.
|
|
269
|
+
|
|
270
|
+
```python
|
|
271
|
+
@workflow(
|
|
272
|
+
inputs=["pollyxt/scc", "pollyxt/scc-calibration-candidates"],
|
|
273
|
+
outputs="pollyxt/scc-calibration",
|
|
274
|
+
keys=per_datum(),
|
|
275
|
+
first=["scc-calibration-candidates"],
|
|
276
|
+
)
|
|
277
|
+
def scc_sync(item: Item, ctx: Context) -> list[Output]:
|
|
278
|
+
...
|
|
279
|
+
```
|
|
280
|
+
|
|
281
|
+
Here every calibration is uploaded, checked, and deleted from SCC if it fails, before any data file that would use it. A run takes items one at a time, so it finishes everything ready in a `first` dataset before it starts on the rest. It is an order, not a dependency: an item waiting to be retried doesn't hold back the others. The platform works out each item's place from all its inputs, also those planned in earlier runs, and changing `first` reorders items that are still waiting. `pangea-flows try` doesn't use the ledger, so it goes by key.
|
|
282
|
+
|
|
283
|
+
## Options
|
|
284
|
+
|
|
285
|
+
| `@workflow(...)` | |
|
|
286
|
+
| --- | --- |
|
|
287
|
+
| `inputs`, `outputs` | Datasets, see above |
|
|
288
|
+
| `keys` | See above |
|
|
289
|
+
| `schedule`, `timezone` | A cron expression, every 15 minutes by default; `None` to only run when asked |
|
|
290
|
+
| `max_items` | Items per run, 10 by default |
|
|
291
|
+
| `max_attempts` | Tries per item, 3 by default |
|
|
292
|
+
| `lease` | How long a claim lasts without a heartbeat, 1 hour by default. Heartbeats are sent while the function runs. |
|
|
293
|
+
| `start` | On the first run, also take data uploaded since this time |
|
|
294
|
+
| `first` | Input datasets whose items go first, see [Order](#order) |
|
|
295
|
+
| `secrets` | Environment variables with credentials, see [Secrets](#secrets) |
|
|
296
|
+
| `name`, `token_env` | Default to the function's name and `PANGEA_TOKEN_<NAME>` |
|
|
297
|
+
| Anything else | Shown with the workflow on the platform |
|
|
298
|
+
|
|
299
|
+
## Development
|
|
300
|
+
|
|
301
|
+
```bash
|
|
302
|
+
uv sync
|
|
303
|
+
uv run pytest # Unit tests
|
|
304
|
+
uv run pytest -m integration # Against a running API, see tests/README.md
|
|
305
|
+
uv run pytest -m e2e # pangea-flows serve, with podman
|
|
306
|
+
```
|
|
@@ -0,0 +1,293 @@
|
|
|
1
|
+
# pangea-flows
|
|
2
|
+
|
|
3
|
+
Write PANGEA processing workflows in Python: format conversions, plots, uploads to other databases. You write a function that turns input files into output files. The platform keeps track of what is done, what is waiting and what failed, so your code doesn't have to.
|
|
4
|
+
|
|
5
|
+
## The quick way
|
|
6
|
+
|
|
7
|
+
```python
|
|
8
|
+
from pangea_flows import each_file
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
@each_file(inputs="pollyxt/raw", pattern="*.nc", output="pollyxt/quicklooks")
|
|
12
|
+
def quicklook(path, out_dir):
|
|
13
|
+
"""A plot of each raw file"""
|
|
14
|
+
plot(path, out_dir / f"{path.stem}.png") # Your code
|
|
15
|
+
```
|
|
16
|
+
|
|
17
|
+
That is a whole workflow. Every 15 minutes it looks for new files in `pollyxt/raw`, downloads each one, and calls `quicklook` with its path and an empty folder. Whatever files are in `out_dir` when it returns become one datum in `pollyxt/quicklooks`, with the input's time, linked to the input. Write nothing to produce nothing. Raise an exception and the file is tried again later.
|
|
18
|
+
|
|
19
|
+
For one output per hour or day, from all the files in it:
|
|
20
|
+
|
|
21
|
+
```python
|
|
22
|
+
from datetime import timedelta
|
|
23
|
+
from pangea_flows import each_window, hourly
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
@each_window(hourly(settle=timedelta(hours=2)), inputs="pollyxt/raw", pattern="*.nc", output="pollyxt/hourly")
|
|
27
|
+
def hourly_plot(paths, window, out_dir):
|
|
28
|
+
plot(paths, window.start, window.end, out_dir / f"{window.start:%Y%m%d_%H}.png")
|
|
29
|
+
```
|
|
30
|
+
|
|
31
|
+
`paths` are sorted by time. `settle` waits for files that arrive late. `daily()` or any `timedelta` work too.
|
|
32
|
+
|
|
33
|
+
Both take `schedule=` and the other options of `@workflow`, below. To start a project:
|
|
34
|
+
|
|
35
|
+
```bash
|
|
36
|
+
pangea-flows new my-flows # flows.py, pyproject.toml, a README and .env.example
|
|
37
|
+
cd my-flows && uv sync
|
|
38
|
+
pangea-flows try quicklook # Runs it on the last day's data, into ./out/
|
|
39
|
+
```
|
|
40
|
+
|
|
41
|
+
## The full form
|
|
42
|
+
|
|
43
|
+
When a workflow needs more, like several outputs, metadata or its own grouping of inputs, write it with `@workflow`. `each_file` above is this, written out:
|
|
44
|
+
|
|
45
|
+
```python
|
|
46
|
+
from pangea_flows import Context, Item, Output, per_datum, workflow
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
@workflow(inputs="pollyxt/raw", outputs="pollyxt/quicklooks", keys=per_datum())
|
|
50
|
+
def quicklook(item: Item, ctx: Context) -> list[Output]:
|
|
51
|
+
out_dir = ctx.tmpdir / "out"
|
|
52
|
+
out_dir.mkdir()
|
|
53
|
+
for path in ctx.fetch(item.inputs, pattern="*.nc"):
|
|
54
|
+
plot(path, out_dir / f"{path.stem}.png")
|
|
55
|
+
files = list(out_dir.iterdir())
|
|
56
|
+
return [Output("pollyxt/quicklooks", files, timestamp=item.inputs[0].timestamp)] if files else []
|
|
57
|
+
```
|
|
58
|
+
|
|
59
|
+
And with hours, two outputs and a setting of its own:
|
|
60
|
+
|
|
61
|
+
```python
|
|
62
|
+
from datetime import timedelta
|
|
63
|
+
from pangea_flows import Context, Item, Output, hourly, workflow
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
@workflow(
|
|
67
|
+
inputs="pollyxt/raw",
|
|
68
|
+
outputs="pollyxt/quicklooks",
|
|
69
|
+
keys=hourly(settle=timedelta(hours=2), span=timedelta(hours=1)),
|
|
70
|
+
schedule="0 * * * *",
|
|
71
|
+
)
|
|
72
|
+
def quicklooks(item: Item, ctx: Context) -> list[Output]:
|
|
73
|
+
"""Hourly backscatter plots"""
|
|
74
|
+
files = ctx.fetch(item.inputs, pattern="*.nc")
|
|
75
|
+
png = ctx.tmpdir / f"{item.key}.png"
|
|
76
|
+
plot(files, item.window, png) # Your code
|
|
77
|
+
return [Output("pollyxt/quicklooks", png, timestamp=item.window.start)]
|
|
78
|
+
```
|
|
79
|
+
|
|
80
|
+
## How it works
|
|
81
|
+
|
|
82
|
+
Every workflow has a **ledger** on the platform: one **item** per unit of work, such as an hour of data. Each run:
|
|
83
|
+
|
|
84
|
+
1. **Plans**: follows the uploads to the input datasets since the last run, and adds their datums to items. A datum only counts once all its files are uploaded.
|
|
85
|
+
2. **Claims** items that are ready, one at a time. Two runs never get the same item.
|
|
86
|
+
3. **Calls your function** with the item's inputs, in a temporary directory of its own.
|
|
87
|
+
4. **Registers the outputs** your function returns, uploads their files, and records where they came from.
|
|
88
|
+
5. **Marks the item** done, or failed with the error, to be retried later.
|
|
89
|
+
|
|
90
|
+
Each run is recorded on the platform with its log, and each attempt at an item with what the function logged and printed (the last 64 KB of each). A run that fails outside any item, for example because a dataset name is wrong, notifies the instrument's subscribers, once until it works again. Members can also ask for a run from the platform instead of waiting for the schedule.
|
|
91
|
+
|
|
92
|
+
A run that crashes or hangs loses its claim when the lease runs out, and another run takes the item over. When late data arrives for an item that is already done, the item runs again, and its outputs are updated in place rather than duplicated.
|
|
93
|
+
|
|
94
|
+
## Setting up a workflow
|
|
95
|
+
|
|
96
|
+
1. On the platform, a member of the instrument creates the workflow (`POST /workflows`). They choose which datasets it may write to, then issue its token (`POST /workflows/:id/token`).
|
|
97
|
+
2. Give the token to the runner as `PANGEA_TOKEN_<NAME>`, where `<NAME>` is the function's name in capitals (`PANGEA_TOKEN_QUICKLOOKS` above), and set `PANGEA_URL`.
|
|
98
|
+
3. Try it first with `pangea-flows try quicklooks`, which only reads (see Running). Then run it once for real: `pangea-flows run quicklooks`. The first run only records where to start from, so nothing happens until new data is uploaded. Pass `start=` to `@workflow` to also take data from earlier.
|
|
99
|
+
|
|
100
|
+
## Writing the function
|
|
101
|
+
|
|
102
|
+
It gets an `Item` and a `Context`, and returns a list of `Output`s, or nothing.
|
|
103
|
+
|
|
104
|
+
| | |
|
|
105
|
+
| --- | --- |
|
|
106
|
+
| `item.key` | The item, e.g. `2026-11-04T10:00Z` |
|
|
107
|
+
| `item.inputs` | Its datums (`.id`, `.timestamp`, `.metadata`, `.files`) |
|
|
108
|
+
| `item.window` | The time it covers (`.start`, `.end`), for time-based keys |
|
|
109
|
+
| `item.attempt` | 1 the first time, then 2, 3... |
|
|
110
|
+
| `ctx.fetch(datums, pattern="*.nc")` | Downloads files into `ctx.tmpdir` and returns their paths |
|
|
111
|
+
| `ctx.tmpdir` | A directory to work in, removed afterwards |
|
|
112
|
+
| `ctx.log` | A logger |
|
|
113
|
+
| `ctx.cancelled` | True if another run took the item over; you can stop early |
|
|
114
|
+
| `ctx.settings` | The workflow's settings, read-only (see below) |
|
|
115
|
+
| `ctx.datums(dataset, after=, before=, current=, ...)` | Datums of any dataset, with their files and metadata (see below) |
|
|
116
|
+
| `ctx.datum(id)` | One datum |
|
|
117
|
+
|
|
118
|
+
**Settings** are the extra keyword arguments of `@workflow`. They show on the platform, and the function reads them as `ctx.settings`:
|
|
119
|
+
|
|
120
|
+
```python
|
|
121
|
+
@workflow(inputs=..., outputs=..., keys=per_datum(), calibration_timeout_minutes=10, location="Antikythera")
|
|
122
|
+
def scc_sync(item: Item, ctx: Context) -> list[Output]:
|
|
123
|
+
timeout = ctx.settings["calibration_timeout_minutes"]
|
|
124
|
+
```
|
|
125
|
+
|
|
126
|
+
They change with a commit, not on the platform. `each_file` and `each_window` functions don't get a `ctx`, so use `@workflow` when the function needs settings or other datums.
|
|
127
|
+
|
|
128
|
+
**Other datums**: `ctx.datums(dataset, ...)` lists any dataset's datums, oldest first, with their files and metadata, so `ctx.fetch` works on them too. `dataset` is named like inputs. `after` and `before` (measurement time) and `created_after` and `created_before` (registered on the platform) leave out the bound itself. `current=True` leaves out outputs that a workflow no longer makes: when an item runs again and doesn't return an output it returned before, that output is superseded. Also `final=`, `order="desc"` and `limit=`. For example, to compare a calibration with the earlier ones that passed:
|
|
129
|
+
|
|
130
|
+
```python
|
|
131
|
+
history = [
|
|
132
|
+
d.metadata
|
|
133
|
+
for d in ctx.datums("pollyxt/scc-calibration", before=calibration_start, current=True)
|
|
134
|
+
if d.metadata
|
|
135
|
+
]
|
|
136
|
+
```
|
|
137
|
+
|
|
138
|
+
`PangeaClient.datums(...)` does the same outside a workflow.
|
|
139
|
+
|
|
140
|
+
`Output(dataset, files, timestamp, key=None, sources=None, metadata=None, final=False)`:
|
|
141
|
+
|
|
142
|
+
- `dataset` must be one of the workflow's `outputs`.
|
|
143
|
+
- `key` names the output within the item, and defaults to the dataset's slug. Give each output its own key when there are several in one dataset, like `key=f"calibration-{measurement_id}"`. When the item runs again, an output with the same key updates the same datum, so keep keys and filenames stable.
|
|
144
|
+
- `sources` defaults to all of the item's inputs. Name the ones you actually used, if fewer.
|
|
145
|
+
- `metadata` replaces the datum's metadata each time, and `None` clears it, so a rerun never leaves stale values.
|
|
146
|
+
- An item with nothing to produce returns `[]`. It is done, and won't run again.
|
|
147
|
+
|
|
148
|
+
**Errors**: any exception fails the attempt, and the item is retried later: after 10 minutes, then 20, and so on, up to `max_attempts` (3 by default). After that it is FAILED and the instrument's subscribers are notified. Raise `FailPermanently("why")` when retrying can't help, such as for a corrupt input. A member can reset FAILED items on the platform once the problem is fixed.
|
|
149
|
+
|
|
150
|
+
**Things outside PANGEA**: when a workflow also uploads somewhere else, it may run again for the same item, so check whether that system already has the data first.
|
|
151
|
+
|
|
152
|
+
## Keys: what an item is
|
|
153
|
+
|
|
154
|
+
| | One item per... | Keys |
|
|
155
|
+
| --- | --- | --- |
|
|
156
|
+
| `per_datum()` | input datum | `datum-16584` |
|
|
157
|
+
| `hourly()` | hour (UTC) | `2026-11-04T10:00Z` |
|
|
158
|
+
| `daily()` | day (UTC) | `2026-11-04` |
|
|
159
|
+
| `TimeWindows(timedelta(minutes=30))` | any window that divides a day | `2026-11-04T10:30Z` |
|
|
160
|
+
|
|
161
|
+
Time-based schemes take:
|
|
162
|
+
|
|
163
|
+
- `settle`: how long after a window ends to wait before running it, for data that arrives late.
|
|
164
|
+
- `span`: how long each datum covers, so that it goes to every window it overlaps. A `timedelta` from its timestamp, or a function returning `(start, end)`, e.g. from `datum.metadata`. By default a datum belongs only to the window of its timestamp.
|
|
165
|
+
|
|
166
|
+
For other rules, subclass `KeyScheme`: `keys_for(datum)` returns the item keys a datum belongs to, and `name` identifies the scheme. A workflow's scheme can never change, since its existing keys would mean something else; a different scheme needs a new workflow.
|
|
167
|
+
|
|
168
|
+
## Datasets
|
|
169
|
+
|
|
170
|
+
Name datasets as `"instrument/dataset"` by slug, by their slug alone if they belong to the workflow's own instrument, or by ID.
|
|
171
|
+
|
|
172
|
+
Changing a workflow's inputs or outputs is refused, in case the code was changed by mistake, or it is someone else's code with this token. Run once with `--accept-changes` when it is intended.
|
|
173
|
+
|
|
174
|
+
## Running
|
|
175
|
+
|
|
176
|
+
```bash
|
|
177
|
+
pangea-flows new my-flows # Start a project in a new folder
|
|
178
|
+
pangea-flows list # The workflows in flows.py, and their token variables
|
|
179
|
+
pangea-flows try quicklooks # Try it on the last day's uploads
|
|
180
|
+
pangea-flows try quicklooks --since 2026-11-01 --key 2026-11-01T10:00Z
|
|
181
|
+
pangea-flows run quicklooks # Plan and process up to max_items items, once
|
|
182
|
+
```
|
|
183
|
+
|
|
184
|
+
A workflow is named by its name in `flows.py`, or as `module`, `module:name` or `file.py:name`. `PANGEA_URL` and the tokens can also go in a `.env` file in the current folder.
|
|
185
|
+
|
|
186
|
+
`try` (also `dry-run`) doesn't touch the ledger: it plans from uploads since `--since`, a day ago by default, runs your function, and copies the outputs into `./out/<key>/<output key>/`. It only reads, so your personal API token works too (`PANGEA_TOKEN`).
|
|
187
|
+
|
|
188
|
+
### Running workflows from their repositories
|
|
189
|
+
|
|
190
|
+
On the platform, a member gives the workflow a **source**: the git repository, the branch and the workflow in it, like `flows.py:quicklook`. For a private repository, the workflow's page then shows a public key: add it to the repository as a read-only deploy key (on GitHub: the repository's Settings → Deploy keys). From then on, every push to the branch is picked up within a minute or so. A run that fails to build or import shows on the workflow's page with the error.
|
|
191
|
+
|
|
192
|
+
The repository needs a `uv.lock`, so the server installs exactly what you tested with: `pangea-flows new` sets that up. Runs get 4 GB of memory, 2 CPUs and 2 hours by default; ask for more in the decorator, up to what the server allows:
|
|
193
|
+
|
|
194
|
+
```python
|
|
195
|
+
@each_file(inputs="pollyxt/raw", output="pollyxt/quicklooks", memory="8g", cpus=4, timeout=timedelta(hours=6))
|
|
196
|
+
```
|
|
197
|
+
|
|
198
|
+
The server side is `pangea-flows serve` (`pip install "pangea-flows[serve]"`, Python 3.11+, git and podman). An admin creates a runner on the platform (`POST /runners`, then `POST /runners/:id/token`), and writes its settings:
|
|
199
|
+
|
|
200
|
+
```toml
|
|
201
|
+
api_url = "https://pangea.example.org/api"
|
|
202
|
+
token_file = "token" # Or $PANGEA_RUNNER_TOKEN
|
|
203
|
+
secrets_key_file = "secrets.key" # Or $PANGEA_RUNNER_SECRETS_KEY; see below
|
|
204
|
+
state_dir = "/var/lib/pangea-runner" # Clones, deploy keys, scratch space
|
|
205
|
+
|
|
206
|
+
[container]
|
|
207
|
+
api_url = "https://pangea.example.org/api" # What runs use; api_url by default
|
|
208
|
+
network = "" # podman --network for runs; its default if empty
|
|
209
|
+
# With podman's default network, a run can't reach this host by its own address.
|
|
210
|
+
# If the API is on the same host, point its name at the host instead:
|
|
211
|
+
add_hosts = ["pangea.example.org:host-gateway"]
|
|
212
|
+
|
|
213
|
+
[limits] # For runs that don't ask
|
|
214
|
+
memory = "4g"
|
|
215
|
+
cpus = 2
|
|
216
|
+
timeout_minutes = 120
|
|
217
|
+
|
|
218
|
+
[max] # The most a run may ask for
|
|
219
|
+
memory = "16g"
|
|
220
|
+
cpus = 4
|
|
221
|
+
timeout_minutes = 1440
|
|
222
|
+
```
|
|
223
|
+
|
|
224
|
+
`secrets_key_file` holds the private key, base64, that [secrets](#secrets) are sealed to; the API has its public key as `WORKFLOW_SECRETS_PUBLIC_KEY` (see `packages/pangea-api/docs/runners.md` for making the pair). The runner opens each run's secrets and passes them to its container by name, with the values only in podman's environment, and masks them in the output it keeps. Without the key, runs of workflows that have secrets fail with a message saying so.
|
|
225
|
+
|
|
226
|
+
Then `pangea-flows serve --config runner.toml`. It builds an image for each new commit (from `ghcr.io/astral-sh/uv`, with `uv sync --frozen`), and runs each workflow in a container with a read-only filesystem, a scratch `/tmp` on the host, its memory and CPU limits, and a token for that run only. See `packages/pangea-api/docs/runners.md` for what it does on the platform.
|
|
227
|
+
|
|
228
|
+
### From Python
|
|
229
|
+
|
|
230
|
+
```python
|
|
231
|
+
from pangea_flows import run_once
|
|
232
|
+
|
|
233
|
+
report = run_once(quicklooks) # Or run_once(quicklooks, client=PangeaClient(url, token))
|
|
234
|
+
print(report.done, report.failed, report.run_id)
|
|
235
|
+
```
|
|
236
|
+
|
|
237
|
+
## Secrets
|
|
238
|
+
|
|
239
|
+
Credentials for systems outside PANGEA, such as an SCC login, are **secrets**. Members set them on the workflow's page on the platform, and runs get each as an environment variable. Declare the ones the code needs:
|
|
240
|
+
|
|
241
|
+
```python
|
|
242
|
+
@workflow(..., secrets=["SCC_USERNAME", "SCC_PASSWORD"])
|
|
243
|
+
def scc_sync(item: Item, ctx: Context) -> list[Output]:
|
|
244
|
+
login(os.environ["SCC_USERNAME"], os.environ["SCC_PASSWORD"])
|
|
245
|
+
```
|
|
246
|
+
|
|
247
|
+
- If one isn't set, the run fails at the start with `Missing secrets: SCC_PASSWORD`, instead of every item failing. The platform shows which are missing.
|
|
248
|
+
- Names are like `SCC_PASSWORD`. `PANGEA_*`, `HOME`, `TMPDIR`, `PATH` and `PYTHONUNBUFFERED` are the runner's.
|
|
249
|
+
- Values are masked as `***` in the logs and errors sent to the platform. Values shorter than 6 characters aren't masked.
|
|
250
|
+
- `pangea-flows run` and `try` read them from the environment or `.env`, like the token. Keep `.env` out of git.
|
|
251
|
+
- On the platform, nobody can read a value back, not even the workflow's own token.
|
|
252
|
+
|
|
253
|
+
## Order
|
|
254
|
+
|
|
255
|
+
Items with an input from a dataset in `first` are handled before the others, in the order of the list. Everything else goes oldest first.
|
|
256
|
+
|
|
257
|
+
```python
|
|
258
|
+
@workflow(
|
|
259
|
+
inputs=["pollyxt/scc", "pollyxt/scc-calibration-candidates"],
|
|
260
|
+
outputs="pollyxt/scc-calibration",
|
|
261
|
+
keys=per_datum(),
|
|
262
|
+
first=["scc-calibration-candidates"],
|
|
263
|
+
)
|
|
264
|
+
def scc_sync(item: Item, ctx: Context) -> list[Output]:
|
|
265
|
+
...
|
|
266
|
+
```
|
|
267
|
+
|
|
268
|
+
Here every calibration is uploaded, checked, and deleted from SCC if it fails, before any data file that would use it. A run takes items one at a time, so it finishes everything ready in a `first` dataset before it starts on the rest. It is an order, not a dependency: an item waiting to be retried doesn't hold back the others. The platform works out each item's place from all its inputs, also those planned in earlier runs, and changing `first` reorders items that are still waiting. `pangea-flows try` doesn't use the ledger, so it goes by key.
|
|
269
|
+
|
|
270
|
+
## Options
|
|
271
|
+
|
|
272
|
+
| `@workflow(...)` | |
|
|
273
|
+
| --- | --- |
|
|
274
|
+
| `inputs`, `outputs` | Datasets, see above |
|
|
275
|
+
| `keys` | See above |
|
|
276
|
+
| `schedule`, `timezone` | A cron expression, every 15 minutes by default; `None` to only run when asked |
|
|
277
|
+
| `max_items` | Items per run, 10 by default |
|
|
278
|
+
| `max_attempts` | Tries per item, 3 by default |
|
|
279
|
+
| `lease` | How long a claim lasts without a heartbeat, 1 hour by default. Heartbeats are sent while the function runs. |
|
|
280
|
+
| `start` | On the first run, also take data uploaded since this time |
|
|
281
|
+
| `first` | Input datasets whose items go first, see [Order](#order) |
|
|
282
|
+
| `secrets` | Environment variables with credentials, see [Secrets](#secrets) |
|
|
283
|
+
| `name`, `token_env` | Default to the function's name and `PANGEA_TOKEN_<NAME>` |
|
|
284
|
+
| Anything else | Shown with the workflow on the platform |
|
|
285
|
+
|
|
286
|
+
## Development
|
|
287
|
+
|
|
288
|
+
```bash
|
|
289
|
+
uv sync
|
|
290
|
+
uv run pytest # Unit tests
|
|
291
|
+
uv run pytest -m integration # Against a running API, see tests/README.md
|
|
292
|
+
uv run pytest -m e2e # pangea-flows serve, with podman
|
|
293
|
+
```
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
[project]
|
|
2
|
+
name = "pangea-flows"
|
|
3
|
+
version = "0.3.0"
|
|
4
|
+
description = "Write PANGEA processing workflows in Python"
|
|
5
|
+
readme = "README.md"
|
|
6
|
+
requires-python = ">=3.10"
|
|
7
|
+
authors = [{ name = "Thanasis Georgiou", email = "ageorgiou@noa.gr" }]
|
|
8
|
+
dependencies = [
|
|
9
|
+
"httpx>=0.27",
|
|
10
|
+
"pydantic>=2.6",
|
|
11
|
+
]
|
|
12
|
+
|
|
13
|
+
[project.optional-dependencies]
|
|
14
|
+
serve = ["croniter>=2", "pynacl>=1.5"]
|
|
15
|
+
|
|
16
|
+
[project.scripts]
|
|
17
|
+
pangea-flows = "pangea_flows.cli:main"
|
|
18
|
+
|
|
19
|
+
[dependency-groups]
|
|
20
|
+
dev = ["pytest>=8"]
|
|
21
|
+
|
|
22
|
+
[build-system]
|
|
23
|
+
requires = ["hatchling"]
|
|
24
|
+
build-backend = "hatchling.build"
|
|
25
|
+
|
|
26
|
+
[tool.pytest.ini_options]
|
|
27
|
+
testpaths = ["tests"]
|
|
28
|
+
markers = [
|
|
29
|
+
"integration: needs a running PANGEA API, see tests/README.md",
|
|
30
|
+
"e2e: runs `pangea-flows serve` with podman against a running API, see tests/README.md",
|
|
31
|
+
]
|