Pipeline steps¶
A pipeline step reads a day's raw readings, drops the rows with no value, and writes a
clean copy. This guide declares what the step needs before it runs and what it promises
afterwards, runs it as a Step, and shows what happens when it runs again
or fails. It needs Python 3.14 and wend, with no extras. The examples build on each
other: run them in order, in an empty directory.
Declare the paths and their conditions¶
from wend import Absent, Creatable, Exists, File, P, Param, Readable, Step, T
root, day = Param("root"), Param("day")
raw = (P(root) / "raw" / T(t"{day}.csv")).requires(File, Exists, Readable)
clean = (P(root) / "clean" / T(t"{day}.csv")).requires(Absent, Creatable)
clean = clean.ensures(File, Exists)
convert = Step(raw=raw, clean=clean)
requiresdeclares preconditions. The raw file must be an existing, readable file. Nothing may be at the clean path yet, and it must be possible to create it.ensuresdeclares postconditions: after the step, the clean path is an existing file.Stepgroups the paths that one step uses, by name. Its bindings are shared, and each path takes the parameters it uses.
effects lists what each path is declared to be used
for:
>>> convert
Step(raw='{root}/raw/{day}.csv', clean='{root}/clean/{day}.csv')
>>> convert.effects()
[<raw: read {root}/raw/{day}.csv>, <clean: create {root}/clean/{day}.csv>]
read comes from Readable, and create from Creatable. A path that must exist
afterwards and need not exist before is also listed as create. The list reports the
declarations. It does not observe what the step's code does.
Write the transform¶
The transform is ordinary code that takes two pathlib.Path values:
import csv
from pathlib import Path
def drop_missing(src: Path, dst: Path) -> None:
with src.open(newline="") as f:
rows = [row for row in csv.DictReader(f) if row["temp_c"].strip()]
dst.parent.mkdir(parents=True, exist_ok=True)
with dst.open("w", newline="") as f:
out = csv.DictWriter(f, fieldnames=["station", "temp_c"])
out.writeheader()
out.writerows(rows)
Path("data/raw").mkdir(parents=True)
Path("data/raw/2026-10-17.csv").write_text("station,temp_c\nA,11.5\nB,\nC,9.0\n")
Run the step¶
Calling the step with bindings gives a context manager. Entering it checks every
precondition and gives the rendered paths, as a dict keyed by the names given to Step.
Leaving it without an exception checks every postcondition.
>>> with convert(root="data", day="2026-10-17") as p:
... print(p)
... drop_missing(p["raw"], p["clean"])
{'raw': PosixPath('data/raw/2026-10-17.csv'), 'clean': PosixPath('data/clean/2026-10-17.csv')}
>>> print(Path("data/clean/2026-10-17.csv").read_text())
station,temp_c
A,11.5
C,9.0
<BLANKLINE>
Run it a second time¶
The clean file now exists, and the step requires it to be absent. The second run raises before the body runs, so the output is not overwritten:
>>> with convert(root="data", day="2026-10-17") as p:
... drop_missing(p["raw"], p["clean"])
Traceback (most recent call last):
...
wend.errors.PathExistsError: 'data/clean/2026-10-17.csv' already exists (required: Absent)
PathExistsError is also a FileExistsError. A loop over every
day with input can run the step for each one and skip the days already done.
glob yields a Match for each raw file, and a
Match is a mapping of bindings, so it can be passed to the step:
Path("data/raw/2026-10-18.csv").write_text("station,temp_c\nA,12.0\nB,10.5\n")
>>> for m in raw.glob(root="data"):
... try:
... with convert(m) as p:
... drop_missing(p["raw"], p["clean"])
... except FileExistsError:
... print("skipped", m["day"])
... else:
... print("converted", m["day"])
skipped 2026-10-17
converted 2026-10-18
A missing input¶
A day with no raw file fails on entry, with
MissingPathError, a FileNotFoundError:
>>> with convert(root="data", day="2026-10-19") as p:
... drop_missing(p["raw"], p["clean"])
Traceback (most recent call last):
...
wend.errors.MissingPathError: 'data/raw/2026-10-19.csv' does not exist (required: Exists)
check runs the same checks without a body, and returns
the rendered paths. Once the input exists, they pass:
Path("data/raw/2026-10-19.csv").write_text("station,temp_c\n")
>>> convert.check(root="data", day="2026-10-19")
{'raw': PosixPath('data/raw/2026-10-19.csv'), 'clean': PosixPath('data/clean/2026-10-19.csv')}
A body that does not write its output¶
If the body finishes without creating the clean file, the postcondition fails when the block is left:
>>> with convert(root="data", day="2026-10-19") as p:
... pass
Traceback (most recent call last):
...
wend.errors.MissingPathError: 'data/clean/2026-10-19.csv' does not exist (required: Exists)
If the body raises, its exception propagates and the postconditions are not checked.
confirm checks the postconditions on their own.
A step that replaces its output¶
To let a step overwrite its output, declare Writable in place of
Absent and Creatable. Writable holds for an existing path that can be written, or
for one that can be created.
>>> from wend import Writable
>>> output = (P(root) / "clean" / T(t"{day}.csv")).requires(Writable)
>>> rebuild = Step(raw=raw, clean=output.ensures(File, Exists))
>>> rebuild.effects()
[<raw: read {root}/raw/{day}.csv>, <clean: create, write {root}/clean/{day}.csv>]
>>> with rebuild(root="data", day="2026-10-17") as p:
... drop_missing(p["raw"], p["clean"])
What the checks guarantee¶
Each check describes the filesystem at the moment it runs. A file that exists when the step is entered can be removed before the body opens it, and the checks are not atomic with the body's reads and writes. The conditions state what the step expects and catch mistakes such as a missing input or an output that would be overwritten (Effects).
See also¶
- Effects: every condition, what it checks, and the error it raises.
- Lineage and caching: record what each run of a step made from what, with
Ledger.run. - API reference:
Step,PathExpr.requires,PathExpr.ensures,wend.conditions,wend.errors.