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.
@@ -0,0 +1,5 @@
1
+ __pycache__/
2
+ .venv/
3
+ .pytest_cache/
4
+ dist/
5
+ out/
@@ -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
+ ]