Skip to main content
Learning Center
Python Programming

Module 14: The Opportunity Review Assistant

Capstone Phase 5 to 7: Storage, Reporting, and the Test Suite

Storing the validated batch idempotently, producing the CSV and summary a reviewer receives, and covering the pipeline with four test categories, including a regression.

Lesson 45 of 46 in the recommended order · About 30 min (estimate)

On this page

Outcome

By the end of this lesson you can store the validated batch so a second run is safe, produce the report a reviewer actually receives, and cover the whole pipeline with tests in all four categories including a regression.

Why it matters

These three phases turn a working script into something somebody else could run. Storage can preserve yesterday's shortlist; reporting brings results to a person who will not run the code; tests make tomorrow's change safer to review.

Phase 6 is also where the project either keeps its promises or quietly stops. The whole point of phases 3 and 4 was that verdicts carry reasons and figures carry denominators. A report that prints five rows and a total, dropping the reasons and the exclusions, throws away the work.

The tests are what let you finish. Without them the last three phases are a slow, anxious sequence of manual checks, repeated every time anything changes.

Concept

Capstone phases 5 to 7: store, report, verify

Continue with validated records and rule verdicts: Persist the validated batch, derive the reviewer outputs from stored rows, and check each boundary with fixtures and tests.

The map shows dependencies to implement; it is not evidence the capstone has passed.

Phase 5 - Store

Input
Validated records and agency reference data.
Write
Transactional upsert by notice id; enable and enforce agency foreign keys.
Read
Bound shortlist query joins stored notices to agency names.

A second run should refresh existing rows without duplicates; tests must also check updated values and rollback.

Phase 6 - Report

CSV
Named columns and quoted titles, generated from shortlisted rows.
Summary
Counts with explicit bases, validation exclusions and rule reasons.

Reconcile the matched rows with the CSV; retain why other records were invalid, incomplete or did not match.

Phase 7 - Verify

Success
Compare the generated shortlist with the expected fixture.
Boundary
Test the exact minimum and one unit below.
Malformed
Name each field error and account for every input.
Regression
Keep the exact case for a repaired defect.

Tests run locally against stored fixtures, without a real provider or credential.

Storage feeds the CSV and summary; tests check both persistence and what a reviewer sees. The diagram names the contracts to verify without supplying a finished CSV, summary, test result or release claim.

Storage. A small normalised schema: an agencies table seeded from the CSV fixture, and a notices table with the notice id as primary key and a foreign key to the agency. Constraints carry real weight here. NOT NULL on the agency code and the foreign key give you a second, independent detection of the unrecognised agency your normalisation already reported, which is a good property rather than duplicated work.

The load is an upsert inside one transaction, so a second run refreshes rather than duplicates, and a failure partway through rolls back. Schema changes go in numbered forward-only migration files. Values supplied by records and queries bind as parameters; the fixed schema seed literals below are illustrative constants.

Reporting. Two outputs.

The CSV is what a reviewer opens in a spreadsheet: written with csv.DictWriter so a title containing a comma round-trips, named columns, one row per shortlisted notice. The expected result is stored as a fixture, so the test compares against a file rather than against a description.

The Markdown summary is what a reviewer reads first, and every figure states what it was computed from: records read, records normalised, records excluded and why, records matched, and, for any average, its denominator. A summary that cannot be reconciled to the CSV is a defect.

Tests, in the four categories from Module 5, over this pipeline:

  • Success: the fixture batch produces exactly the expected shortlist.
  • Boundary: a record worth exactly the minimum qualifies; one unit below does not.
  • Malformed: each defect in the malformed fixture produces an error naming its field, and both the clean-plus-errors count and per-input identity checks hold.
  • Regression: the exact input from a defect you diagnosed and repaired.

Two properties the whole suite must have. It runs with no network access or project-specific credentials and configuration variables, which makes the fixture tests reproducible. And it does not edit an existing assertion merely to make a change pass; a failing test is reporting something.

Give every assertion a message naming the behaviour it protects. When one fails months from now, that sentence is the entire diagnosis.

Read the code

import csv
import io
import sqlite3

connection = sqlite3.connect(":memory:")
connection.execute("PRAGMA foreign_keys = ON")
connection.executescript(
    """
    CREATE TABLE agencies (agency_code TEXT PRIMARY KEY, name TEXT NOT NULL);
    CREATE TABLE notices (
        notice_id TEXT PRIMARY KEY,
        agency_code TEXT NOT NULL REFERENCES agencies(agency_code),
        title TEXT NOT NULL,
        amount INTEGER NOT NULL
    );
    INSERT INTO agencies VALUES ('GSA', 'General Services Administration');
    INSERT INTO agencies VALUES ('EPA', 'Environmental Protection Agency');
    """
)

UPSERT = (
    "INSERT INTO notices (notice_id, agency_code, title, amount) VALUES (?, ?, ?, ?)"
    " ON CONFLICT(notice_id) DO UPDATE SET"
    " agency_code = excluded.agency_code, title = excluded.title, amount = excluded.amount"
)


def load(rows):
    with connection:
        connection.executemany(UPSERT, rows)
    return len(rows)


def shortlist(minimum):
    return connection.execute(
        "SELECT n.notice_id, n.title, a.name, n.amount FROM notices n"
        " JOIN agencies a ON a.agency_code = n.agency_code"
        " WHERE n.amount >= ? ORDER BY n.amount DESC",
        (minimum,),
    ).fetchall()


def to_csv(rows):
    out = io.StringIO()
    writer = csv.DictWriter(out, fieldnames=["notice_id", "title", "agency_name", "amount"])
    writer.writeheader()
    for notice_id, title, agency_name, amount in rows:
        writer.writerow(
            {"notice_id": notice_id, "title": title, "agency_name": agency_name, "amount": amount}
        )
    return out.getvalue()


BATCH = [
    ("SYN-0001", "GSA", "Grounds Maintenance, Building 7", 310000),
    ("SYN-0011", "EPA", "Groundwater Monitoring Wells", 640000),
    ("SYN-0006", "GSA", "Snow Removal", 42000),
]

print(load(BATCH), load(BATCH))
print(connection.execute("SELECT COUNT(*) FROM notices").fetchone()[0])
print(to_csv(shortlist(100000)).splitlines()[2])
print(len(shortlist(100000)), "of", connection.execute("SELECT COUNT(*) FROM notices").fetchone()[0])

The load runs twice on purpose. The printed count shows no duplicate rows after a second run; the upsert statement also updates existing values. The full phase 5 test must assert both the stable row count and refreshed values, rather than treating this printout as proof of both.

shortlist joins to agencies so the report carries the readable name rather than the code, and the join is an inner one, which is correct here: a notice whose agency code is unrecognised was already rejected during normalisation, and the foreign key would have refused it anyway.

to_csv uses DictWriter, so the title containing a comma is quoted correctly on the way out.

Predict the output

Predict all four printed lines.

Check your prediction
3 3
3
SYN-0001,"Grounds Maintenance, Building 7",General Services Administration,310000
2 of 3

The third line is the one worth studying. csv.DictWriter quotes only the fields that need it, so the title is wrapped in quotation marks and the other three are not. That is correct minimal quoting, and it is exactly what a hand-rolled writer using ",".join(...) would get wrong.

The line printed is the third of the CSV, which is the second data row: the shortlist is ordered by amount descending, so the Environmental Protection Agency notice at 640,000 comes first and this one second.

The second printed line shows that two runs over three records leave three rows. A separate value assertion is needed to prove a changed record would refresh. The last line reports the shortlist alongside the total, which is the denominator discipline arriving in the report.

Modify the code

Change to_csv to build each line with ",".join(str(value) for value in row) instead of using DictWriter. Predict the third printed line, and what a consumer sees.

What changes, and why
SYN-0001,Grounds Maintenance, Building 7,General Services Administration,310000

Four fields written, five fields read back. Every column after the title shifts left by one, so a spreadsheet shows Building 7 as the agency name and General Services Administration in the amount column.

Nothing raises and the file opens, but the second data row now has an extra field and shifted columns because its title contains a comma. A comparison with the completed capstone's expected CSV fixture can catch this class of error; this smaller worked CSV is not the same file as that full fixture.

Debug the bug

An assistant was asked for the summary section of the report. It produced this.

def summary(shortlisted, all_records, errors):
    average = sum(r["amount"] for r in shortlisted) / len(shortlisted)
    return (
        f"# Opportunity Review\n\n"
        f"- {len(shortlisted)} opportunities matched\n"
        f"- average value {average:,.0f}\n"
        f"- {len(shortlisted) / len(all_records):.0%} of the pipeline\n"
    )


print(summary([], [], []))
What's actually wrong

It raises ZeroDivisionError on the first line, before producing anything.

An empty shortlist is a completely normal outcome, and the summary is the one place that must still work when there is nothing to say. A run that matches nothing and crashes while reporting it leaves an operator with no output and no explanation.

Fix the division and three more problems remain, all of which this course has already named:

The average has no denominator and no exclusions. It is computed over shortlisted records only, and the reader is not told how many that was or how many records had no published amount. That is Module 8's rule, and the summary is exactly where it applies.

The percentage is against all_records, which may be the raw batch, the normalised set, or something else entirely. A proportion whose base is ambiguous is unreadable.

errors is accepted and never used. These validation errors name records rejected before matching; both their count and messages are missing. Rule-level incomplete records are a separate category this miniature does not receive.

A version that says what it knows, as a separate dictionary-shaped summary miniature: shortlisted and normalised hold records with named amount fields, and errors holds validation-error messages. The earlier SQL shortlist() returns tuples; the integration layer must map those tuples to named records before passing them here. Its filter checks the worked amount minimum only, not the full configured rule set. Rule-level no-match and incomplete reasons are outside this small function:

def summary(shortlisted, normalised, errors):
    lines = [
        "# Opportunity Review",
        "",
        f"- {len(normalised)} records normalised, {len(errors)} validation errors",
        f"- {len(shortlisted)} of {len(normalised)} met the worked amount minimum",
    ]
    amounts = [r["amount"] for r in shortlisted if r.get("amount") is not None]
    if amounts:
        lines.append(f"- average value for amount-minimum rows {sum(amounts) / len(amounts):,.0f} over {len(amounts)} records")
    else:
        lines.append("- no amount-minimum rows with a published value")
    lines.extend(f"- validation error: {error}" for error in errors)
    return "\n".join(lines)

The counts and average state their bases, validation errors are named, and an empty shortlist produces a report rather than a traceback. This miniature still needs actual configured-rule verdicts, rejection reasons and incomplete reasons added to satisfy the full Phase 6 report.

Try it yourself

Four assertions, one per category. One of them fails. Read the message, repair the defect, and confirm the others still hold.

Loading this exercise…

Practical challenge (optional)

Optional, and it completes phases 5 to 7. Build the real schema with its migration file, load the twelve-record fixture batch, generate the CSV, and compare it against capstone/fixtures/expected-shortlist.csv row for row. Then write the four-category suite over the real pipeline, seed one defect of your own by changing a single comparison, confirm which test fails, repair it, and keep the test as your regression case. Record which defect you seeded and which test caught it; the retrospective in phase 11 asks for it.

Sign in to track your progress on this exercise.

AI collaboration

Checkpoint

  1. Why does an upsert matter more than a plain insert for the capstone's load?
  2. Why store the expected CSV as a fixture rather than describing it in a test?
  3. Name the two properties the whole suite must have.
  4. What must a summary figure always state?
Answers
  1. A second run is normal, after a failure or a refresh. An upsert leaves the row count unchanged and the values current; a plain insert raises, and a table without a key would duplicate silently.
  2. A stored fixture makes the expected columns, rows and order concrete. A parsed row-by-row comparison checks those values; a byte comparison can additionally check exact quoting and line endings. Either test must say which contract it verifies.
  3. It runs without network access or project-specific credentials and configuration variables, and no existing assertion is edited merely to make a change pass.
  4. Its denominator, and how many records were excluded from it. A proportion or average without a base is unreadable.

Sign in to track your progress on this exercise.

Summary and next step

Store with constraints and an upsert inside a transaction, report with the CSV module and a summary whose every figure names its base, and cover the pipeline with success, boundary, malformed, and regression cases that run offline. Next: reading the API contract behind the fixtures, the read-only interface, one reviewed change, and the documents that hand the project to somebody else.

learning.goultergroup.com

The interactive parts of this page have not loaded. Reading and links still work; reload the page to try again.