ab-plumber 0.1.0__py3-none-any.whl
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.
- ab_plumber-0.1.0.dist-info/METADATA +133 -0
- ab_plumber-0.1.0.dist-info/RECORD +18 -0
- ab_plumber-0.1.0.dist-info/WHEEL +4 -0
- ab_plumber-0.1.0.dist-info/entry_points.txt +2 -0
- ab_plumber-0.1.0.dist-info/licenses/LICENSE +21 -0
- plumber/__init__.py +3 -0
- plumber/check.py +250 -0
- plumber/checkpoint.py +27 -0
- plumber/cli.py +71 -0
- plumber/entries.py +25 -0
- plumber/examples/__init__.py +0 -0
- plumber/examples/snap_shim.py +28 -0
- plumber/local.py +18 -0
- plumber/localmp.py +73 -0
- plumber/probe.py +57 -0
- plumber/protocols.py +48 -0
- plumber/resolver.py +21 -0
- plumber/run.py +133 -0
|
@@ -0,0 +1,133 @@
|
|
|
1
|
+
Metadata-Version: 2.5
|
|
2
|
+
Name: ab-plumber
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: A geospatial pipeline framework: phase functions run by a swappable execution strategy.
|
|
5
|
+
Project-URL: Homepage, https://github.com/austinbreunig/plumber
|
|
6
|
+
Project-URL: Repository, https://github.com/austinbreunig/plumber
|
|
7
|
+
Project-URL: Issues, https://github.com/austinbreunig/plumber/issues
|
|
8
|
+
Author: Austin Breunig
|
|
9
|
+
License: MIT License
|
|
10
|
+
|
|
11
|
+
Copyright (c) 2026 plumber contributors
|
|
12
|
+
|
|
13
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
14
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
15
|
+
in the Software without restriction, including without limitation the rights
|
|
16
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
17
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
18
|
+
furnished to do so, subject to the following conditions:
|
|
19
|
+
|
|
20
|
+
The above copyright notice and this permission notice shall be included in all
|
|
21
|
+
copies or substantial portions of the Software.
|
|
22
|
+
|
|
23
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
24
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
25
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
26
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
27
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
28
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
29
|
+
SOFTWARE.
|
|
30
|
+
License-File: LICENSE
|
|
31
|
+
Keywords: geopandas,geospatial,gis,pipeline
|
|
32
|
+
Classifier: Development Status :: 3 - Alpha
|
|
33
|
+
Classifier: Intended Audience :: Science/Research
|
|
34
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
35
|
+
Classifier: Programming Language :: Python :: 3
|
|
36
|
+
Classifier: Programming Language :: Python :: 3.9
|
|
37
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
38
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
39
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
40
|
+
Classifier: Topic :: Scientific/Engineering :: GIS
|
|
41
|
+
Requires-Python: >=3.9
|
|
42
|
+
Requires-Dist: geopandas>=0.13
|
|
43
|
+
Requires-Dist: pyarrow>=10
|
|
44
|
+
Requires-Dist: pyyaml>=6.0
|
|
45
|
+
Provides-Extra: dev
|
|
46
|
+
Requires-Dist: pytest>=7.0; extra == 'dev'
|
|
47
|
+
Requires-Dist: ruff>=0.4; extra == 'dev'
|
|
48
|
+
Description-Content-Type: text/markdown
|
|
49
|
+
|
|
50
|
+
# plumber
|
|
51
|
+
|
|
52
|
+
A geospatial data-engineering pipeline framework. A stable function-shaped phase
|
|
53
|
+
interface (`run(gdf, **params) -> gdf`) separates three concerns that are usually
|
|
54
|
+
tangled: *what an algorithm does*, *how phases are sequenced and scaled*, and
|
|
55
|
+
*how a pipeline is specified*. That separation is meant to make both
|
|
56
|
+
prototyping→production scaling and safe agentic pipeline generation tractable —
|
|
57
|
+
new logic drops into a fixed slot instead of modifying a monolith.
|
|
58
|
+
|
|
59
|
+
## Status
|
|
60
|
+
|
|
61
|
+
Phase 3 (Tracer). `run`, `check`, `localmp`, checkpoints and partial runs work. See
|
|
62
|
+
[issue #1](https://github.com/austinbreunig/plumber/issues/1) for the wayfinder map and
|
|
63
|
+
`plan.md` for the design.
|
|
64
|
+
|
|
65
|
+
## Quickstart
|
|
66
|
+
|
|
67
|
+
```bash
|
|
68
|
+
pipx install ab-plumber
|
|
69
|
+
```
|
|
70
|
+
|
|
71
|
+
In any repo, write your input function, one or more phases, and an output function.
|
|
72
|
+
A phase is `run(gdf, **params) -> gdf`. Then add a `plumber.yaml`:
|
|
73
|
+
|
|
74
|
+
```yaml
|
|
75
|
+
input:
|
|
76
|
+
path: myio:load_roads
|
|
77
|
+
params: {path: data/roads.parquet}
|
|
78
|
+
phases:
|
|
79
|
+
- path: phases.buffer:run
|
|
80
|
+
partitionable: true
|
|
81
|
+
params: {distance: 10}
|
|
82
|
+
output:
|
|
83
|
+
path: myio:write_parquet
|
|
84
|
+
params: {path: out/roads.parquet}
|
|
85
|
+
```
|
|
86
|
+
|
|
87
|
+
Check it, then run it:
|
|
88
|
+
|
|
89
|
+
```bash
|
|
90
|
+
plumber check # PASS/WARN/FAIL per phase, also writes report.json
|
|
91
|
+
plumber run # one process (local)
|
|
92
|
+
plumber run --strategy localmp --worker-count 4 # worker processes, same output
|
|
93
|
+
```
|
|
94
|
+
|
|
95
|
+
Optional: `checkpoint: true` on a slow phase saves its output, and
|
|
96
|
+
`plumber run --from <phase> --to <phase>` runs a slice.
|
|
97
|
+
|
|
98
|
+
## Execution strategy
|
|
99
|
+
|
|
100
|
+
Default is `local` (one process). Switch to `localmp` (worker processes) with no phase
|
|
101
|
+
changes:
|
|
102
|
+
|
|
103
|
+
```yaml
|
|
104
|
+
execution:
|
|
105
|
+
strategy: localmp
|
|
106
|
+
partition: # pick exactly one
|
|
107
|
+
by: [zone_id] # one piece per unique value combination
|
|
108
|
+
# chunk_size: 10000 # fixed-size row slices, in order
|
|
109
|
+
# worker_count: 8 # N roughly equal slices
|
|
110
|
+
```
|
|
111
|
+
|
|
112
|
+
CLI flags override the config: `--strategy localmp`, `--by zone_id`, `--chunk-size 10000`,
|
|
113
|
+
`--worker-count 8`. Any partition flag replaces the config's whole `partition:` block.
|
|
114
|
+
A phase with `partitionable: false` gets the joined data once. The output keeps the input's
|
|
115
|
+
row order, and the output function runs once on the joined data.
|
|
116
|
+
|
|
117
|
+
## Setup
|
|
118
|
+
|
|
119
|
+
Install with `pipx install ab-plumber`, then follow the [Quickstart](#quickstart).
|
|
120
|
+
|
|
121
|
+
## Wrapping your own function
|
|
122
|
+
|
|
123
|
+
See [`docs/adapter-shim.md`](docs/adapter-shim.md).
|
|
124
|
+
|
|
125
|
+
## Development workflow
|
|
126
|
+
|
|
127
|
+
Follows the `ab_spatial` playbook: Discovery → POC → Tracer → MVP → Refinement.
|
|
128
|
+
See `../../playbook/phases.md`.
|
|
129
|
+
|
|
130
|
+
## Issue tracker
|
|
131
|
+
|
|
132
|
+
GitHub Issues in [`austinbreunig/plumber`](https://github.com/austinbreunig/plumber/issues),
|
|
133
|
+
via the `gh` CLI. See `docs/agents/issue-tracker.md`.
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
plumber/__init__.py,sha256=njBxINA1zb50rLgbcBil5rN-cAg01sKNFlsBwkKEakc,96
|
|
2
|
+
plumber/check.py,sha256=3sYvKEWD3xP7k4F3N4VdWNUe7yIbandZjbIFxzgQSnU,9757
|
|
3
|
+
plumber/checkpoint.py,sha256=b5L_drzx5vhm88pZ2eCvszoQywHK2i68-YWgmAejbYQ,778
|
|
4
|
+
plumber/cli.py,sha256=g0OpXv7VyuhTAKdRexyRotGNT8xRwnfzfwnxEvoz8VE,2866
|
|
5
|
+
plumber/entries.py,sha256=MJV0wHPCzl140qYbWyBxetcJYNX54eJNBpifux3qSe8,537
|
|
6
|
+
plumber/local.py,sha256=AraWiXem79HdpsGg7eU2YSp59_xInerGFlyjKJP0yQ4,678
|
|
7
|
+
plumber/localmp.py,sha256=7PnQ-eQwXvS8_k70TnawBLk8C6o7EhOfOiAeAu8xiH8,3171
|
|
8
|
+
plumber/probe.py,sha256=DuozBTGSUTQE7nl9AeAKGEPJzkrl_05SdofP1cak73Y,2218
|
|
9
|
+
plumber/protocols.py,sha256=KtiHYeaRNMKXHzSEOCnKI-91FTViGltypKV4wPHIuWw,1198
|
|
10
|
+
plumber/resolver.py,sha256=cuS2_g6DB_3FlriSKL3EHDcFnvMNYDSLPvziXX7B_1U,726
|
|
11
|
+
plumber/run.py,sha256=gJhL3INafTrho2WZMmhZWSR6FBX3u6omD1TBDpk92SA,4970
|
|
12
|
+
plumber/examples/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
13
|
+
plumber/examples/snap_shim.py,sha256=to9tadVKD_XTpi6qtyseR50mFXTqZwRyqNN0hI9XWSQ,988
|
|
14
|
+
ab_plumber-0.1.0.dist-info/METADATA,sha256=d45nm2-1aIXRGIjfaMBmVcC7u1eXeLzL5agtNRos450,5094
|
|
15
|
+
ab_plumber-0.1.0.dist-info/WHEEL,sha256=W3fkpkm7-wf9vBI5Z-7s0eWkeM-spu78I8Neb98DeEg,87
|
|
16
|
+
ab_plumber-0.1.0.dist-info/entry_points.txt,sha256=TNvhstZNqb-vgbBfrNjiQ8yXEUWq9HpJ0xglyZAAS4k,45
|
|
17
|
+
ab_plumber-0.1.0.dist-info/licenses/LICENSE,sha256=HmOGqubXHZ0hjm0jSSvImvmAf6jI3ysSSHdbxkoGRFQ,1077
|
|
18
|
+
ab_plumber-0.1.0.dist-info/RECORD,,
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 plumber contributors
|
|
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.
|
plumber/__init__.py
ADDED
plumber/check.py
ADDED
|
@@ -0,0 +1,250 @@
|
|
|
1
|
+
"""`plumber check`: cheap checks that run no phase code, plus the preflight inside `run`.
|
|
2
|
+
|
|
3
|
+
Layers, cheapest first:
|
|
4
|
+
structural config only, nothing imported
|
|
5
|
+
static import + inspect.signature, no phase code runs
|
|
6
|
+
runtime probe.py runs the phases on `check.sample` (only from `plumber check`, not `run`)
|
|
7
|
+
|
|
8
|
+
Every check is a plain function `fn(config, overrides) -> list[Finding]`. To add a check,
|
|
9
|
+
write one and append it to STRUCTURAL_CHECKS or STATIC_CHECKS.
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
import inspect
|
|
13
|
+
import json
|
|
14
|
+
|
|
15
|
+
from plumber.entries import FAIL, PASS, SKIP, WARN, Finding, name_of, params_of, phase_label
|
|
16
|
+
from plumber.probe import probe
|
|
17
|
+
from plumber.resolver import resolve
|
|
18
|
+
|
|
19
|
+
# Which `execution.partition:` keys each strategy understands. A key a strategy does not list
|
|
20
|
+
# is a FAIL (so `worker_count` only works with `localmp`).
|
|
21
|
+
STRATEGY_PARTITION_KEYS = {
|
|
22
|
+
"local": set(),
|
|
23
|
+
"localmp": {"by", "chunk_size", "worker_count"},
|
|
24
|
+
}
|
|
25
|
+
DEFAULT_STRATEGY = "local"
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def phase_labels(config):
|
|
29
|
+
return [phase_label(number) for number in range(1, len(config.get("phases") or []) + 1)]
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
# ---- structural layer -------------------------------------------------------------------
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def check_phase_fields(config, overrides):
|
|
36
|
+
findings = []
|
|
37
|
+
for label, entry in zip(phase_labels(config), config.get("phases") or []):
|
|
38
|
+
if "path" not in entry:
|
|
39
|
+
findings.append(Finding(label, FAIL, "missing `path`"))
|
|
40
|
+
if "partitionable" not in entry:
|
|
41
|
+
findings.append(Finding(label, FAIL, "missing `partitionable` (true or false)"))
|
|
42
|
+
elif not isinstance(entry["partitionable"], bool):
|
|
43
|
+
findings.append(Finding(label, FAIL, "`partitionable` must be true or false"))
|
|
44
|
+
if not isinstance(entry.get("checkpoint", False), bool):
|
|
45
|
+
findings.append(Finding(label, FAIL, "`checkpoint` must be true or false"))
|
|
46
|
+
return findings
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def check_checkpoints_block(config, overrides):
|
|
50
|
+
block = config.get("checkpoints")
|
|
51
|
+
if block is None:
|
|
52
|
+
return []
|
|
53
|
+
if not isinstance(block, dict):
|
|
54
|
+
return [Finding("checkpoints", FAIL, "`checkpoints` must be a mapping with a `dir`")]
|
|
55
|
+
if "dir" in block and not isinstance(block["dir"], str):
|
|
56
|
+
return [Finding("checkpoints", FAIL, "`checkpoints.dir` must be a path string")]
|
|
57
|
+
return []
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def partition_value_problem(key, value):
|
|
61
|
+
"""Why one partition value is wrong, or None if it is fine."""
|
|
62
|
+
if key == "by":
|
|
63
|
+
if not (isinstance(value, list) and value and all(isinstance(v, str) for v in value)):
|
|
64
|
+
return "`by` must be a list of column names"
|
|
65
|
+
elif not (isinstance(value, int) and not isinstance(value, bool) and value > 0):
|
|
66
|
+
return f"`{key}` must be a whole number above 0"
|
|
67
|
+
return None
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def check_partition_suits_strategy(config, overrides):
|
|
71
|
+
"""The partition spec must suit the strategy: known keys only, and `localmp` needs
|
|
72
|
+
exactly one of them, with a sensible value."""
|
|
73
|
+
execution = config.get("execution") or {}
|
|
74
|
+
if not isinstance(execution, dict):
|
|
75
|
+
return [Finding("execution", FAIL, "`execution` must be a mapping")]
|
|
76
|
+
strategy = execution.get("strategy", DEFAULT_STRATEGY)
|
|
77
|
+
if strategy not in STRATEGY_PARTITION_KEYS:
|
|
78
|
+
known = ", ".join(sorted(STRATEGY_PARTITION_KEYS))
|
|
79
|
+
return [Finding("strategy", FAIL, f"unknown strategy {strategy!r} (known: {known})")]
|
|
80
|
+
partition = execution.get("partition") or {}
|
|
81
|
+
if not isinstance(partition, dict):
|
|
82
|
+
return [Finding("partition", FAIL, "`partition` must be a mapping")]
|
|
83
|
+
allowed = STRATEGY_PARTITION_KEYS[strategy]
|
|
84
|
+
findings = [
|
|
85
|
+
Finding("partition", FAIL, f"key {key!r} is not used by strategy {strategy!r}")
|
|
86
|
+
for key in partition
|
|
87
|
+
if key not in allowed
|
|
88
|
+
]
|
|
89
|
+
if allowed and len(partition) != 1:
|
|
90
|
+
keys = ", ".join(sorted(allowed))
|
|
91
|
+
findings.append(Finding("partition", FAIL, f"strategy {strategy!r} needs one of: {keys}"))
|
|
92
|
+
for key, value in partition.items():
|
|
93
|
+
problem = key in allowed and partition_value_problem(key, value)
|
|
94
|
+
if problem:
|
|
95
|
+
findings.append(Finding("partition", FAIL, problem))
|
|
96
|
+
return findings
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
def check_input_present(config, overrides):
|
|
100
|
+
if not config.get("input"):
|
|
101
|
+
return [Finding("input", FAIL, "missing `input:` block")]
|
|
102
|
+
return []
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
STRUCTURAL_CHECKS = [
|
|
106
|
+
check_input_present,
|
|
107
|
+
check_phase_fields,
|
|
108
|
+
check_checkpoints_block,
|
|
109
|
+
check_partition_suits_strategy,
|
|
110
|
+
]
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
# ---- static layer -----------------------------------------------------------------------
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
def entries_to_check(config):
|
|
117
|
+
"""(label, entry, takes_data) for input, every phase, and output (if there is one)."""
|
|
118
|
+
entries = []
|
|
119
|
+
if config.get("input"):
|
|
120
|
+
entries.append(("input", config["input"], False))
|
|
121
|
+
for label, entry in zip(phase_labels(config), config.get("phases") or []):
|
|
122
|
+
entries.append((label, entry, True))
|
|
123
|
+
if config.get("output"):
|
|
124
|
+
entries.append(("output", config["output"], True))
|
|
125
|
+
return entries
|
|
126
|
+
|
|
127
|
+
|
|
128
|
+
def check_signature(label, fn, params, takes_data):
|
|
129
|
+
"""Compare the configured params with the function's signature."""
|
|
130
|
+
parameters = list(inspect.signature(fn).parameters.values())
|
|
131
|
+
if takes_data:
|
|
132
|
+
parameters = parameters[1:] # the first argument is the data
|
|
133
|
+
accepts_any = any(p.kind is inspect.Parameter.VAR_KEYWORD for p in parameters)
|
|
134
|
+
named = [p for p in parameters if p.kind in (p.POSITIONAL_OR_KEYWORD, p.KEYWORD_ONLY)]
|
|
135
|
+
names = [p.name for p in named]
|
|
136
|
+
shown = ", ".join(str(p) for p in parameters)
|
|
137
|
+
|
|
138
|
+
findings = []
|
|
139
|
+
for key in params:
|
|
140
|
+
if key not in names and not accepts_any:
|
|
141
|
+
findings.append(Finding(label, FAIL, f"unexpected param {key!r} (signature: {shown})"))
|
|
142
|
+
for p in named:
|
|
143
|
+
if p.default is p.empty and p.name not in params:
|
|
144
|
+
findings.append(Finding(label, FAIL, f"missing required param {p.name!r}"))
|
|
145
|
+
return findings
|
|
146
|
+
|
|
147
|
+
|
|
148
|
+
def check_paths_and_signatures(config, overrides):
|
|
149
|
+
findings = []
|
|
150
|
+
for label, entry, takes_data in entries_to_check(config):
|
|
151
|
+
if "path" not in entry:
|
|
152
|
+
continue # the structural layer already reported it
|
|
153
|
+
try:
|
|
154
|
+
fn = resolve(entry["path"])
|
|
155
|
+
except Exception as error: # importing runs user code, which can raise anything
|
|
156
|
+
findings.append(Finding(label, FAIL, f"cannot resolve {entry['path']!r}: {error}"))
|
|
157
|
+
continue
|
|
158
|
+
if callable(fn):
|
|
159
|
+
findings += check_signature(label, fn, params_of(entry), takes_data)
|
|
160
|
+
else:
|
|
161
|
+
findings.append(Finding(label, FAIL, f"{entry['path']!r} is not callable"))
|
|
162
|
+
return findings
|
|
163
|
+
|
|
164
|
+
|
|
165
|
+
def check_unknown_overrides(config, overrides):
|
|
166
|
+
"""A --params key that no phase lists is probably a typo."""
|
|
167
|
+
listed = {key for entry in config.get("phases") or [] for key in params_of(entry)}
|
|
168
|
+
return [
|
|
169
|
+
Finding("--params", WARN, f"{key!r}: no phase lists this param, so it is ignored")
|
|
170
|
+
for key in overrides
|
|
171
|
+
if key not in listed
|
|
172
|
+
]
|
|
173
|
+
|
|
174
|
+
|
|
175
|
+
STATIC_CHECKS = [check_paths_and_signatures, check_unknown_overrides]
|
|
176
|
+
|
|
177
|
+
|
|
178
|
+
# ---- report -----------------------------------------------------------------------------
|
|
179
|
+
|
|
180
|
+
|
|
181
|
+
def row_for(label, findings, config):
|
|
182
|
+
entries = {lab: entry for lab, entry, _ in entries_to_check(config)}
|
|
183
|
+
mine = [f for f in findings if f.label == label]
|
|
184
|
+
entry = entries.get(label)
|
|
185
|
+
statuses = {f.status for f in mine}
|
|
186
|
+
status = next((s for s in (FAIL, WARN, SKIP) if s in statuses), PASS)
|
|
187
|
+
return {
|
|
188
|
+
"label": label,
|
|
189
|
+
"name": name_of(entry) if entry and "path" in entry else None,
|
|
190
|
+
"status": status,
|
|
191
|
+
"messages": [f.message for f in mine],
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
|
|
195
|
+
def run_probe(config, overrides, findings):
|
|
196
|
+
"""The runtime layer. Returns (findings, note for the report's `runtime_probe` line)."""
|
|
197
|
+
sample = (config.get("check") or {}).get("sample")
|
|
198
|
+
if not sample:
|
|
199
|
+
return [], "not run (no check.sample)"
|
|
200
|
+
if any(f.status == FAIL for f in findings):
|
|
201
|
+
return [], "not run (fix the FAIL rows first)"
|
|
202
|
+
return probe(sample, config["phases"], overrides), f"ran on {sample}"
|
|
203
|
+
|
|
204
|
+
|
|
205
|
+
def check(config, overrides=None, runtime=True):
|
|
206
|
+
"""Run the structural, static and (if `runtime` and `check.sample` is set) runtime layers.
|
|
207
|
+
|
|
208
|
+
Returns the report as a plain dict. `run`'s preflight passes runtime=False.
|
|
209
|
+
"""
|
|
210
|
+
overrides = overrides or {}
|
|
211
|
+
findings = []
|
|
212
|
+
for check_fn in STRUCTURAL_CHECKS + STATIC_CHECKS:
|
|
213
|
+
findings += check_fn(config, overrides)
|
|
214
|
+
probe_note = "not run (no check.sample)"
|
|
215
|
+
if runtime:
|
|
216
|
+
probe_findings, probe_note = run_probe(config, overrides, findings)
|
|
217
|
+
findings += probe_findings
|
|
218
|
+
|
|
219
|
+
labels = [label for label, _, _ in entries_to_check(config)]
|
|
220
|
+
labels += [f.label for f in findings if f.label not in labels] # config-level rows
|
|
221
|
+
rows = [row_for(label, findings, config) for label in labels]
|
|
222
|
+
return {
|
|
223
|
+
"ok": all(row["status"] != FAIL for row in rows),
|
|
224
|
+
"rows": rows,
|
|
225
|
+
"runtime_probe": probe_note,
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
|
|
229
|
+
def format_report(report):
|
|
230
|
+
lines = []
|
|
231
|
+
for row in report["rows"]:
|
|
232
|
+
name = row["name"] or ""
|
|
233
|
+
line = f"{row['label']:<10} {name:<28} {row['status']}"
|
|
234
|
+
if row["messages"]:
|
|
235
|
+
line += " " + "; ".join(row["messages"])
|
|
236
|
+
lines.append(line.rstrip())
|
|
237
|
+
lines.append(f"runtime probe: {report['runtime_probe']}")
|
|
238
|
+
return "\n".join(lines)
|
|
239
|
+
|
|
240
|
+
|
|
241
|
+
def write_report(report, path="report.json"):
|
|
242
|
+
with open(path, "w") as file:
|
|
243
|
+
json.dump(report, file, indent=2)
|
|
244
|
+
|
|
245
|
+
|
|
246
|
+
def preflight(config):
|
|
247
|
+
"""Run before `run`. Any FAIL stops the run; a WARN never does."""
|
|
248
|
+
report = check(config, runtime=False)
|
|
249
|
+
if not report["ok"]:
|
|
250
|
+
raise SystemExit("plumber check failed, nothing was run:\n" + format_report(report))
|
plumber/checkpoint.py
ADDED
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
"""Checkpoints: a phase's output saved as GeoParquet. Plumber writes these itself."""
|
|
2
|
+
|
|
3
|
+
from pathlib import Path
|
|
4
|
+
|
|
5
|
+
import geopandas as gpd
|
|
6
|
+
|
|
7
|
+
DEFAULT_DIR = "./.plumber/checkpoints"
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
def checkpoint_dir(run_params):
|
|
11
|
+
"""The folder checkpoints go in, from the strategy's `run_params`."""
|
|
12
|
+
return run_params.get("checkpoint_dir", DEFAULT_DIR)
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def checkpoint_path(directory, name):
|
|
16
|
+
return Path(directory) / f"{name}.parquet"
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def write_checkpoint(data, directory, name):
|
|
20
|
+
"""Save `data` to `<directory>/<name>.parquet`, replacing any old file."""
|
|
21
|
+
path = checkpoint_path(directory, name)
|
|
22
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
23
|
+
data.to_parquet(path)
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def read_checkpoint(directory, name):
|
|
27
|
+
return gpd.read_parquet(checkpoint_path(directory, name))
|
plumber/cli.py
ADDED
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
"""The `plumber` command line."""
|
|
2
|
+
|
|
3
|
+
import argparse
|
|
4
|
+
|
|
5
|
+
import yaml
|
|
6
|
+
|
|
7
|
+
from plumber.check import check, format_report, write_report
|
|
8
|
+
from plumber.run import load_config, run
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
def parse_params(pairs):
|
|
12
|
+
"""Turn ["a=1", "b=x"] into {"a": 1, "b": "x"}. Values are read as YAML (1 is an int)."""
|
|
13
|
+
params = {}
|
|
14
|
+
for pair in pairs:
|
|
15
|
+
key, sep, value = pair.partition("=")
|
|
16
|
+
if not (key and sep):
|
|
17
|
+
raise SystemExit(f"Bad --params {pair!r}: expected key=value")
|
|
18
|
+
params[key] = yaml.safe_load(value)
|
|
19
|
+
return params
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def apply_execution_flags(config, args):
|
|
23
|
+
"""Copy --strategy and the partition flags into `config["execution"]`.
|
|
24
|
+
|
|
25
|
+
Any partition flag replaces the config's whole `partition:` block, so a flag never
|
|
26
|
+
ends up beside a different key from the file.
|
|
27
|
+
"""
|
|
28
|
+
execution = dict(config.get("execution") or {})
|
|
29
|
+
if args.strategy:
|
|
30
|
+
execution["strategy"] = args.strategy
|
|
31
|
+
flags = {"by": args.by, "chunk_size": args.chunk_size, "worker_count": args.worker_count}
|
|
32
|
+
given = {key: value for key, value in flags.items() if value is not None}
|
|
33
|
+
if given:
|
|
34
|
+
execution["partition"] = given
|
|
35
|
+
if execution:
|
|
36
|
+
config["execution"] = execution
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def main(argv=None):
|
|
40
|
+
parser = argparse.ArgumentParser(prog="plumber")
|
|
41
|
+
commands = parser.add_subparsers(dest="command", required=True)
|
|
42
|
+
run_parser = commands.add_parser("run", help="run the pipeline in a config file")
|
|
43
|
+
check_parser = commands.add_parser("check", help="check the config without running phases")
|
|
44
|
+
for sub in (run_parser, check_parser):
|
|
45
|
+
sub.add_argument("--config", default="plumber.yaml", help="default: ./plumber.yaml")
|
|
46
|
+
sub.add_argument(
|
|
47
|
+
"--params",
|
|
48
|
+
nargs="+",
|
|
49
|
+
default=[],
|
|
50
|
+
metavar="key=value",
|
|
51
|
+
help="override phase params (applies to every phase that has that param)",
|
|
52
|
+
)
|
|
53
|
+
sub.add_argument("--strategy", help="execution strategy: local or localmp")
|
|
54
|
+
sub.add_argument("--by", nargs="+", metavar="column", help="localmp: one piece per value")
|
|
55
|
+
sub.add_argument("--chunk-size", type=int, help="localmp: rows per piece")
|
|
56
|
+
sub.add_argument("--worker-count", type=int, help="localmp: split into N pieces")
|
|
57
|
+
run_parser.add_argument("--from", dest="start", metavar="phase", help="start at this phase")
|
|
58
|
+
run_parser.add_argument("--to", dest="stop", metavar="phase", help="stop after this phase")
|
|
59
|
+
args = parser.parse_args(argv)
|
|
60
|
+
|
|
61
|
+
config = load_config(args.config)
|
|
62
|
+
apply_execution_flags(config, args)
|
|
63
|
+
overrides = parse_params(args.params)
|
|
64
|
+
if args.command == "check":
|
|
65
|
+
report = check(config, overrides)
|
|
66
|
+
print(format_report(report))
|
|
67
|
+
write_report(report)
|
|
68
|
+
if not report["ok"]:
|
|
69
|
+
raise SystemExit(1)
|
|
70
|
+
else:
|
|
71
|
+
run(config, overrides, args.start, args.stop)
|
plumber/entries.py
ADDED
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
"""Small readers for one config entry, plus the `Finding` shape that check and probe share."""
|
|
2
|
+
|
|
3
|
+
from typing import NamedTuple
|
|
4
|
+
|
|
5
|
+
PASS, WARN, FAIL, SKIP = "PASS", "WARN", "FAIL", "SKIP"
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
class Finding(NamedTuple):
|
|
9
|
+
"""One problem. `label` is the report row it belongs to ("input", "P1", "partition", ...)."""
|
|
10
|
+
|
|
11
|
+
label: str
|
|
12
|
+
status: str
|
|
13
|
+
message: str
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def phase_label(number):
|
|
17
|
+
return f"P{number}"
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def params_of(entry):
|
|
21
|
+
return entry.get("params") or {}
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def name_of(entry):
|
|
25
|
+
return entry.get("name", entry["path"])
|
|
File without changes
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
"""The canonical adapter shim. Copy this pattern; see docs/adapter-shim.md.
|
|
2
|
+
|
|
3
|
+
`snap_to_network` stands in for "your existing function": it does not have the phase shape.
|
|
4
|
+
`run` is the shim: phase-shaped, pure, and nothing else.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
import geopandas as gpd
|
|
8
|
+
from shapely.ops import nearest_points
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
def snap_to_network(points, network, tolerance):
|
|
12
|
+
"""Existing function (wrong shape for a phase): move points onto the network line."""
|
|
13
|
+
line = network.geometry.union_all()
|
|
14
|
+
snapped = []
|
|
15
|
+
for point in points.geometry:
|
|
16
|
+
on_line = nearest_points(line, point)[0]
|
|
17
|
+
snapped.append(on_line if point.distance(on_line) <= tolerance else point)
|
|
18
|
+
return points.set_geometry(snapped)
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def read_network(path):
|
|
22
|
+
return gpd.read_parquet(path)
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def run(gdf, *, network_path, tolerance):
|
|
26
|
+
"""The shim. Reads its own side input from a path, keeps no state, returns a new gdf."""
|
|
27
|
+
network = read_network(network_path)
|
|
28
|
+
return snap_to_network(gdf, network, tolerance)
|
plumber/local.py
ADDED
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
"""LocalSequential: run everything in this process, one phase after another."""
|
|
2
|
+
|
|
3
|
+
from plumber.checkpoint import checkpoint_dir, write_checkpoint
|
|
4
|
+
from plumber.protocols import Result
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
class LocalSequential:
|
|
8
|
+
def execute(self, input, steps, output, run_params):
|
|
9
|
+
input_fn, input_params = input
|
|
10
|
+
data = input_fn(**input_params)
|
|
11
|
+
for step in steps:
|
|
12
|
+
data = step.fn(data, **step.params)
|
|
13
|
+
if step.checkpoint:
|
|
14
|
+
write_checkpoint(data, checkpoint_dir(run_params), step.name)
|
|
15
|
+
if output is not None:
|
|
16
|
+
output_fn, output_params = output
|
|
17
|
+
output_fn(data, **output_params)
|
|
18
|
+
return Result(data)
|
plumber/localmp.py
ADDED
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
"""LocalMultiprocess (`localmp`): run partitionable phases on pieces, in worker processes.
|
|
2
|
+
|
|
3
|
+
Flow: input -> split -> phases on pieces -> join -> (non-partitionable phase) -> split again
|
|
4
|
+
-> ... -> join -> output. `split` and `join` are the two seams. Anything that needs the
|
|
5
|
+
whole dataset (a non-partitionable phase, the output function, later checkpoints) goes
|
|
6
|
+
through `join`.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from concurrent.futures import ProcessPoolExecutor
|
|
10
|
+
|
|
11
|
+
import numpy as np
|
|
12
|
+
import pandas as pd
|
|
13
|
+
|
|
14
|
+
from plumber.checkpoint import checkpoint_dir, write_checkpoint
|
|
15
|
+
from plumber.protocols import Result
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def split(data, partition):
|
|
19
|
+
"""Cut `data` into pieces by one of: `by` (columns), `chunk_size`, `worker_count`."""
|
|
20
|
+
if "by" in partition:
|
|
21
|
+
return [piece for _, piece in data.groupby(partition["by"], sort=True, dropna=False)]
|
|
22
|
+
if "chunk_size" in partition:
|
|
23
|
+
size = partition["chunk_size"]
|
|
24
|
+
return [data.iloc[start : start + size] for start in range(0, len(data), size)]
|
|
25
|
+
positions = np.array_split(np.arange(len(data)), partition["worker_count"])
|
|
26
|
+
return [data.iloc[p] for p in positions if len(p)]
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def join(pieces, order):
|
|
30
|
+
"""Join pieces into one table in `order`, the index the data had before it was split.
|
|
31
|
+
|
|
32
|
+
Rows sort by where their index label sat in `order`. A phase must keep the index
|
|
33
|
+
(and it must be unique) for this to restore the input order.
|
|
34
|
+
"""
|
|
35
|
+
joined = pd.concat(pieces)
|
|
36
|
+
return joined.iloc[np.argsort(order.get_indexer(joined.index), kind="stable")]
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def whole(data, pieces, order):
|
|
40
|
+
"""All the data in input order: `data` if not split, else the joined pieces."""
|
|
41
|
+
return data if pieces is None else join(pieces, order)
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
class LocalMultiprocess:
|
|
45
|
+
def __init__(self, partition):
|
|
46
|
+
self.partition = partition
|
|
47
|
+
|
|
48
|
+
def execute(self, input, steps, output, run_params):
|
|
49
|
+
input_fn, input_params = input
|
|
50
|
+
data = input_fn(**input_params)
|
|
51
|
+
workers = self.partition.get("worker_count")
|
|
52
|
+
pieces = None # set while the data is split; None means `data` holds everything
|
|
53
|
+
order = None # the index of `data` when it was split
|
|
54
|
+
with ProcessPoolExecutor(max_workers=workers) as pool:
|
|
55
|
+
for step in steps:
|
|
56
|
+
if step.partitionable:
|
|
57
|
+
if pieces is None:
|
|
58
|
+
order = data.index
|
|
59
|
+
pieces = split(data, self.partition)
|
|
60
|
+
futures = [pool.submit(step.fn, piece, **step.params) for piece in pieces]
|
|
61
|
+
pieces = [future.result() for future in futures]
|
|
62
|
+
else:
|
|
63
|
+
data, pieces = whole(data, pieces, order), None
|
|
64
|
+
data = step.fn(data, **step.params)
|
|
65
|
+
if step.checkpoint:
|
|
66
|
+
# Gather point: whole data in input order, write one file, split again.
|
|
67
|
+
data, pieces = whole(data, pieces, order), None
|
|
68
|
+
write_checkpoint(data, checkpoint_dir(run_params), step.name)
|
|
69
|
+
data = whole(data, pieces, order)
|
|
70
|
+
if output is not None:
|
|
71
|
+
output_fn, output_params = output
|
|
72
|
+
output_fn(data, **output_params)
|
|
73
|
+
return Result(data)
|
plumber/probe.py
ADDED
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
"""The runtime probe: run the phases on a small sample file and report what went wrong.
|
|
2
|
+
|
|
3
|
+
Only `plumber check` calls this. It runs user code on real data, so `run`'s preflight skips it.
|
|
4
|
+
It returns `Finding`s, like the other layers.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
import geopandas as gpd
|
|
8
|
+
|
|
9
|
+
from plumber.entries import FAIL, SKIP, WARN, Finding, params_of, phase_label
|
|
10
|
+
from plumber.resolver import resolve
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def probe_phase(fn, params, partitionable, data):
|
|
14
|
+
"""Call one phase. Returns (output, [(status, message), ...]). Output is None on a crash."""
|
|
15
|
+
try:
|
|
16
|
+
output = fn(data.copy(), **params)
|
|
17
|
+
except Exception as error: # user code can raise anything
|
|
18
|
+
return None, [(FAIL, f"{type(error).__name__}: {error}")]
|
|
19
|
+
|
|
20
|
+
results = []
|
|
21
|
+
if partitionable and not output.equals(fn(data.copy(), **params)):
|
|
22
|
+
results.append((FAIL, "different output on identical input (shared state?)"))
|
|
23
|
+
if getattr(output, "crs", None) != getattr(data, "crs", None):
|
|
24
|
+
results.append((WARN, f"CRS changed from {data.crs} to {output.crs}"))
|
|
25
|
+
dropped = [column for column in data.columns if column not in output.columns]
|
|
26
|
+
if dropped:
|
|
27
|
+
results.append((WARN, f"columns dropped: {', '.join(map(str, dropped))}"))
|
|
28
|
+
return output, results
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def probe(sample, phases, overrides):
|
|
32
|
+
"""Chain the sample through `phases` (config entries) in order.
|
|
33
|
+
|
|
34
|
+
After a crash every later phase is SKIP.
|
|
35
|
+
"""
|
|
36
|
+
try:
|
|
37
|
+
data = gpd.read_parquet(sample)
|
|
38
|
+
except Exception as error:
|
|
39
|
+
return [Finding("check.sample", FAIL, f"cannot read {sample!r}: {error}")]
|
|
40
|
+
|
|
41
|
+
findings = []
|
|
42
|
+
crashed = False
|
|
43
|
+
for number, entry in enumerate(phases, start=1):
|
|
44
|
+
label = phase_label(number)
|
|
45
|
+
if crashed:
|
|
46
|
+
findings.append(Finding(label, SKIP, "upstream phase failed"))
|
|
47
|
+
continue
|
|
48
|
+
params = {key: overrides.get(key, value) for key, value in params_of(entry).items()}
|
|
49
|
+
output, results = probe_phase(
|
|
50
|
+
resolve(entry["path"]), params, entry["partitionable"], data
|
|
51
|
+
)
|
|
52
|
+
findings += [Finding(label, status, message) for status, message in results]
|
|
53
|
+
if output is None:
|
|
54
|
+
crashed = True
|
|
55
|
+
else:
|
|
56
|
+
data = output
|
|
57
|
+
return findings
|
plumber/protocols.py
ADDED
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
"""The shapes every part of plumber agrees on: phases, steps, results, strategies."""
|
|
2
|
+
|
|
3
|
+
from dataclasses import dataclass
|
|
4
|
+
from typing import Any, Callable, Generic, Optional, Protocol, TypeVar
|
|
5
|
+
|
|
6
|
+
Payload = TypeVar("Payload")
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
class PhaseModule(Protocol[Payload]):
|
|
10
|
+
"""A resolved phase: a plain function `run(data, **params) -> data`."""
|
|
11
|
+
|
|
12
|
+
def __call__(self, data: Payload, **params: Any) -> Payload: ...
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
@dataclass(frozen=True)
|
|
16
|
+
class Step:
|
|
17
|
+
"""One configured phase, ready to run."""
|
|
18
|
+
|
|
19
|
+
name: str
|
|
20
|
+
fn: Callable
|
|
21
|
+
params: dict
|
|
22
|
+
partitionable: bool
|
|
23
|
+
checkpoint: bool
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
class Result(Generic[Payload]):
|
|
27
|
+
"""What a strategy hands back. Only `.collect()` is public."""
|
|
28
|
+
|
|
29
|
+
def __init__(self, data: Payload):
|
|
30
|
+
self._data = data
|
|
31
|
+
|
|
32
|
+
def collect(self) -> Payload:
|
|
33
|
+
return self._data
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
# A pre-bound (function, params) pair. The strategy calls `fn(**params)` (input)
|
|
37
|
+
# or `fn(data, **params)` (output).
|
|
38
|
+
Bound = tuple[Callable, dict[str, Any]]
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
class ExecutionStrategy(Protocol[Payload]):
|
|
42
|
+
def execute(
|
|
43
|
+
self,
|
|
44
|
+
input: Bound,
|
|
45
|
+
steps: list[Step],
|
|
46
|
+
output: Optional[Bound],
|
|
47
|
+
run_params: dict[str, Any],
|
|
48
|
+
) -> Result[Payload]: ...
|
plumber/resolver.py
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
"""Turn a "pkg.mod:attr" string into the callable it names."""
|
|
2
|
+
|
|
3
|
+
import importlib
|
|
4
|
+
import os
|
|
5
|
+
import sys
|
|
6
|
+
from typing import Callable
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
def resolve(path: str) -> Callable:
|
|
10
|
+
"""Import `pkg.mod` and return its `attr`. The `:attr` part is required."""
|
|
11
|
+
module_name, sep, attr = path.partition(":")
|
|
12
|
+
if not (module_name and sep and attr):
|
|
13
|
+
raise ValueError(f"Bad path {path!r}: expected 'module:attr' (':attr' is required)")
|
|
14
|
+
cwd = os.getcwd()
|
|
15
|
+
if cwd not in sys.path:
|
|
16
|
+
sys.path.insert(0, cwd)
|
|
17
|
+
module = importlib.import_module(module_name)
|
|
18
|
+
try:
|
|
19
|
+
return getattr(module, attr)
|
|
20
|
+
except AttributeError:
|
|
21
|
+
raise ValueError(f"Module {module_name!r} has no attribute {attr!r}") from None
|
plumber/run.py
ADDED
|
@@ -0,0 +1,133 @@
|
|
|
1
|
+
"""Read ./plumber.yaml, resolve the user's functions, and hand them to a strategy."""
|
|
2
|
+
|
|
3
|
+
import time
|
|
4
|
+
import warnings
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
|
|
7
|
+
import yaml
|
|
8
|
+
|
|
9
|
+
from plumber.check import DEFAULT_STRATEGY, preflight
|
|
10
|
+
from plumber.checkpoint import DEFAULT_DIR, checkpoint_path, read_checkpoint
|
|
11
|
+
from plumber.entries import name_of, params_of
|
|
12
|
+
from plumber.local import LocalSequential
|
|
13
|
+
from plumber.localmp import LocalMultiprocess
|
|
14
|
+
from plumber.protocols import Step
|
|
15
|
+
from plumber.resolver import resolve
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def load_config(path="plumber.yaml"):
|
|
19
|
+
return yaml.safe_load(Path(path).read_text())
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def bind(entry):
|
|
23
|
+
"""Turn an `input:`/`output:` block into a (function, params) pair."""
|
|
24
|
+
return resolve(entry["path"]), params_of(entry)
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def build_step(entry, overrides):
|
|
28
|
+
"""Build one Step. CLI overrides replace config params, key by key (shallow merge).
|
|
29
|
+
|
|
30
|
+
An override only applies to phases that already list that key in their params.
|
|
31
|
+
"""
|
|
32
|
+
params = params_of(entry)
|
|
33
|
+
params = {key: overrides.get(key, value) for key, value in params.items()}
|
|
34
|
+
return Step(
|
|
35
|
+
name=name_of(entry),
|
|
36
|
+
fn=resolve(entry["path"]),
|
|
37
|
+
params=params,
|
|
38
|
+
partitionable=entry["partitionable"],
|
|
39
|
+
checkpoint=entry.get("checkpoint", False),
|
|
40
|
+
)
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def build_steps(config, overrides):
|
|
44
|
+
"""Build every Step in config order. Names must be unique.
|
|
45
|
+
|
|
46
|
+
Warns about `--params` keys that no phase lists (likely typos).
|
|
47
|
+
"""
|
|
48
|
+
entries = config["phases"]
|
|
49
|
+
first_seen = {}
|
|
50
|
+
for number, entry in enumerate(entries, start=1):
|
|
51
|
+
name = name_of(entry)
|
|
52
|
+
if name in first_seen:
|
|
53
|
+
raise ValueError(
|
|
54
|
+
f"Duplicate phase name {name!r}: entries {first_seen[name]} and {number}. "
|
|
55
|
+
"Give each a distinct `name:`."
|
|
56
|
+
)
|
|
57
|
+
first_seen[name] = number
|
|
58
|
+
|
|
59
|
+
listed = {key for entry in entries for key in params_of(entry)}
|
|
60
|
+
for key in overrides:
|
|
61
|
+
if key not in listed:
|
|
62
|
+
warnings.warn(
|
|
63
|
+
f"--params {key}: no phase lists this param, so it was ignored", stacklevel=2
|
|
64
|
+
)
|
|
65
|
+
|
|
66
|
+
return [build_step(entry, overrides) for entry in entries]
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def build_strategy(config):
|
|
70
|
+
"""Pick the execution strategy from `execution:` (default `local`); `check` vetted it."""
|
|
71
|
+
execution = config.get("execution") or {}
|
|
72
|
+
if execution.get("strategy", DEFAULT_STRATEGY) == "localmp":
|
|
73
|
+
return LocalMultiprocess(execution["partition"])
|
|
74
|
+
return LocalSequential()
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def phase_index(steps, name, flag):
|
|
78
|
+
names = [step.name for step in steps]
|
|
79
|
+
if name not in names:
|
|
80
|
+
raise SystemExit(f"{flag} {name}: no such phase. Phases are: {', '.join(names)}")
|
|
81
|
+
return names.index(name)
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
def checkpoint_input(steps, first, directory):
|
|
85
|
+
"""The input pair for `--from`: read the checkpoint of the phase before `first`."""
|
|
86
|
+
previous = steps[first - 1].name
|
|
87
|
+
path = checkpoint_path(directory, previous)
|
|
88
|
+
if not path.exists():
|
|
89
|
+
raise SystemExit(
|
|
90
|
+
f"--from {steps[first].name}: no checkpoint for {previous!r} at {path}. "
|
|
91
|
+
f"Set `checkpoint: true` on {previous} and run through it once."
|
|
92
|
+
)
|
|
93
|
+
saved = time.strftime("%Y-%m-%d %H:%M", time.localtime(path.stat().st_mtime))
|
|
94
|
+
warnings.warn(
|
|
95
|
+
f"Starting at {steps[first].name} from checkpoint {path.name} (saved {saved})",
|
|
96
|
+
stacklevel=3,
|
|
97
|
+
)
|
|
98
|
+
return read_checkpoint, {"directory": directory, "name": previous}
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
def run(config, overrides=None, start=None, stop=None):
|
|
102
|
+
"""Run the pipeline. This never reads data; the strategy calls the user's functions.
|
|
103
|
+
|
|
104
|
+
`start`/`stop` are phase names (`--from`/`--to`). This function alone rewrites the call:
|
|
105
|
+
a later `start` swaps the input for the previous checkpoint, and an earlier `stop`
|
|
106
|
+
drops the output. The strategy just gets an ordinary `execute`.
|
|
107
|
+
"""
|
|
108
|
+
preflight(config)
|
|
109
|
+
input_pair = bind(config["input"])
|
|
110
|
+
all_steps = build_steps(config, overrides or {})
|
|
111
|
+
if config.get("output"):
|
|
112
|
+
output_pair = bind(config["output"])
|
|
113
|
+
else:
|
|
114
|
+
warnings.warn("No `output:` in config: result discarded", stacklevel=2)
|
|
115
|
+
output_pair = None
|
|
116
|
+
directory = (config.get("checkpoints") or {}).get("dir", DEFAULT_DIR)
|
|
117
|
+
|
|
118
|
+
first = phase_index(all_steps, start, "--from") if start else 0
|
|
119
|
+
last = phase_index(all_steps, stop, "--to") if stop else len(all_steps) - 1
|
|
120
|
+
if first > last:
|
|
121
|
+
raise SystemExit(f"--from {start} comes after --to {stop}")
|
|
122
|
+
if first > 0:
|
|
123
|
+
input_pair = checkpoint_input(all_steps, first, directory)
|
|
124
|
+
if last < len(all_steps) - 1:
|
|
125
|
+
output_pair = None
|
|
126
|
+
if not all_steps[last].checkpoint:
|
|
127
|
+
warnings.warn(
|
|
128
|
+
f"--to {stop}: {stop} has no checkpoint: result discarded; runs anyway",
|
|
129
|
+
stacklevel=2,
|
|
130
|
+
)
|
|
131
|
+
steps = all_steps[first : last + 1]
|
|
132
|
+
run_params = {"checkpoint_dir": directory}
|
|
133
|
+
return build_strategy(config).execute(input_pair, steps, output_pair, run_params)
|