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.
@@ -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,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: hatchling 1.32.4
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
@@ -0,0 +1,2 @@
1
+ [console_scripts]
2
+ plumber = plumber.cli:main
@@ -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
@@ -0,0 +1,3 @@
1
+ """plumber: geospatial pipelines with swappable execution strategies."""
2
+
3
+ __version__ = "0.1.0"
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)