Capstone: The Order Digest
Capstone Part One: Assembling the Digest
Joining collection, validation, aggregation and reporting into one workflow with a single entry point, where every stage hands the next one a value rather than a side effect.
Lesson 16 of 18 in the recommended order · About 30 min (estimate)
On this page
Workflow automation glossary — terms and common confusions
- Dry run
- A run that reports every change it would make and makes none of them.
A dry run that still writes a log file, sends a message, or creates a folder is not a dry run.
- Idempotent
- Running it again leaves the same result as running it once.
"It did not crash the second time" is not the same property; check what the second run changed.
- Run record
- The stored facts about one execution: when it started, what it read, what it changed, how it ended.
A run record is not the log. The log is prose for a person; the record is data the next run reads.
- Transient failure
- A failure that a later identical attempt could succeed at, such as a timeout.
A rejected record and a dropped connection are not the same failure, and retrying the first one forever is a bug.
- Backoff
- Waiting longer between successive retries instead of retrying immediately.
Backoff without a maximum attempt count is an unbounded loop with a politeness delay.
- Request budget
- A hard cap on how many requests one run may make, checked before each request.
A page limit is not a budget if a retry can make extra requests the limit never counts.
- Quarantine
- Setting aside a record a run could not process, with the reason, so the run continues.
Quarantine is not "skip". A skipped record leaves no trace; a quarantined one is countable and reviewable.
- Reconciliation
- Showing that the counts in a report add up: read equals processed plus quarantined plus rejected.
A report whose totals cannot be reconciled is a summary of what the code believed, not of what happened.
- Trigger
- The event or time that causes a run to start.
Scheduling a trigger is a decision made on a machine, not something a browser lesson can install for you.
- Heartbeat
- A signal a healthy run emits, whose absence is itself the alert.
Alerting only on errors cannot detect the job that stopped running at all, which is the most common outage.
- Staleness
- How old the most recent successful run is, compared with how old it is allowed to be.
A green last run is not freshness. Ask when it ran, not whether it passed.
- Atomic replace
- Writing output to a temporary name and renaming it into place in one step.
A reader never sees a half-written file; an interrupted run leaves the previous output intact.
- Transport
- The injected callable that actually performs a request, separate from the client that interprets it.
A client that builds its own connection cannot be tested without a network, which is why the seam exists.
- In-memory filesystem
- The filesystem this course’s browser exercises operate on, which lives only inside the tab.
It behaves like a filesystem and is not your disk: nothing an exercise writes exists after the run ends.
Outcome
By the end of this lesson you have the shape of the whole project: one entry point, five stages that each take a value and return a value, and a digest whose totals can be checked against the number of records that went in.
Why it matters
Five techniques that each work are not a workflow. The thing that makes them one is the seam between them, and the seam is where projects like this usually come apart.
The failure mode has a recognisable shape: stage two writes into a dictionary that stage four also writes into, stage three reads a global that stage one set, and by the time somebody wants to test stage four alone they cannot, because stage four does not take an input — it takes the accumulated state of everything before it. At that point the only way to test anything is to run all of it, and the only way to find a defect is to read all of it.
The alternative costs nothing at the time and is the difference between a project you can change and one you can only rewrite: every stage takes a value and returns a value. Collection returns files. Parsing returns records and rejections. Validation returns accepted and quarantined. Aggregation returns totals. Reporting returns lines. Each one can be run on its own, with hand-made input, in a test that takes a millisecond.
The digest also needs to be checkable. A report saying "north: 2 orders, 21 units" is a claim, and the only way anybody can believe it is if the same run also says how many records it read, how many it accepted, and how many it set aside — and if those numbers add up.
Concept
The layout. A project somebody else can read has an obvious front door:
order-digest/
run.py the entry point; parses arguments, calls the pipeline, exits with a status
digest/
collect.py inbox -> list of files
parse.py rows -> records, rejections
validate.py records -> accepted, quarantined
aggregate.py accepted -> totals
report.py totals + counts -> report lines
record.py everything -> the run record
settings.toml paths, budget, deadline, schedule interval
fixtures/ the recorded inputs the tests run against
tests/
RUNBOOK.md
Nothing here is required by Python; it is required by the person reading it in a year. The one rule worth insisting on is that run.py contains no domain transformations: it reads settings, parses arguments, calls stages, and decides the exit status. Keep parsing, validation, and aggregation in functions that can be tested without running the whole program.
If you would rather start from something running, starter_daily_digest.py is this shape collapsed into one file, with four functions left for you and a completed version to compare against afterwards. It reads sample-orders.csv, which is the same data as the capstone's first fixture, so the totals it produces are the ones the capstone's expected output states.
The pipeline, as one function. The five processing stages are collect, parse, validate, aggregate, and report. The run record is a separate final output.
Trace the digest stage boundaries
Five processing stages, with named handoffs
Reconcile the conceptual five-stage run: read = rejected + quarantined + accepted
Each read row must end in exactly one bucket. The smaller practice exercise combines all set-aside rows into quarantined instead.
1. Collect
Receives- Inbox and archive locations
Hands forward- files: the selected input files
File selection and archiving are boundary effects; the next stage receives a value.
Next step in this same run
2. Parse
Receives- rows from files, plus schema
Hands forward- records: parsed candidates and rejected rows
A rejection carries a reason and contributes to the read count.
Next step in this same run
3. Validate
Receives- parsed candidates, rules and lookups
Hands forward- checked: accepted and quarantined records
Only accepted records may enter aggregation.
Next step in this same run
4. Aggregate
Receives- checked.accepted
Hands forward- totals: grouped orders, units and value
This stage should not need input files or shared mutable totals.
Next step in this same run
5. Report
Receives- totals, reconciled counts and metadata
Hands forward- lines: the human-readable digest
A separate final run record holds counts, duration and status; run.py orchestrates the calls and exit status.
files = collect(inbox, archive)
records = parse(rows_from(files), schema)
checked = validate(records.accepted, rules, lookups)
totals = aggregate(checked.accepted)
counts = counts_from(records, checked)
lines = report(totals, counts, metadata)
record = finish(job, counts, seconds, status)
This is a map of value handoffs, not executable code or a prescribed function API: rows_from, counts_from, and finish stand for work the entry point must arrange. Keep the transformation stages independent of shared state. Collection may archive input, while writing report lines or a run record belongs at an explicit output boundary.
Counts flow forward, and they are the reconciliation. Each stage reports what it received and what it produced, and the final record adds them up:
read rows that came out of the files
rejected rows parse could not turn into records
quarantined records validate set aside
accepted records that reached the aggregation
read == rejected + quarantined + accepted
That equation is the thing to check in code. A digest that cannot state it is a digest nobody can check.
Settings, in one place. The inbox path, the archive path, the request budget, the deadline, the schedule interval, and the region lookup all belong in one file that is read once, at the start, by the entry point. Values scattered through the stages are values nobody can find when the job needs to move to another machine.
One exit status. 0 for a completed run, and something non-zero for anything else, so a scheduler can tell. A refused run is not a crash, and giving it its own status — 2, say — lets monitoring distinguish "did not run because the input was not there" from "broke".
Read the code
The middle of the pipeline, joined up:
def parse_rows(rows, regions):
"""Rows to typed records, or a rejection naming the field."""
accepted, rejected = [], []
for row in rows:
try:
units = int(row["units"])
except (KeyError, ValueError):
rejected.append((row.get("order_id", "?"), f"units: {row.get('units', '')!r} is not a whole number"))
continue
if row["region_code"] not in regions:
rejected.append((row["order_id"], f"region_code: {row['region_code']} is not a known region"))
continue
accepted.append({**row, "units": units, "group": regions[row["region_code"]]})
return accepted, rejected
def aggregate(records):
"""Totals by reporting group, from accepted records only."""
totals = {}
for record in records:
group = totals.setdefault(record["group"], {"orders": 0, "units": 0, "value": 0.0})
group["orders"] += 1
group["units"] += record["units"]
group["value"] += record["units"] * float(record["unit_price"])
return totals
REGIONS = {"NW": "north", "NE": "north", "SW": "south", "SE": "south"}
ROWS = [
{"order_id": "ORD-1", "region_code": "NW", "units": "12", "unit_price": "4.50"},
{"order_id": "ORD-2", "region_code": "ZZ", "units": "3", "unit_price": "19.99"},
{"order_id": "ORD-3", "region_code": "NE", "units": "two", "unit_price": "7.25"},
]
accepted, rejected = parse_rows(ROWS, REGIONS)
totals = aggregate(accepted)
print("read:", len(ROWS))
print("accepted:", len(accepted))
print("rejected:", len(rejected))
for group in sorted(totals):
figures = totals[group]
print(f"{group}: {figures['orders']} orders, {figures['units']} units, {figures['value']:.2f}")
for order_id, reason in rejected:
print("rejected:", order_id, reason)
parse_rows returns two lists for the shown bad-units and unknown-region cases, so those rows do not stop this batch. Returning them lets the caller count both outcomes. This small example does not handle every malformed row: missing region_code or order_id can still raise, and aggregate can raise on an invalid unit_price. A production input contract would need to handle those cases deliberately.
aggregate takes only the accepted records. It never sees a rejection, so it cannot accidentally include one, and it can be tested with three hand-written dictionaries.
{**row, "units": units, ...} builds a new record rather than modifying the row in place. The caller still holds the original, unmodified, which matters when a later stage wants to report what actually arrived.
:.2f formats the displayed money value to two decimal places. Floating-point arithmetic can sometimes produce longer raw decimal representations; this formatting keeps the report readable. For money that must be exact across many operations, use decimal arithmetic rather than relying on display formatting.
Predict the output
Predict every line, in order.
Check your prediction
read: 3
accepted: 1
rejected: 2
north: 1 orders, 12 units, 54.00
rejected: ORD-2 region_code: ZZ is not a known region
rejected: ORD-3 units: 'two' is not a whole number
Only ORD-1 survives. ORD-2 carries a region code the lookup does not contain and ORD-3 has units that will not convert, and each is rejected with a reason naming the field and the value.
1 orders reads badly and is deliberate here: pluralising in a format string is a decision worth making once, in the reporting stage, rather than in the middle of an aggregation.
The counts reconcile: 3 read, 1 accepted, 2 rejected.
Modify the code
Make aggregate take all the rows instead of only the accepted ones, by calling aggregate(ROWS).
What changes, and why
It raises:
KeyError: 'group'
The raw rows never went through parse_rows, so none of them has a group key. The crash is the good outcome, and it is worth understanding why it was available: aggregate requires a field that only the parsing stage adds, so a caller that skips the stage is stopped immediately rather than producing totals from unvalidated data.
If aggregate used record.get("group", "unknown"), it would hide the missing group at first and create an unknown bucket. It would then increment that bucket's order count for ORD-1 before raising when it tries to add the raw string "12" to its numeric units total. The code would not complete a valid digest; the fallback only defers the failure and leaves a partially changed accumulator. Accommodating input a stage was not designed for weakens the validation boundary.
Debug the bug
An assistant was asked to "put the pipeline together into one script". It produced this.
TOTALS = {}
ERRORS = []
def process(rows):
for row in rows:
try:
TOTALS.setdefault(row["region_code"], 0)
TOTALS[row["region_code"]] += int(row["units"])
except Exception as error:
ERRORS.append(str(error))
def main():
process(read_rows())
print(f"Digest: {TOTALS}")
if ERRORS:
print(f"{len(ERRORS)} errors")
What's actually wrong
- The stages share module-level state.
TOTALSandERRORSare globals, so nothing can be tested in isolation, two runs in one process contaminate each other, and the only way to know whatprocessdid is to inspect variables it did not return. - Nothing reconciles. The number of rows read is never counted, so
TOTALSandERRORScannot be checked against it. Whensetdefaultsucceeds beforeintfails, a zero-valued region key remains and an error is recorded, but the output does not say which input row contributed or failed. except Exceptionaround the whole body catches aKeyErrorfor a missing column and aValueErrorfor bad units identically, and records only the exception's text. That text may contain a field name or bad value, but it does not reliably identify the order or preserve structured context.- It groups by raw region code, never consulting the lookup, so an unknown code becomes a legitimate-looking group in the digest.
- It reports units only. No order count, no value, and no way to tell whether a group with 40 units is one large order or forty small ones.
print(f"Digest: {TOTALS}")puts a Python dictionary in front of a person at eight in the morning.
The version that can be tested a stage at a time:
def process(rows, regions):
accepted, rejected = parse_rows(rows, regions)
return {
"totals": aggregate(accepted),
"counts": {"read": len(rows), "accepted": len(accepted), "rejected": len(rejected)},
"rejected": rejected,
}
One function, no globals, everything returned. Each stage inside it takes a value and gives one back, the counts come out with the totals so they can be reconciled, and every rejection carries the identifier and reason a person needs.
Try it yourself
Assemble the digest. Write parse_rows, aggregate, and report, and run them over the supplied orders and region lookup. The printed digest must reconcile: read equals accepted plus quarantined.
Loading this exercise…
Practical challenge (optional)
Optional, and the transfer task for this lesson: make the digest comparable with yesterday's.
Add a second supplied day of orders, produce both digests, and write the three lines that describe the difference between them — orders, units, and value, per group, as a change rather than a level. Then decide what the report should say on the first day, when there is nothing to compare against.
What a good answer looks like
The first-day case is the interesting one, and "0% change" is the wrong answer: it claims a comparison that did not happen. no previous run to compare against is the honest line, and it is the same distinction the staleness check drew between "stale" and "never succeeded".
The second thing people find is that a comparison needs the groups from both days, not just today's. A region that had orders yesterday and none today disappears from today's totals entirely, so a difference computed by walking today's groups reports nothing about it — which is exactly the change most worth noticing.
Sign in to track your progress on this exercise.
AI collaboration
Checkpoint
- Why should every stage take a value and return a value rather than update shared state?
- What does the reconciliation equation compare, and where should it be checked?
- Why should
run.pykeep domain transformations out of the entry point? - Why is it better for
aggregateto raise on an unparsed row than to accommodate it?
Answers
- Because a stage that reads shared state cannot be run on its own: it needs everything before it to have run first. Passing values means each stage can be tested in a millisecond with hand-written input, and two runs in one process cannot contaminate each other.
- That the number of rows read equals rejected plus quarantined plus accepted, checked in code at the end of the run, and reported in the digest so a reader can see the numbers account for each other.
- Because parsing, validation, and aggregation in the entry point are harder to test on their own. Argument parsing, orchestration, and exit-status selection still belong there.
- Because accommodating input a stage was not designed for can hide a missing transformation. A missing
groupkey means the row skipped parsing or normalization; raising shows that boundary failure immediately, while a fallback could defer it.
Sign in to track your progress on this exercise.
Summary and next step
One entry point without domain transformations, stages that each take a value and return a value, counts that flow forward and reconcile at the end, settings in one file, and one exit status a scheduler can read. That is the whole digest, and it works on a good day. The next lesson is about the other days: interrupting it deliberately, proving the re-run redoes nothing it already did, and checking that a second run over unchanged input changes nothing at all.