Math, Statistics, and DataWorking with Real Data › Day 135

Hands-on lab — Day 135: From API to DataFrame

Commands

Setup

cd labs/sections/math-statistics-and-data/day-135-from-api-to-dataframe
python3 -m venv .venv
.venv/bin/pip install -r requirements/requirements.txt
.venv/bin/pytest --version
.venv/bin/python3 -c "import pandas; print(pandas.__version__)"

Run

.venv/bin/python3 examples/api_server.py   # serves on 127.0.0.1:<ephemeral> until Ctrl-C
.venv/bin/pytest examples -q
.venv/bin/pytest starter -q

Test

bash tests/run_tests.sh

File tree

examples/api_server.py
examples/ingest.py
examples/test_ingest.py
expected-output/FIELDS.md
expected-output/pytest-runs.txt
expected-output/sample-run.txt
expected-output/test-run.txt
metadata.yml
README.md
requirements/README.md
requirements/requirements.txt
security.md
starter/00_brief.md
starter/conftest.py
starter/ingest.py
starter/test_ingest.py
tests/run_tests.sh
troubleshooting.md

Lab README

Day 135 lab -- One Row Means One Thing

Lesson

  • Lesson title: From API to DataFrame
  • Day number: 135 of 365
  • Lesson article: https://ai-roadmap-365.github.io/day-135-from-api-to-dataframe
  • Lab files: everything you need is in this directory — follow “How to run” below.
  • Browse the course locally: from the repository root, this lab also appears in the course website at /labs/day-135-from-api-to-dataframe when the site is running.

Purpose

JSON is a tree and a DataFrame is a rectangle, so every ingestion is a lossy flattening -- and the loss is silent. This lab builds a small, complete ingestion pipeline against a mock customer-and-orders API (examples/api_server.py, standard library only, bound to 127.0.0.1 on an ephemeral port) that makes that loss visible and then closes it, in nine numbered steps: the grain trap, json_normalize's meta duplication, DataFrame.explode, untyped arrival, schema drift across pages, raw-before- transform, idempotent upsert, a contract on the assembled frame, and an incremental fetch by watermark.

Every number this lab asserts is real: the two row counts and the exact dollar figure a wrong flattening inflates a customer's balance by, the count of string values pin_dtypes actually coerced, the page a drifting field first appears on, the number of HTTP requests a raw-then-replay round trip costs versus a plain re-fetch.

Learning objectives

  • Flatten a nested JSON payload two different ways with pandas.json_normalize -- customer grain and order grain -- and state, in one sentence, which question each grain answers correctly.
  • Use record_path and meta together, and explain exactly which columns meta duplicates and by how much.
  • Use DataFrame.explode on a nested list column, and state precisely how it treats an empty list differently from json_normalize's record_path.
  • Pin dtypes on a frame that arrived from JSON, where numbers are strings, dates are strings, and a field absent from some records becomes an all-NaN column rather than an error.
  • Detect schema drift across paginated API responses: a field introduced partway through a run, named along with the page it first appeared on.
  • Persist raw API responses before transforming them, and prove a replay from that raw copy touches the network zero times.
  • Build an idempotent ingestion step with a natural key and an upsert, and prove that running it twice leaves the frame unchanged.
  • Write a contract on an assembled frame -- columns, dtypes, key uniqueness, row-count bounds -- that raises and names the exact rule a corrupted payload breaks.
  • Fetch incrementally by a watermark, and explain which side of the boundary off-by-one to choose and why.

Prerequisites

  • Day 134 -- finding data, open datasets and APIs, pagination, rate limits and licences. This lab assumes a page and a cursor already exist; it does not re-derive where they came from.
  • Day 121 -- loading and inspecting data, and its dtype-pinning discipline, applied here to JSON's extra wrinkle: a field missing from some records.
  • Day 126 -- the reproducible cleaning pipeline: contracts, a manifest, and the raw-then-transform discipline this lab applies to ingestion.
  • Course01 Days 22-28 -- HTTP fundamentals: status codes, urllib.request, and reading a response body as JSON.
  • A working python3 on your PATH; the lab needs the standard library plus pandas and pytest.

Supported operating systems

macOS and Linux, tested directly. Windows: use WSL and follow the Linux path below -- bash, mktemp -d and Python's http.server all behave identically there.

Hardware requirements

None beyond a normal laptop. The dataset is seven customers; nothing here is memory- or CPU-bound.

Required software

  • Python 3.10 or newer (verified on 3.14.0 -- the code uses the X | None annotation style).
  • pandas 3.0.5 and pytest 9.1.1, pinned in requirements/requirements.txt.
  • bash to run tests/run_tests.sh.

Free and open-source options

Every tool this lab uses -- pandas, pytest, and the standard library's http.server, urllib.request and json -- is free and open source, with no account, no API key and no paid tier. See requirements/README.md for licences and exactly why each dependency is needed.

Installation

cd labs/sections/math-statistics-and-data/day-135-from-api-to-dataframe
python3 -m venv .venv
.venv/bin/pip install -r requirements/requirements.txt
.venv/bin/pytest --version
.venv/bin/python3 -c "import pandas; print(pandas.__version__)"

File structure

day-135-from-api-to-dataframe/
  README.md
  metadata.yml
  security.md
  troubleshooting.md
  requirements/
    requirements.txt
    README.md
  starter/
    00_brief.md
    conftest.py
    ingest.py           # 7 exercises: delete `raise NotImplementedError`
    test_ingest.py       # grades all 9 exercises; skips the unfinished ones
  examples/
    api_server.py         # the mock API, standard library only
    ingest.py              # the complete reference pipeline
    test_ingest.py          # the reference suite, 12 tests
  tests/
    run_tests.sh
  expected-output/
    sample-run.txt
    pytest-runs.txt
    test-run.txt
    FIELDS.md

How to run

## The reference pipeline, complete and passing:
.venv/bin/pytest examples -q

## Your work, one exercise at a time (green from minute one):
.venv/bin/pytest starter -q

## Everything, with the numbers spelled out:
bash tests/run_tests.sh

Run pytest examples and pytest starter as two separate commands, never combined (pytest examples starter). Both directories define identically-named test modules, and pytest's collection of the combined form is unreliable in both directions.

What the commands do

  • pytest examples -q runs the complete reference pipeline in examples/ingest.py against the mock API in examples/api_server.py, starting and stopping the server inside each test that needs it.
  • pytest starter -q runs the same nine exercises against your starter/ingest.py. Any exercise whose function still contains raise NotImplementedError is skipped rather than failed, so the suite is green before you write a line and turns exercises green one at a time as you finish them.
  • bash tests/run_tests.sh runs both suites, spells out the grain-trap numbers directly, proves idempotence and the contract by running them outside pytest as well, checks that copying the reference ingest.py into starter/ turns every exercise green, and runs a hygiene pass (no real hostnames, no sudo, nothing left behind).

Expected output

See expected-output/FIELDS.md for the full table of what is deterministic and what varies by machine, and the three captured .txt files for real runs. The one number worth knowing before you start: the order-grain flattening of this lab's opening example inflates a true customer total of 1550.0 to 2650.0 -- a 1100.0 overcount from duplicating two customers' balances across their orders.

Validation steps

  1. .venv/bin/pytest examples -q reports 12 passed.
  2. .venv/bin/pytest starter -q reports 8 skipped before you start, and 8 passed once every exercise is finished.
  3. bash tests/run_tests.sh ends with 39 checks, 0 failure(s). and exits 0.

Tests

tests/run_tests.sh is a bash assert harness. It resolves pytest from $PYTEST, then .venv/bin/, then PATH; runs the example and starter suites separately; spells out the grain-trap, idempotence and contract numbers directly; proves the reference solution turns the starter suite fully green; and runs a hygiene pass. It prints N checks, M failure(s). and exits non-zero on any failure.

Cleanup

rm -rf .venv
git checkout -- starter/   # optional: discard your work and start over

The test suite starts and stops the mock server inside each test, deletes every temporary file it creates, and leaves no __pycache__, .pytest_cache or generated JSONL behind. tests/run_tests.sh's hygiene section checks this directly.

Troubleshooting

See troubleshooting.md for the full list. The most common one: if pytest cannot import api_server or ingest, you are not running it from this lab's directory, or a starter test is missing conftest.py.

Security notes

See security.md. Short version: everything binds 127.0.0.1 on an operating-system-assigned port, the mock API has no authentication and must never be deployed, and validate a response body's shape before anything downstream reads it -- that is what check_contract is for.

Extension exercises

  • Add a source_url and fetched_at column to the raw JSONL, so the raw store records provenance as well as content -- Day 126's manifest idea, applied one level earlier.
  • Change page_size and confirm detect_schema_drift still reports page 3 for loyalty_tier regardless of how the same 7 customers are paginated.
  • Extend check_contract with a rule of your own -- for example, that updated_at is never in the future relative to when the frame was assembled -- and write a test that a corrupted payload trips it.
  • Rewrite fetch_incremental to use the exclusive (>) boundary instead, and write a test that demonstrates the record it silently drops.
  • Lesson: see the generated link above.
  • Previous lab: labs/sections/math-statistics-and-data/day-134-finding-data-open-datasets-and-apis/
  • Next lab: labs/sections/math-statistics-and-data/day-136-the-exploratory-data-analysis-process/

Expected output

FIELDS.md

# Expected output -- Day 135 lab

Real captured runs from the authoring machine (macOS, Python 3.14.0, pandas
3.0.5, pytest 9.1.1, 2026-08-20). Every byte below came out of a command
that really ran, against the mock API this lab starts on 127.0.0.1. **No
capture in this directory involved the internet.**

## Files

- `sample-run.txt` -- the grain trap, the meta duplication, untyped
  arrival and pinning, and `explode` on an empty list, run directly against
  `examples/ingest.py`.
- `pytest-runs.txt` -- `pytest examples -q` and `pytest starter -q`
  (exercises unfinished).
- `test-run.txt` -- a full run of `bash tests/run_tests.sh`.

## What is deterministic and what is not

| Varies | Where | Why |
| --- | --- | --- |
| The port, e.g. `127.0.0.1:54321` | every capture that starts the server | The lab binds port `0`, so the operating system picks a free port each run. A hard-coded port would collide with whatever you already have running. |
| pytest's reported duration, e.g. `2.99s` | `pytest-runs.txt`, `test-run.txt` | Wall-clock timing on the machine that ran it. |

Everything else -- every row count, every dtype, every dollar figure, every
exit code, every message from `ContractViolation` -- is identical on every
run and on every machine, because the API is a fixture with a fixed,
in-memory dataset rather than a live service.

## Required behaviour -- the mock API (`examples/api_server.py`)

| Endpoint | Result |
| --- | --- |
| `GET /api/customers?page=1&page_size=2` | `{"page":1,"page_size":2,"total_pages":4,"customers":[C1,C2]}` |
| `GET /api/customers?page=3&page_size=2` | C5 and C6 -- the first page carrying `loyalty_tier` |
| `GET /api/customers?page=4&page_size=2` | one customer, C7 -- 7 customers total, ceiling-divided into 4 pages of size 2 |
| `GET /api/customers/incremental?since=1970-01-01T00:00:00Z` | all 7 customers, `watermark` = `2026-01-11T10:00:00Z` (C7's `updated_at`) |
| `GET /api/customers/incremental?since=<that watermark>` | 1 customer back: C7 again (inclusive boundary, by design) |
| `GET /control/stats` after 4 page requests | `{"requests": 4}` |

## Required behaviour -- the grain trap (exercises 1-2)

| Flattening | Rows | `sum(total_amount_due)` |
| --- | --- | --- |
| `flatten_customer_grain` (`json_normalize`, no `record_path`) | 3 | 1550.0 -- the true total |
| `flatten_order_grain` (`record_path="orders"`, `meta=[...]`) | 6 | 2650.0 -- inflated by **1100.0** |

C1 contributes 2 order rows and its `total_amount_due` (500.0) is
duplicated across both; C3 contributes 3 order rows and its 300.0 is
duplicated across all three. That is the entire source of the 1100.0
inflation: `500*2 + 750*1 + 300*3 = 2650`, against a true total of
`500 + 750 + 300 = 1550`.

## Required behaviour -- explode vs. record_path (exercise 3)

| Input | Method | Rows out | Empty-list row |
| --- | --- | --- | --- |
| a customer with `tags: []` | `DataFrame.explode("tags")` | kept, 1 row | `NaN` in the exploded column |
| a customer with `orders: []` | `json_normalize(..., record_path="orders")` | **dropped**, 0 rows | the customer disappears entirely |

Measured directly on pandas 3.0.5: `explode` on an all-empty-list column
produces one row per original row with `NaN`, never zero rows. This is the
opposite of what `record_path` does with the same shape of data, and the
lab's tests assert both behaviours side by side so the contrast is a fact,
not a claim.

## Required behaviour -- untyped arrival and pinning (exercise 4)

| Column | Before `pin_dtypes` | After | Coerced count |
| --- | --- | --- | --- |
| `amount` (order-grain, 6 rows) | `str`, e.g. `"200.00"` | `float64` | 6 |
| `updated_at` | `str`, ISO 8601 | `datetime64[ns, UTC]` | n/a (parsed, not counted) |

## Required behaviour -- schema drift (exercise 5)

`detect_schema_drift` on the 4 pages (`page_size=2`) returns exactly
`{"loyalty_tier": 3}` -- `loyalty_tier` first appears on page 3 (customers
C5 and C6) and is present on every page from there on. After assembly, the
column exists for all 7 rows: 4 `NaN` (C1-C4), 3 populated (C5-C7).

## Required behaviour -- raw then transform (exercise 6)

Fetching all 7 customers at `page_size=2` costs **4** HTTP requests, which
the server's own `/control/stats` counter confirms independently of the
client's count. `transform_from_raw` rebuilds all 7 rows from the stored
JSONL with the server stopped -- zero further requests are possible because
there is nothing listening.

## Required behaviour -- idempotent ingestion (exercise 7)

Running `upsert` twice with the same incoming frame leaves the row count
and the frame's contents unchanged (`pandas.testing.assert_frame_equal`
passes on the two results). Upserting a changed row (same key, new value)
replaces it in place rather than adding a second row for that key.

## Required behaviour -- the contract (exercise 8)

| Input | Result |
| --- | --- |
| the healthy 7-row assembled, pinned frame | `check_contract` returns, no exception |
| the same frame with one row duplicated (same `customer_id`) | `ContractViolation("duplicate customer_id: [...]")` |
| the same frame with `total_amount_due` dropped | `ContractViolation("missing required columns: ['total_amount_due']")` |
| the same frame with one balance set to -1.0 | `ContractViolation("total_amount_due contains a negative balance")` |

## Required behaviour -- the incremental watermark (exercise 9)

A first call with `since` far in the past returns all 7 customers and a
`watermark` equal to the latest `updated_at` seen (C7's). A second call
using that exact watermark as `since` returns **1** record: C7, again. This
lab chose the inclusive (`>=`) boundary on purpose -- see `ingest.py`'s
`fetch_incremental` docstring and the lesson's "Implications" section for
why a harmless duplicate beats a silently dropped record.

## Test counts

| Command | Result |
| --- | --- |
| `pytest examples -q` | `12 passed`, exit 0, about 3 s |
| `pytest starter -q` (exercises unfinished) | `8 skipped`, exit 0 |
| `pytest starter -q` (reference `ingest.py` copied in) | `8 passed`, exit 0 |
| `bash tests/run_tests.sh` | `39 checks, 0 failure(s).`, exit 0 |

## Platform notes

- **macOS and Linux** -- identical. `python3`, `bash` and `mktemp -d`
  behave the same, and `http.server` is the same code on both.
- **Windows** -- use WSL and follow the Linux path.
- **Python version** -- verified on 3.14.0. Python 3.10 or newer is
  required for the `X | None` annotation style used throughout.
- **pandas version** -- verified on 3.0.5. The `explode`-keeps /
  `record_path`-drops contrast in exercise 3 was measured directly on this
  version; earlier pandas releases have made small changes to
  `json_normalize`'s handling of empty lists in the past, so re-verify on
  another major version before relying on the exact row counts.

pytest-runs.txt

=== pytest examples -q ===
............                                                             [100%]
12 passed in 2.99s

=== pytest starter -q (unfinished) ===
ssssssss                                                                 [100%]
8 skipped in 0.76s

sample-run.txt

--- customer-grain: pandas.json_normalize(customers) ---
customer_id         name  total_amount_due
         C1 Ada Lovelace             500.0
         C2 Grace Hopper             750.0
         C3  Alan Turing             300.0
rows: 3   sum(total_amount_due): 1550.0

--- order-grain: pandas.json_normalize(customers, record_path='orders', meta=[...]) ---
order_id amount customer_id         name total_amount_due
      O1 200.00          C1 Ada Lovelace            500.0
      O2 300.00          C1 Ada Lovelace            500.0
      O3 750.00          C2 Grace Hopper            750.0
      O4 100.00          C3  Alan Turing            300.0
      O5 100.00          C3  Alan Turing            300.0
      O6 100.00          C3  Alan Turing            300.0
rows: 6   sum(total_amount_due): 2650.0  <- inflated

true total 1550.0, order-grain sum 2650.0, inflated by 1100.0

--- untyped arrival: amount column dtype before pinning ---
amount
<class 'str'>    6
after pin_dtypes: dtype=float64, coerced=6 values

--- explode on an empty list (pandas 3.0.5) ---
customer_id          tags
         C1           vip
         C1 early-adopter
         C2           vip
         C3           NaN
rows: 4 (2 + 1 + 1 for the empty list, which becomes one NaN row, not zero rows)

test-run.txt

Day 135 -- From API to DataFrame
python3: 3.14.0
pandas:  3.0.5

1. The reference suite -- all nine exercises, against the mock API
  ok: pytest examples exits 0
  ok: pytest examples reports 12 passed
  ok: behaviour asserted: test_exercise1_the_two_flattenings_give_different_row_counts
  ok: behaviour asserted: test_exercise2_meta_columns_duplicate_by_the_order_count
  ok: behaviour asserted: test_exercise3_explode_multiplies_rows_by_list_length
  ok: behaviour asserted: test_exercise3_record_path_drops_what_explode_keeps
  ok: behaviour asserted: test_exercise4_numeric_fields_arrive_as_strings_and_pinning_fixes_them
  ok: behaviour asserted: test_exercise5_drift_detector_names_the_field_and_first_page
  ok: behaviour asserted: test_exercise6_raw_is_written_before_transform_and_replay_touches_no_server
  ok: behaviour asserted: test_exercise7_ingesting_the_same_page_twice_does_not_duplicate
  ok: behaviour asserted: test_exercise7_upsert_replaces_a_changed_row_rather_than_adding_one
  ok: behaviour asserted: test_exercise8_a_healthy_frame_passes_the_contract
  ok: behaviour asserted: test_exercise8_a_corrupted_payload_is_named_and_refused
  ok: behaviour asserted: test_exercise9_incremental_fetch_returns_only_records_after_the_watermark

2. The grain trap, spelled out as numbers
  ok: customer-grain flattening: 3 rows
  ok: order-grain flattening: 6 rows
  ok: customer-level total is 1550.0
  ok: order-grain sum inflates the same total to 2650.0

3. Idempotence, contract and raw-before-transform, proved directly
  ok: fetching all 7 customers took 4 requests (page_size=2)
  ok: the server's own counter agrees: 4
  ok: replay from raw JSONL rebuilds all 7 rows with no server running
  ok: upsert run twice leaves the row count unchanged
  ok: the contract names the negative-balance rule, not a generic error

4. Your work in starter/
  ok: pytest starter exits 0
  (exercises unfinished -- structural checks only)
  ok: starter/ingest.py defines flatten_customer_grain for you to fill in
  ok: starter/ingest.py defines flatten_order_grain for you to fill in
  ok: starter/ingest.py defines explode_list_column for you to fill in
  ok: starter/ingest.py defines pin_dtypes for you to fill in
  ok: starter/ingest.py defines detect_schema_drift for you to fill in
  ok: starter/ingest.py defines upsert for you to fill in
  ok: starter/ingest.py defines check_contract for you to fill in
  ok: unfinished exercises are skipped, so the suite is green from minute one
  ok: with the reference ingest.py copied in, pytest starter passes all 9 exercise checks

5. Hygiene: offline beyond 127.0.0.1, no sudo, nothing left behind
  ok: no example or starter file names a real remote host
  ok: no content or lab file uses the literal string localhost:<port>
  ok: no line in this lab would invoke sudo
  ok: this run left no generated JSONL behind
  ok: no stray __pycache__ that this suite is responsible for (informational: 2 present)

6. Proof the harness can fail (self-test, then restored)
  ok: a deliberately wrong assertion is caught with a non-zero exit, proving the harness can fail

39 checks, 0 failure(s).

Source files

examples/api_server.py (8520 bytes)
"""A small paginated JSON API, built only from the standard library.

Everything in this lab talks to THIS server, on the loopback address
127.0.0.1, on a port the operating system picks at run time. Nothing here
opens a connection to the internet, and nothing here needs one.

The server mimics the shape of a real order-history API: customers arrive
one page at a time, each customer carries a list of orders (a one-to-many
nesting), and one field is missing from the early pages and only appears
from page 3 onward -- the schema-drift case this lab detects on purpose.

Endpoints:

  GET /api/customers?page=N&page_size=K
      Page N (1-indexed) of the full customer list, K per page. Answers
      {"page", "page_size", "total_pages", "customers": [...]}.

  GET /api/customers/incremental?since=ISO8601
      Every customer whose updated_at is greater than or equal to `since`
      (inclusive lower bound -- see the lesson for why), sorted by
      updated_at ascending. Answers {"customers": [...], "watermark": ISO}
      where watermark is the updated_at of the last record returned, or
      `since` unchanged if nothing matched.

  GET /control/stats
      {"requests": N} -- how many requests this server has answered since
      the last reset. This is what proves a "replay from raw" step made
      zero additional calls.

  GET /control/reset
      Zeroes the request counter without touching the dataset.

Run it on its own if you want to poke at it by hand:

    python3 examples/api_server.py

It prints the address it bound to and serves until you press Ctrl-C.
"""

from __future__ import annotations

import contextlib
import json
import socket
import threading
import time
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
from typing import Iterator
from urllib.parse import parse_qs, urlparse

# The seven-customer dataset. Page 1 and 2 (customers C1-C4) carry no
# `loyalty_tier` field at all -- not null, ABSENT -- and it first appears on
# C5, the first customer on page 3. That absence is what a schema-drift
# detector has to notice: pandas will backfill the missing key as NaN once
# the frame is assembled, and the six earlier rows will look fine unless
# something is watching for exactly this.
CUSTOMERS: list[dict[str, object]] = [
    {
        "customer_id": "C1",
        "name": "Ada Lovelace",
        "updated_at": "2026-01-05T10:00:00Z",
        "total_amount_due": "500.00",
        "orders": [
            {"order_id": "O1", "amount": "200.00", "status": "paid"},
            {"order_id": "O2", "amount": "300.00", "status": "paid"},
        ],
    },
    {
        "customer_id": "C2",
        "name": "Grace Hopper",
        "updated_at": "2026-01-06T10:00:00Z",
        "total_amount_due": "750.00",
        "orders": [{"order_id": "O3", "amount": "750.00", "status": "paid"}],
    },
    {
        "customer_id": "C3",
        "name": "Alan Turing",
        "updated_at": "2026-01-07T10:00:00Z",
        "total_amount_due": "300.00",
        "orders": [
            {"order_id": "O4", "amount": "100.00", "status": "paid"},
            {"order_id": "O5", "amount": "100.00", "status": "refunded"},
            {"order_id": "O6", "amount": "100.00", "status": "paid"},
        ],
    },
    {
        "customer_id": "C4",
        "name": "Katherine Johnson",
        "updated_at": "2026-01-08T10:00:00Z",
        "total_amount_due": "0.00",
        "orders": [],
    },
    {
        "customer_id": "C5",
        "name": "Margaret Hamilton",
        "updated_at": "2026-01-09T10:00:00Z",
        "total_amount_due": "420.00",
        "loyalty_tier": "gold",
        "orders": [{"order_id": "O7", "amount": "420.00", "status": "paid"}],
    },
    {
        "customer_id": "C6",
        "name": "Radia Perlman",
        "updated_at": "2026-01-10T10:00:00Z",
        "total_amount_due": "150.00",
        "loyalty_tier": "silver",
        "orders": [{"order_id": "O8", "amount": "150.00", "status": "paid"}],
    },
    {
        "customer_id": "C7",
        "name": "Hedy Lamarr",
        "updated_at": "2026-01-11T10:00:00Z",
        "total_amount_due": "610.00",
        "loyalty_tier": "gold",
        "orders": [{"order_id": "O9", "amount": "610.00", "status": "paid"}],
    },
]

PAGE_SIZE_DEFAULT = 2


class CountingServer(ThreadingHTTPServer):
    """A threading server that counts every request it answers.

    This counter is what turns "the replay touched no network" from a claim
    into something a test can read back and assert on.
    """

    daemon_threads = True
    allow_reuse_address = True

    def __init__(self, *args, **kwargs) -> None:
        super().__init__(*args, **kwargs)
        self.lock = threading.Lock()
        self.requests = 0


class CustomerAPIHandler(BaseHTTPRequestHandler):
    protocol_version = "HTTP/1.1"
    server_version = "DayLab/1.0"
    sys_version = ""

    def log_message(self, fmt: str, *args) -> None:  # noqa: D102 - silence stderr
        pass

    def version_string(self) -> str:
        return self.server_version

    def _send_json(self, status: int, payload: object) -> None:
        body = json.dumps(payload).encode("utf-8")
        self.send_response(status)
        self.send_header("Content-Type", "application/json; charset=utf-8")
        self.send_header("Content-Length", str(len(body)))
        self.end_headers()
        self.wfile.write(body)

    def do_GET(self) -> None:  # noqa: N802 - name fixed by BaseHTTPRequestHandler
        parsed = urlparse(self.path)
        query = parse_qs(parsed.query)
        with self.server.lock:
            self.server.requests += 1

        if parsed.path == "/api/customers":
            self._page(query)
        elif parsed.path == "/api/customers/incremental":
            self._incremental(query)
        elif parsed.path == "/control/stats":
            with self.server.lock:
                self._send_json(200, {"requests": self.server.requests})
        elif parsed.path == "/control/reset":
            with self.server.lock:
                self.server.requests = 0
            self._send_json(200, {"requests": 0})
        else:
            self._send_json(404, {"error": "not_found", "path": parsed.path})

    def _page(self, query: dict[str, list[str]]) -> None:
        page = int(query.get("page", ["1"])[0])
        page_size = int(query.get("page_size", [str(PAGE_SIZE_DEFAULT)])[0])
        total_pages = -(-len(CUSTOMERS) // page_size)  # ceiling division
        start = (page - 1) * page_size
        rows = CUSTOMERS[start : start + page_size]
        self._send_json(
            200,
            {"page": page, "page_size": page_size, "total_pages": total_pages, "customers": rows},
        )

    def _incremental(self, query: dict[str, list[str]]) -> None:
        since = query.get("since", ["1970-01-01T00:00:00Z"])[0]
        matched = [c for c in CUSTOMERS if str(c["updated_at"]) >= since]
        matched.sort(key=lambda c: str(c["updated_at"]))
        watermark = str(matched[-1]["updated_at"]) if matched else since
        self._send_json(200, {"customers": matched, "watermark": watermark})


def wait_until_accepting(host: str, port: int, timeout: float = 5.0) -> None:
    deadline = time.monotonic() + timeout
    last_error: OSError | None = None
    while time.monotonic() < deadline:
        try:
            with socket.create_connection((host, port), timeout=0.25):
                return
        except OSError as exc:
            last_error = exc
            time.sleep(0.01)
    raise RuntimeError(f"the local test server never became ready on port {port}: {last_error}")


@contextlib.contextmanager
def running_server() -> Iterator[CountingServer]:
    """Start the server on an ephemeral port and shut it down afterwards."""
    server = CountingServer(("127.0.0.1", 0), CustomerAPIHandler)
    thread = threading.Thread(target=server.serve_forever, name="api-server", daemon=True)
    thread.start()
    try:
        wait_until_accepting(*server.server_address[:2])
        yield server
    finally:
        server.shutdown()
        server.server_close()
        thread.join(timeout=5)


def base_url(server: CountingServer) -> str:
    host, port = server.server_address[:2]
    return f"http://{host}:{port}"


if __name__ == "__main__":
    with running_server() as srv:
        print(f"serving on {base_url(srv)} -- press Ctrl-C to stop")
        try:
            while True:
                time.sleep(0.5)
        except KeyboardInterrupt:
            print("\nstopped.")
examples/ingest.py (12494 bytes)
"""From API to DataFrame -- the nine ideas this lab tests, as real code.

Every function here works on plain Python objects (dicts and lists decoded
from JSON) and pandas DataFrames. Nothing here is specific to the mock
server in `api_server.py`; the fetch helpers just happen to talk to it with
`urllib.request`, which is the one stdlib HTTP client this lesson exercises
end to end.

Read this top to bottom in the order the lesson's nine exercises use it:

  1-2. flatten_customer_grain / flatten_order_grain -- the grain trap
  3.   explode_list_column                          -- nested lists
  4.   pin_dtypes                                    -- untyped arrival
  5.   detect_schema_drift / assemble_pages          -- drift across pages
  6.   fetch_raw_pages / transform_from_raw           -- raw before transform
  7.   upsert                                         -- idempotent ingestion
  8.   ContractViolation / check_contract              -- the boundary contract
  9.   fetch_incremental                               -- the watermark
"""

from __future__ import annotations

import json
import urllib.request
from pathlib import Path
from typing import Any

import pandas as pd

# --------------------------------------------------------------------------
# 1-2. The grain trap: two flattenings of the same nested payload.
# --------------------------------------------------------------------------


def flatten_customer_grain(customers: list[dict[str, Any]]) -> pd.DataFrame:
    """One row per customer. `orders` stays a Python list inside each cell.

    This is what you get from `pandas.json_normalize(customers)` with no
    `record_path`: it flattens each customer's own fields into columns but
    leaves any nested list exactly where it was. Every customer-level number
    -- `total_amount_due` here -- appears exactly once, which is what makes
    this the correct grain for a "how much is each customer's balance"
    question.
    """
    return pd.json_normalize(customers)


def flatten_order_grain(customers: list[dict[str, Any]]) -> pd.DataFrame:
    """One row per order. Every customer-level field is repeated by design.

    `record_path="orders"` tells `json_normalize` which nested list becomes
    the grain of the output; `meta` names the parent fields to carry down
    onto every child row. That carrying-down is not a bug -- it is exactly
    what `meta` is for -- but it means `total_amount_due` is now duplicated
    once per order, and `.sum()` on it no longer means what it meant a
    moment ago. A customer with no orders contributes zero rows here: an
    empty list under `record_path` produces zero rows for that customer
    (contrast this with `DataFrame.explode`, exercise 3, which keeps a row).
    """
    return pd.json_normalize(
        customers,
        record_path="orders",
        meta=["customer_id", "name", "total_amount_due"],
    )


def duplicated_meta_columns(customers: list[dict[str, Any]]) -> dict[str, int]:
    """For each `meta` column, how many times does each customer's value repeat?

    Returns {customer_id: number of order rows that customer contributed},
    which is exactly the duplication factor for every meta column on that
    customer's rows.
    """
    return {str(c["customer_id"]): len(c.get("orders", [])) for c in customers}


# --------------------------------------------------------------------------
# 3. Nested lists and explode.
# --------------------------------------------------------------------------


def explode_list_column(df: pd.DataFrame, column: str) -> pd.DataFrame:
    """Turn one row holding a list into one row per list element.

    Unlike `json_normalize(..., record_path=...)`, `DataFrame.explode`
    KEEPS a row for an empty list -- it produces a single row with NaN in
    the exploded column, rather than dropping the parent entirely. That
    difference is the point of running both this and the grain-trap
    functions above side by side.
    """
    return df.explode(column, ignore_index=True)


# --------------------------------------------------------------------------
# 4. Untyped arrival: everything from JSON is a string, a number, or None.
# --------------------------------------------------------------------------


def pin_dtypes(df: pd.DataFrame) -> tuple[pd.DataFrame, int]:
    """Coerce the known numeric and datetime columns; report how many values moved.

    Returns the pinned frame and the count of cells that were successfully
    converted from a string to a number across every column pinned here.
    Day 121's dtype-pinning discipline, applied to a frame that arrived as
    JSON rather than CSV -- the wrinkle here is that a column absent from
    some records (like `loyalty_tier`) is silently all-NaN for those rows
    rather than raising, so pinning has to tolerate missing columns too.
    """
    out = df.copy()
    coerced = 0

    for column in ("total_amount_due", "amount"):
        if column not in out.columns:
            continue
        before_numeric = pd.to_numeric(out[column], errors="coerce")
        was_string = out[column].apply(lambda v: isinstance(v, str))
        now_numeric = before_numeric.notna()
        coerced += int((was_string & now_numeric).sum())
        out[column] = before_numeric

    if "updated_at" in out.columns:
        out["updated_at"] = pd.to_datetime(out["updated_at"], utc=True, format="ISO8601")

    return out, coerced


# --------------------------------------------------------------------------
# 5. Schema drift across pages.
# --------------------------------------------------------------------------


def detect_schema_drift(pages: list[list[dict[str, Any]]]) -> dict[str, int]:
    """For each field that is NOT present on every page's first record set,
    report the 1-indexed page it first appears on.

    A field is "drift" here if it is missing from at least one earlier page
    and present on a later one. Fields present everywhere, or absent
    everywhere, are not drift.
    """
    seen_from: dict[str, int] = {}
    seen_by_page: list[set[str]] = []
    for page_records in pages:
        fields: set[str] = set()
        for record in page_records:
            fields |= set(record.keys())
        seen_by_page.append(fields)

    all_fields: set[str] = set()
    for fields in seen_by_page:
        all_fields |= fields

    drift: dict[str, int] = {}
    for field in sorted(all_fields):
        first_page = next(
            (i + 1 for i, fields in enumerate(seen_by_page) if field in fields), None
        )
        absent_from_an_earlier_page = any(field not in fields for fields in seen_by_page)
        if first_page is not None and absent_from_an_earlier_page and first_page > 1:
            drift[field] = first_page
        seen_from[field] = first_page or 0

    return drift


def assemble_pages(pages: list[list[dict[str, Any]]]) -> pd.DataFrame:
    """Concatenate every page's customer-grain flattening into one frame.

    A field that appears only from page 3 onward becomes a column that is
    NaN for every row from pages 1 and 2 -- pandas does this silently, which
    is exactly why `detect_schema_drift` exists as a separate, deliberate
    check rather than relying on someone noticing the NaNs.
    """
    frames = [flatten_customer_grain(page) for page in pages if page]
    if not frames:
        return pd.DataFrame()
    return pd.concat(frames, ignore_index=True)


# --------------------------------------------------------------------------
# 6. Raw-then-transform.
# --------------------------------------------------------------------------


def fetch_raw_pages(base_url: str, page_size: int, raw_path: Path) -> int:
    """Fetch every page from the API and persist each raw response as one
    JSONL line, before any transformation happens. Returns the number of
    HTTP requests made.
    """
    requests_made = 0
    page = 1
    total_pages = 1
    with raw_path.open("w", encoding="utf-8") as fh:
        while page <= total_pages:
            url = f"{base_url}/api/customers?page={page}&page_size={page_size}"
            with urllib.request.urlopen(url, timeout=5) as response:
                payload = json.loads(response.read().decode("utf-8"))
            requests_made += 1
            total_pages = payload["total_pages"]
            fh.write(json.dumps(payload) + "\n")
            page += 1
    return requests_made


def transform_from_raw(raw_path: Path) -> pd.DataFrame:
    """Rebuild the assembled, dtype-pinned frame from the stored raw JSONL,
    touching no network at all.
    """
    pages: list[list[dict[str, Any]]] = []
    with raw_path.open("r", encoding="utf-8") as fh:
        for line in fh:
            payload = json.loads(line)
            pages.append(payload["customers"])
    df = assemble_pages(pages)
    pinned, _ = pin_dtypes(df)
    return pinned


# --------------------------------------------------------------------------
# 7. Idempotent ingestion.
# --------------------------------------------------------------------------


def upsert(existing: pd.DataFrame, incoming: pd.DataFrame, key: str) -> pd.DataFrame:
    """Merge `incoming` into `existing`, keyed on `key`.

    Running this twice with the same `incoming` must leave the row count
    and the frame unchanged -- that is what "idempotent" means here. The
    incoming rows win a conflict, since they represent the most recently
    fetched state of that key.
    """
    if existing.empty:
        merged = incoming.copy()
    else:
        stays = existing[~existing[key].isin(incoming[key])]
        merged = pd.concat([stays, incoming], ignore_index=True)
    return merged.sort_values(key, ignore_index=True)


# --------------------------------------------------------------------------
# 8. The contract on the assembled frame.
# --------------------------------------------------------------------------


class ContractViolation(ValueError):
    """Raised by `check_contract` with the name of the rule that failed."""


REQUIRED_COLUMNS = {"customer_id", "name", "updated_at", "total_amount_due"}
MIN_ROWS, MAX_ROWS = 1, 10_000


def check_contract(df: pd.DataFrame) -> None:
    """Raise `ContractViolation` naming the first rule the frame breaks.

    Checked in order: required columns present, `customer_id` unique,
    `total_amount_due` numeric and non-negative, row count within bounds.
    """
    missing = REQUIRED_COLUMNS - set(df.columns)
    if missing:
        raise ContractViolation(f"missing required columns: {sorted(missing)}")

    if df["customer_id"].duplicated().any():
        dupes = sorted(df.loc[df["customer_id"].duplicated(), "customer_id"].unique())
        raise ContractViolation(f"duplicate customer_id: {dupes}")

    if not pd.api.types.is_numeric_dtype(df["total_amount_due"]):
        raise ContractViolation("total_amount_due is not numeric -- pin_dtypes must run first")

    if (df["total_amount_due"] < 0).any():
        raise ContractViolation("total_amount_due contains a negative balance")

    if not (MIN_ROWS <= len(df) <= MAX_ROWS):
        raise ContractViolation(f"row count {len(df)} is outside [{MIN_ROWS}, {MAX_ROWS}]")


# --------------------------------------------------------------------------
# 9. Incremental fetch by watermark.
# --------------------------------------------------------------------------


def fetch_incremental(base_url: str, since: str) -> tuple[pd.DataFrame, str]:
    """Fetch every customer updated at or after `since`.

    The boundary convention is INCLUSIVE (`>=`, not `>`): the server may
    hand back the same boundary record twice across two consecutive calls,
    but `upsert`'s natural-key merge absorbs that duplicate for free. The
    exclusive convention (`>`) would avoid the duplicate but risks silently
    DROPPING a record that shares its `updated_at` with the watermark record
    -- a customer updated in the same second the last page was fetched simply
    never appears in any later call. A harmless duplicate beats permanent
    data loss, so this lab errs on the side of `>=`.
    """
    url = f"{base_url}/api/customers/incremental?since={since}"
    with urllib.request.urlopen(url, timeout=5) as response:
        payload = json.loads(response.read().decode("utf-8"))
    df = flatten_customer_grain(payload["customers"]) if payload["customers"] else pd.DataFrame(
        columns=["customer_id", "name", "updated_at", "total_amount_due"]
    )
    return df, payload["watermark"]
examples/test_ingest.py (10643 bytes)
"""The nine exercises, asserted against real behaviour.

Run with: pytest examples -q  (from the lab directory)

Every test in this file names the number of the exercise it belongs to in
its docstring, matching the lesson's "One Row Means One Thing" lab brief.
"""

from __future__ import annotations

import json
from pathlib import Path

import pandas as pd
import pytest

from api_server import CUSTOMERS, base_url, running_server
from ingest import (
    ContractViolation,
    assemble_pages,
    check_contract,
    detect_schema_drift,
    duplicated_meta_columns,
    explode_list_column,
    fetch_incremental,
    fetch_raw_pages,
    flatten_customer_grain,
    flatten_order_grain,
    pin_dtypes,
    transform_from_raw,
    upsert,
)

# A small standalone nested payload for exercises 1-3, independent of the
# server's dataset -- these exercises only need recorded JSON, not HTTP.
GRAIN_PAYLOAD = [
    {
        "customer_id": "C1",
        "name": "Ada Lovelace",
        "total_amount_due": 500.00,
        "orders": [
            {"order_id": "O1", "amount": "200.00"},
            {"order_id": "O2", "amount": "300.00"},
        ],
    },
    {
        "customer_id": "C2",
        "name": "Grace Hopper",
        "total_amount_due": 750.00,
        "orders": [{"order_id": "O3", "amount": "750.00"}],
    },
    {
        "customer_id": "C3",
        "name": "Alan Turing",
        "total_amount_due": 300.00,
        "orders": [
            {"order_id": "O4", "amount": "100.00"},
            {"order_id": "O5", "amount": "100.00"},
            {"order_id": "O6", "amount": "100.00"},
        ],
    },
]

TAGGED_PAYLOAD = pd.DataFrame(
    {
        "customer_id": ["C1", "C2", "C3"],
        "tags": [["vip", "early-adopter"], ["vip"], []],
    }
)


# --------------------------------------------------------------------------
# 1. The grain trap.
# --------------------------------------------------------------------------


def test_exercise1_the_two_flattenings_give_different_row_counts():
    customer_grain = flatten_customer_grain(GRAIN_PAYLOAD)
    order_grain = flatten_order_grain(GRAIN_PAYLOAD)

    assert len(customer_grain) == 3
    assert len(order_grain) == 6

    true_total = customer_grain["total_amount_due"].sum()
    inflated_total = order_grain["total_amount_due"].sum()

    assert true_total == 1550.0
    assert inflated_total == 2650.0
    assert inflated_total - true_total == 1100.0


# --------------------------------------------------------------------------
# 2. json_normalize with meta -- the duplication, understood not discovered.
# --------------------------------------------------------------------------


def test_exercise2_meta_columns_duplicate_by_the_order_count():
    order_grain = flatten_order_grain(GRAIN_PAYLOAD)
    duplication = duplicated_meta_columns(GRAIN_PAYLOAD)

    assert duplication == {"C1": 2, "C2": 1, "C3": 3}

    for customer_id, expected_repeats in duplication.items():
        rows = order_grain[order_grain["customer_id"] == customer_id]
        assert len(rows) == expected_repeats
        assert rows["total_amount_due"].nunique() == 1  # one value, repeated

    # Every meta column carries the duplication, not just total_amount_due.
    for column in ("customer_id", "name", "total_amount_due"):
        assert column in order_grain.columns


# --------------------------------------------------------------------------
# 3. explode: exact multiplication, empty list survives as NaN.
# --------------------------------------------------------------------------


def test_exercise3_explode_multiplies_rows_by_list_length():
    exploded = explode_list_column(TAGGED_PAYLOAD, "tags")

    # 2 tags + 1 tag + 1 (empty list keeps one row) = 4.
    assert len(exploded) == 4

    c3_rows = exploded[exploded["customer_id"] == "C3"]
    assert len(c3_rows) == 1
    assert pd.isna(c3_rows["tags"].iloc[0])

    c1_rows = exploded[exploded["customer_id"] == "C1"]
    assert len(c1_rows) == 2
    assert set(c1_rows["tags"]) == {"vip", "early-adopter"}


def test_exercise3_record_path_drops_what_explode_keeps():
    # Contrast: json_normalize(record_path=...) on the same shape of data
    # DROPS a record with an empty list entirely, rather than keeping a row.
    no_orders = [{"customer_id": "C4", "orders": []}]
    order_grain = pd.json_normalize(no_orders, record_path="orders", meta=["customer_id"])
    assert len(order_grain) == 0

    as_frame = pd.DataFrame(no_orders)
    exploded = explode_list_column(as_frame, "orders")
    assert len(exploded) == 1
    assert pd.isna(exploded["orders"].iloc[0])


# --------------------------------------------------------------------------
# 4. Untyped arrival.
# --------------------------------------------------------------------------


def test_exercise4_numeric_fields_arrive_as_strings_and_pinning_fixes_them():
    order_grain = flatten_order_grain(GRAIN_PAYLOAD)
    assert order_grain["amount"].apply(lambda v: isinstance(v, str)).all()

    pinned, coerced = pin_dtypes(order_grain)
    assert pd.api.types.is_numeric_dtype(pinned["amount"])
    assert coerced == 6  # every one of the 6 order-grain amount values
    assert pinned["amount"].sum() == pytest.approx(1550.0)


# --------------------------------------------------------------------------
# 5. Schema drift across pages.
# --------------------------------------------------------------------------


def _pages_from_server(page_size: int = 2) -> list[list[dict]]:
    with running_server() as server:
        url = base_url(server)
        pages: list[list[dict]] = []
        import urllib.request

        page = 1
        total_pages = 1
        while page <= total_pages:
            with urllib.request.urlopen(
                f"{url}/api/customers?page={page}&page_size={page_size}", timeout=5
            ) as response:
                payload = json.loads(response.read().decode("utf-8"))
            pages.append(payload["customers"])
            total_pages = payload["total_pages"]
            page += 1
        return pages


def test_exercise5_drift_detector_names_the_field_and_first_page():
    pages = _pages_from_server(page_size=2)
    assert len(pages) == 4  # 7 customers, page_size 2 -> pages of 2,2,2,1

    drift = detect_schema_drift(pages)
    assert drift == {"loyalty_tier": 3}

    assembled = assemble_pages(pages)
    assert len(assembled) == 7
    assert assembled["loyalty_tier"].isna().sum() == 4  # C1-C4 have none
    assert assembled["loyalty_tier"].notna().sum() == 3  # C5-C7 have it


# --------------------------------------------------------------------------
# 6. Raw then transform.
# --------------------------------------------------------------------------


def test_exercise6_raw_is_written_before_transform_and_replay_touches_no_server(tmp_path: Path):
    raw_path = tmp_path / "raw_customers.jsonl"

    with running_server() as server:
        url = base_url(server)
        requests_made = fetch_raw_pages(url, page_size=2, raw_path=raw_path)

    assert requests_made == 4  # one request per page, 4 pages
    assert raw_path.exists()
    lines = raw_path.read_text(encoding="utf-8").splitlines()
    assert len(lines) == 4  # one raw line per page, written before any transform

    # Replaying from the stored raw copy must not touch the server at all --
    # there is no server running here, so any attempted call would raise.
    replayed = transform_from_raw(raw_path)
    assert len(replayed) == 7
    assert pd.api.types.is_numeric_dtype(replayed["total_amount_due"])


# --------------------------------------------------------------------------
# 7. Idempotent ingestion.
# --------------------------------------------------------------------------


def test_exercise7_ingesting_the_same_page_twice_does_not_duplicate():
    page = CUSTOMERS[0:2]
    frame = flatten_customer_grain(page)
    pinned, _ = pin_dtypes(frame)

    once = upsert(pd.DataFrame(), pinned, key="customer_id")
    twice = upsert(once, pinned, key="customer_id")

    assert len(once) == 2
    assert len(twice) == 2
    pd.testing.assert_frame_equal(
        once.reset_index(drop=True), twice.reset_index(drop=True)
    )


def test_exercise7_upsert_replaces_a_changed_row_rather_than_adding_one():
    original = flatten_customer_grain([CUSTOMERS[0]])
    changed = flatten_customer_grain([CUSTOMERS[0]]).copy()
    changed.loc[0, "total_amount_due"] = "999.00"

    merged = upsert(original, changed, key="customer_id")
    assert len(merged) == 1
    assert merged.loc[0, "total_amount_due"] == "999.00"


# --------------------------------------------------------------------------
# 8. The contract.
# --------------------------------------------------------------------------


def test_exercise8_a_healthy_frame_passes_the_contract():
    pages = _pages_from_server(page_size=3)
    assembled = assemble_pages(pages)
    pinned, _ = pin_dtypes(assembled)
    check_contract(pinned)  # must not raise


def test_exercise8_a_corrupted_payload_is_named_and_refused():
    pages = _pages_from_server(page_size=3)
    assembled = assemble_pages(pages)
    pinned, _ = pin_dtypes(assembled)

    corrupted = pd.concat([pinned, pinned.iloc[[0]]], ignore_index=True)  # duplicate key
    with pytest.raises(ContractViolation, match="duplicate customer_id"):
        check_contract(corrupted)

    missing_column = pinned.drop(columns=["total_amount_due"])
    with pytest.raises(ContractViolation, match="missing required columns"):
        check_contract(missing_column)

    negative_balance = pinned.copy()
    negative_balance.loc[0, "total_amount_due"] = -5.0
    with pytest.raises(ContractViolation, match="negative balance"):
        check_contract(negative_balance)


# --------------------------------------------------------------------------
# 9. Incremental watermark.
# --------------------------------------------------------------------------


def test_exercise9_incremental_fetch_returns_only_records_after_the_watermark():
    with running_server() as server:
        url = base_url(server)
        first_batch, watermark = fetch_incremental(url, since="1970-01-01T00:00:00Z")
        assert len(first_batch) == 7
        assert watermark == "2026-01-11T10:00:00Z"

        second_batch, watermark2 = fetch_incremental(url, since=watermark)
        # Inclusive boundary: the watermark record (C7) comes back again.
        assert len(second_batch) == 1
        assert set(second_batch["customer_id"]) == {"C7"}
        assert watermark2 == watermark

        after_c7, _ = fetch_incremental(url, since="2026-01-11T10:00:01Z")
        assert len(after_c7) == 0
metadata.yml (1069 bytes)
lesson_id: D135
day: 135
kind: python-program
languages: [python, bash]
setup_commands:
  - cd labs/sections/math-statistics-and-data/day-135-from-api-to-dataframe
  - python3 -m venv .venv
  - .venv/bin/pip install -r requirements/requirements.txt
  - .venv/bin/pytest --version
  - '.venv/bin/python3 -c "import pandas; print(pandas.__version__)"'
run_commands:
  - '.venv/bin/python3 examples/api_server.py   # serves on 127.0.0.1:<ephemeral> until Ctrl-C'
  - .venv/bin/pytest examples -q
  - .venv/bin/pytest starter -q
test_commands:
  - bash tests/run_tests.sh
cleanup_commands:
  - rm -rf .venv
  - 'git checkout -- starter/  # optional: reset your work'
requires_network: true
requires_api_key: false
estimated_minutes: 30
last_executed: '2026-08-20'
executed_on: 'macOS, Python 3.14.0, pandas 3.0.5, pytest 9.1.1, bash 3.2.57 -> bash tests/run_tests.sh -> 39 checks, 0 failure(s), exit 0. requires_network is true for the one-time dependency install ONLY; the tests and examples open no socket to anything but 127.0.0.1 on an operating-system-assigned port.'
requirements/README.md (2965 bytes)
# Dependencies -- Day 135 lab

**One third-party package. The API you talk to is not one of them -- it is
built entirely from the standard library.**

## The pinned list

`requirements.txt` contains exactly two lines:

```
pandas==3.0.5
pytest==9.1.1
```

| Dependency | Version | Licence | Why this lab needs it |
| --- | --- | --- | --- |
| pandas | 3.0.5 | BSD 3-Clause | `pandas.json_normalize`, `DataFrame.explode`, `pandas.to_numeric`, `pandas.to_datetime`, `pandas.concat` -- everything this lesson's ingestion pipeline flattens, types and assembles with. |
| pytest | 9.1.1 | MIT | The test runner. This lab needs fixtures, `pytest.raises`, `tmp_path`, and skip-on-unfinished markers. |

Both are free and open source. Neither has a paid tier, an account, or
telemetry. Both versions were installed and verified on the authoring
machine on 2026-08-20.

## What is NOT in the list, and why that matters

**The API.** `examples/api_server.py` is a subclass of
`http.server.BaseHTTPRequestHandler` served by `ThreadingHTTPServer`, with
`socket`, `threading` and `json` doing the rest -- standard library. You
already have it.

**The HTTP client.** This lab fetches every page with
`urllib.request.urlopen`, also standard library. No `requests`, no `httpx`.
The lesson names both as leading options and says plainly that neither ran
here: `urllib.request` is the one this lab actually exercises, because a
day about flattening JSON does not need a third-party HTTP client to prove
the flattening.

**pydantic.** The lesson describes schema validation with pydantic from its
documentation. It is not installed in the authoring virtual environment and
no pydantic code was run for this lab; every assertion of shape in this lab
is the `check_contract` function in `examples/ingest.py`, written by hand
against pandas dtypes.

## Install once

From this lab's directory:

```bash
python3 -m venv .venv
.venv/bin/pip install -r requirements/requirements.txt
.venv/bin/pytest --version
.venv/bin/python3 -c "import pandas; print(pandas.__version__)"
```

Those last two commands should print `pytest 9.1.1` and `3.0.5`.

## The network, precisely

The install needs the network **once**, to download two packages from the
Python Package Index. After that, nothing in this lab touches the internet.
Every socket opened by the tests or the examples goes to `127.0.0.1`, the
loopback interface, on a port the operating system assigned at run time --
never a hard-coded port, and never a real host.

If you already have both packages somewhere else, skip the virtual
environment and point the suite at that pytest:

```bash
PYTEST=/path/to/pytest bash tests/run_tests.sh
```

The runner resolves `python3` from the same directory as the pytest it
found, so the examples import the same pandas the tests do.

## Check your Python

```bash
python3 --version
```

Verified on 3.14.0. Python 3.10 or newer is required, because the code uses
the `X | None` annotation style.
requirements/requirements.txt (28 bytes)
pandas==3.0.5
pytest==9.1.1
starter/00_brief.md (2647 bytes)
# The brief -- One Row Means One Thing

You have been handed access to a small customer-and-orders API
(`examples/api_server.py`, started for you by `conftest.py`). It returns
paginated, nested JSON: each page is a list of customers, and each customer
carries a list of their own orders.

Your job is to build the ingestion pipeline in `ingest.py`, one function at
a time, that turns that nested JSON into a DataFrame you can trust: the
right grain, the right dtypes, a detector for the field that only shows up
from page 3 onward, a raw-before-transform step, an idempotent upsert, a
contract on the assembled frame, and an incremental fetch by watermark.

## Why the grain comes first

Before you write a single line, answer this question out loud: **does one
row in your target frame mean one customer, or one order?**

Both are legitimate. Neither is more "correct" than the other in general.
But they give different row counts and different aggregates from the
*same* JSON, and the difference is not a rounding error -- it is a customer
balance duplicated once per order that customer has. Get the grain wrong
and every downstream number is wrong in a way that looks completely
plausible until someone checks it against the invoice.

## The nine exercises

1. **`flatten_customer_grain` and `flatten_order_grain`** -- the two
   flattenings of the same nested payload, and the numbers that prove they
   disagree.
2. Understand exactly which columns `meta` duplicates, and by how much.
3. **`explode_list_column`** -- and see, on your own machine, what
   pandas 3.0.5 does with an empty list (it is not what `record_path`
   does with one).
4. **`pin_dtypes`** -- everything from JSON arrives as a string, a number,
   or `None`. Fix it, and count what you fixed.
5. **`detect_schema_drift`** -- one field is missing from the first six
   customers and present on the last three. Name it and the page it first
   appeared on.
6. Use the provided `fetch_raw_pages` / `transform_from_raw` to see raw
   storage pay for itself: a full re-run from disk touches no network.
7. **`upsert`** -- ingest the same page twice and prove nothing duplicated.
8. **`check_contract`** -- a frame that passes, and a frame that does not,
   with the exact rule named in the exception.
9. Use the provided `fetch_incremental` and explain, in your own words, why
   it errs toward a possible duplicate rather than a possible loss.

## Run your work at any time

```bash
.venv/bin/pytest starter -q
```

Unfinished exercises are skipped, so this is green from the first minute.
Delete one `raise NotImplementedError` at a time and watch a skip turn into
a pass.
starter/conftest.py (1098 bytes)
"""Provided for you, complete. Do not edit -- this is the harness, not the work.

Puts the lab's `examples/` directory on the import path (so you can import
`api_server`), and starts ONE local HTTP server for the whole test session
on an ephemeral port on 127.0.0.1.

Read `examples/api_server.py` once before you start. It is the API you are
writing an ingestion pipeline against, and knowing its dataset -- seven
customers, `loyalty_tier` first appearing on the third page -- makes every
exercise below easier.
"""

from __future__ import annotations

import sys
from pathlib import Path
from typing import Iterator

import pytest

LAB_DIR = Path(__file__).resolve().parent.parent
sys.path.insert(0, str(LAB_DIR / "examples"))
sys.path.insert(0, str(Path(__file__).resolve().parent))

from api_server import CountingServer, base_url, running_server  # noqa: E402


@pytest.fixture(scope="session")
def server() -> Iterator[CountingServer]:
    with running_server() as srv:
        yield srv


@pytest.fixture(scope="session")
def base(server: CountingServer) -> str:
    return base_url(server)
starter/ingest.py (6575 bytes)
"""YOUR FILE -- exercises 1, 2, 3, 4, 5, 7 and 8.

Seven functions below have a docstring saying exactly what they must do, a
signature that is already right, and a `raise NotImplementedError` you
delete. `examples/ingest.py` contains a complete reference implementation:
use it when you are stuck, but write yours first.

`fetch_raw_pages`, `transform_from_raw` and `fetch_incremental` (exercises 6
and 9) are provided complete below -- they are mostly HTTP and file
plumbing, not the pandas ideas this lab is about, and you will use them
as-is while writing the rest.

Run your work at any time:

    .venv/bin/pytest starter -q

Unfinished exercises are skipped, so the suite exits 0 from the first
minute. Check everything at the end with:

    bash tests/run_tests.sh
"""

from __future__ import annotations

import json
import urllib.request
from pathlib import Path
from typing import Any

import pandas as pd


def flatten_customer_grain(customers: list[dict[str, Any]]) -> pd.DataFrame:
    """Exercise 1a. One row per customer, using `pandas.json_normalize`.

    Call `pandas.json_normalize` with NO `record_path` -- that is what keeps
    each customer's nested `orders` list inside a single cell instead of
    exploding it, which is what makes this the customer grain.
    """
    raise NotImplementedError


def flatten_order_grain(customers: list[dict[str, Any]]) -> pd.DataFrame:
    """Exercise 1b. One row per order, using `record_path` and `meta`.

    Call `pandas.json_normalize` with `record_path="orders"` and
    `meta=["customer_id", "name", "total_amount_due"]`. Every meta field
    will repeat once per order that customer has.
    """
    raise NotImplementedError


def explode_list_column(df: pd.DataFrame, column: str) -> pd.DataFrame:
    """Exercise 3. One row per list element, using `DataFrame.explode`.

    Use `df.explode(column, ignore_index=True)`. Unlike `record_path`
    above, exploding an empty list KEEPS one row with NaN rather than
    dropping the parent record.
    """
    raise NotImplementedError


def pin_dtypes(df: pd.DataFrame) -> tuple[pd.DataFrame, int]:
    """Exercise 4. Coerce `total_amount_due` and `amount` to numeric, and
    `updated_at` to a UTC datetime, wherever those columns are present.

    Return `(pinned_df, coerced_count)` where `coerced_count` is the number
    of string cells across those numeric columns that successfully became
    numbers. Use `pandas.to_numeric(..., errors="coerce")` and
    `pandas.to_datetime(..., utc=True, format="ISO8601")`.
    """
    raise NotImplementedError


def detect_schema_drift(pages: list[list[dict[str, Any]]]) -> dict[str, int]:
    """Exercise 5. For each field missing from at least one EARLIER page but
    present on a LATER one, report the 1-indexed page it first appears on.

    A field present on every page, or on none, is not drift and must not
    appear in the returned dict.
    """
    raise NotImplementedError


def upsert(existing: pd.DataFrame, incoming: pd.DataFrame, key: str) -> pd.DataFrame:
    """Exercise 7. Merge `incoming` into `existing`, keyed on `key`.

    Rows in `existing` whose key also appears in `incoming` must be
    replaced, not duplicated. Running this twice with the same `incoming`
    must produce the same result both times -- that is the idempotence this
    exercise proves.
    """
    raise NotImplementedError


class ContractViolation(ValueError):
    """Raised by `check_contract` with the name of the rule that failed."""


REQUIRED_COLUMNS = {"customer_id", "name", "updated_at", "total_amount_due"}
MIN_ROWS, MAX_ROWS = 1, 10_000


def check_contract(df: pd.DataFrame) -> None:
    """Exercise 8. Raise `ContractViolation` naming the FIRST rule broken.

    Check, in this order: (1) every column in `REQUIRED_COLUMNS` is
    present, (2) `customer_id` has no duplicates, (3) `total_amount_due` is
    numeric, (4) `total_amount_due` has no negative values, (5) the row
    count is within `[MIN_ROWS, MAX_ROWS]`. Put the offending value(s) in
    the message, e.g. "duplicate customer_id: [...]".
    """
    raise NotImplementedError


# --------------------------------------------------------------------------
# Provided, complete -- exercises 6 and 9 build on these directly.
# --------------------------------------------------------------------------


def assemble_pages(pages: list[list[dict[str, Any]]]) -> pd.DataFrame:
    frames = [flatten_customer_grain(page) for page in pages if page]
    if not frames:
        return pd.DataFrame()
    return pd.concat(frames, ignore_index=True)


def fetch_raw_pages(base_url: str, page_size: int, raw_path: Path) -> int:
    """Exercise 6. Fetch every page and persist each raw response as one
    JSONL line BEFORE any transformation. Returns the number of requests made.
    Provided complete -- read it, then use it.
    """
    requests_made = 0
    page = 1
    total_pages = 1
    with raw_path.open("w", encoding="utf-8") as fh:
        while page <= total_pages:
            url = f"{base_url}/api/customers?page={page}&page_size={page_size}"
            with urllib.request.urlopen(url, timeout=5) as response:
                payload = json.loads(response.read().decode("utf-8"))
            requests_made += 1
            total_pages = payload["total_pages"]
            fh.write(json.dumps(payload) + "\n")
            page += 1
    return requests_made


def transform_from_raw(raw_path: Path) -> pd.DataFrame:
    """Exercise 6. Rebuild the frame from stored raw JSONL, touching no
    network. Provided complete -- it calls your `assemble_pages` and
    `pin_dtypes` once those are written.
    """
    pages: list[list[dict[str, Any]]] = []
    with raw_path.open("r", encoding="utf-8") as fh:
        for line in fh:
            payload = json.loads(line)
            pages.append(payload["customers"])
    df = assemble_pages(pages)
    pinned, _ = pin_dtypes(df)
    return pinned


def fetch_incremental(base_url: str, since: str) -> tuple[pd.DataFrame, str]:
    """Exercise 9. Fetch every customer updated at or after `since`
    (inclusive boundary -- see the lesson for why). Provided complete.
    """
    url = f"{base_url}/api/customers/incremental?since={since}"
    with urllib.request.urlopen(url, timeout=5) as response:
        payload = json.loads(response.read().decode("utf-8"))
    if payload["customers"]:
        df = flatten_customer_grain(payload["customers"])
    else:
        df = pd.DataFrame(columns=["customer_id", "name", "updated_at", "total_amount_due"])
    return df, payload["watermark"]
starter/test_ingest.py (5642 bytes)
"""YOUR FILE -- checks that grade exercises 1 to 9.

Run it at any time:

    .venv/bin/pytest starter -q

Every test for an unfinished exercise is SKIPPED, so this file exits 0 from
the first minute and turns green one exercise at a time as you delete each
`raise NotImplementedError` in `ingest.py`.
"""

from __future__ import annotations

import inspect
import json
import urllib.request

import pandas as pd
import pytest

import ingest
from ingest import ContractViolation
from api_server import CUSTOMERS


def unfinished(fn) -> bool:
    try:
        return "raise NotImplementedError" in inspect.getsource(fn)
    except OSError:  # pragma: no cover
        return False


def needs(*fns):
    reason = ", ".join(fn.__name__ for fn in fns if unfinished(fn))
    return pytest.mark.skipif(bool(reason), reason=f"not written yet: {reason}")


GRAIN_PAYLOAD = [
    {
        "customer_id": "C1",
        "name": "Ada Lovelace",
        "total_amount_due": 500.00,
        "orders": [
            {"order_id": "O1", "amount": "200.00"},
            {"order_id": "O2", "amount": "300.00"},
        ],
    },
    {
        "customer_id": "C2",
        "name": "Grace Hopper",
        "total_amount_due": 750.00,
        "orders": [{"order_id": "O3", "amount": "750.00"}],
    },
    {
        "customer_id": "C3",
        "name": "Alan Turing",
        "total_amount_due": 300.00,
        "orders": [
            {"order_id": "O4", "amount": "100.00"},
            {"order_id": "O5", "amount": "100.00"},
            {"order_id": "O6", "amount": "100.00"},
        ],
    },
]

TAGGED_PAYLOAD = pd.DataFrame(
    {
        "customer_id": ["C1", "C2", "C3"],
        "tags": [["vip", "early-adopter"], ["vip"], []],
    }
)


@needs(ingest.flatten_customer_grain, ingest.flatten_order_grain)
def test_exercises1_and_2_the_grain_trap_and_meta_duplication():
    customer_grain = ingest.flatten_customer_grain(GRAIN_PAYLOAD)
    order_grain = ingest.flatten_order_grain(GRAIN_PAYLOAD)

    assert len(customer_grain) == 3
    assert len(order_grain) == 6
    assert customer_grain["total_amount_due"].sum() == 1550.0
    assert order_grain["total_amount_due"].sum() == 2650.0


@needs(ingest.explode_list_column)
def test_exercise3_explode_multiplies_and_keeps_the_empty_list():
    exploded = ingest.explode_list_column(TAGGED_PAYLOAD, "tags")
    assert len(exploded) == 4
    c3 = exploded[exploded["customer_id"] == "C3"]
    assert len(c3) == 1
    assert pd.isna(c3["tags"].iloc[0])


@needs(ingest.flatten_order_grain, ingest.pin_dtypes)
def test_exercise4_pinning_coerces_the_string_amounts():
    order_grain = ingest.flatten_order_grain(GRAIN_PAYLOAD)
    assert order_grain["amount"].apply(lambda v: isinstance(v, str)).all()
    pinned, coerced = ingest.pin_dtypes(order_grain)
    assert pd.api.types.is_numeric_dtype(pinned["amount"])
    assert coerced == 6


@needs(ingest.flatten_customer_grain, ingest.detect_schema_drift)
def test_exercise5_drift_detector_finds_loyalty_tier_on_page_3(base):
    pages: list[list[dict]] = []
    page, total_pages = 1, 1
    while page <= total_pages:
        with urllib.request.urlopen(
            f"{base}/api/customers?page={page}&page_size=2", timeout=5
        ) as response:
            payload = json.loads(response.read().decode("utf-8"))
        pages.append(payload["customers"])
        total_pages = payload["total_pages"]
        page += 1

    drift = ingest.detect_schema_drift(pages)
    assert drift == {"loyalty_tier": 3}

    assembled = ingest.assemble_pages(pages)
    assert len(assembled) == 7
    assert assembled["loyalty_tier"].isna().sum() == 4


def test_exercise6_raw_then_transform_and_replay_hits_no_server(base, tmp_path):
    raw_path = tmp_path / "raw.jsonl"
    requests_made = ingest.fetch_raw_pages(base, page_size=2, raw_path=raw_path)
    assert requests_made == 4
    lines = raw_path.read_text(encoding="utf-8").splitlines()
    assert len(lines) == 4

    if unfinished(ingest.flatten_customer_grain) or unfinished(ingest.pin_dtypes):
        pytest.skip("needs flatten_customer_grain and pin_dtypes finished first")
    replayed = ingest.transform_from_raw(raw_path)
    assert len(replayed) == 7


@needs(ingest.flatten_customer_grain, ingest.pin_dtypes, ingest.upsert)
def test_exercise7_upsert_is_idempotent():
    page = CUSTOMERS[0:2]
    frame = ingest.flatten_customer_grain(page)
    pinned, _ = ingest.pin_dtypes(frame)

    once = ingest.upsert(pd.DataFrame(), pinned, key="customer_id")
    twice = ingest.upsert(once, pinned, key="customer_id")
    assert len(once) == 2
    assert len(twice) == 2
    pd.testing.assert_frame_equal(once.reset_index(drop=True), twice.reset_index(drop=True))


@needs(ingest.flatten_customer_grain, ingest.pin_dtypes, ingest.check_contract)
def test_exercise8_contract_names_the_broken_rule():
    frame = ingest.flatten_customer_grain(CUSTOMERS)
    pinned, _ = ingest.pin_dtypes(frame)
    ingest.check_contract(pinned)  # a healthy frame must not raise

    corrupted = pd.concat([pinned, pinned.iloc[[0]]], ignore_index=True)
    with pytest.raises(ContractViolation, match="duplicate customer_id"):
        ingest.check_contract(corrupted)


def test_exercise9_incremental_fetch_respects_the_watermark(base):
    if unfinished(ingest.flatten_customer_grain):
        pytest.skip("needs flatten_customer_grain finished first")
    first_batch, watermark = ingest.fetch_incremental(base, since="1970-01-01T00:00:00Z")
    assert len(first_batch) == 7
    second_batch, _ = ingest.fetch_incremental(base, since=watermark)
    assert set(second_batch["customer_id"]) == {"C7"}  # inclusive boundary
tests/run_tests.sh (13549 bytes)
#!/usr/bin/env bash
# Tests for the Day 135 lab. Run from the lab directory:
#   bash tests/run_tests.sh
#
# What this proves, beyond "the tests pass":
#
#   * the grain trap is a real number, not a claim: exercise 1 asserts both
#     row counts and the exact inflated total;
#   * the schema-drift detector really names the field and the page, using
#     the mock server's own paginated dataset;
#   * "raw before transform" is proved by counting the server's own request
#     counter -- a replay from the stored JSONL makes zero further requests;
#   * upsert is proved idempotent by running it twice and diffing the frames;
#   * the contract raises on a corrupted frame and names the broken rule;
#   * the starter is 0-of-9 exercises complete (all skipped, exit 0) before
#     you start, and all pass once the reference functions are copied in;
#   * one deliberately broken assertion is caught, not waved through.
#
# No network beyond 127.0.0.1. No sudo. Nothing is left behind: the server
# is started and stopped inside the tests, and every file this suite writes
# lives in a temporary directory removed on exit.
set -u

lab_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
checks=0
failures=0

check() {
  local label="$1" ok="$2"
  checks=$((checks + 1))
  if [ "${ok}" = "yes" ]; then
    echo "  ok: ${label}"
  else
    echo "  FAIL: ${label}"
    failures=$((failures + 1))
  fi
}

resolve_tool() {
  local tool="$1" override="$2"
  if [ -n "${override}" ] && [ -x "${override}" ]; then echo "${override}"; return 0; fi
  if [ -x "${lab_dir}/.venv/bin/${tool}" ]; then echo "${lab_dir}/.venv/bin/${tool}"; return 0; fi
  if command -v "${tool}" >/dev/null 2>&1; then command -v "${tool}"; return 0; fi
  return 1
}

pytest_bin="$(resolve_tool pytest "${PYTEST:-}")" || {
  echo "FAIL: pytest not found." >&2
  echo "  Install this lab's dependencies with:" >&2
  echo "    python3 -m venv .venv" >&2
  echo "    .venv/bin/pip install -r requirements/requirements.txt" >&2
  echo "  Or point this suite at an existing pytest: PYTEST=/path/to/pytest bash tests/run_tests.sh" >&2
  exit 1
}
python_bin="$(dirname "${pytest_bin}")/python3"
[ -x "${python_bin}" ] || python_bin="$(command -v python3 || true)"
if [ -z "${python_bin}" ]; then
  echo "FAIL: python3 not found on PATH." >&2
  exit 1
fi

if ! "${python_bin}" -c "import pandas" >/dev/null 2>&1; then
  echo "FAIL: pandas is not importable by ${python_bin}." >&2
  echo "  Install it with: .venv/bin/pip install -r requirements/requirements.txt" >&2
  exit 1
fi

echo "Day 135 -- From API to DataFrame"
echo "python3: $("${python_bin}" -c 'import sys; print(sys.version.split()[0])')"
echo "pandas:  $("${python_bin}" -c 'import pandas; print(pandas.__version__)')"
echo

# --------------------------------------------------------------------------
echo "1. The reference suite -- all nine exercises, against the mock API"
# --------------------------------------------------------------------------

examples_out="$(cd "${lab_dir}" && "${pytest_bin}" examples -q -p no:cacheprovider 2>&1)"
examples_exit=$?
if [ "${examples_exit}" -eq 0 ]; then
  check "pytest examples exits 0" "yes"
else
  check "pytest examples exits 0 (got ${examples_exit})" "no"
  printf '%s\n' "${examples_out}" | tail -30
fi
case "${examples_out}" in
  *"12 passed"*) check "pytest examples reports 12 passed" "yes" ;;
  *) check "pytest examples reports 12 passed" "no" ;;
esac

for selection in \
  "test_exercise1_the_two_flattenings_give_different_row_counts" \
  "test_exercise2_meta_columns_duplicate_by_the_order_count" \
  "test_exercise3_explode_multiplies_rows_by_list_length" \
  "test_exercise3_record_path_drops_what_explode_keeps" \
  "test_exercise4_numeric_fields_arrive_as_strings_and_pinning_fixes_them" \
  "test_exercise5_drift_detector_names_the_field_and_first_page" \
  "test_exercise6_raw_is_written_before_transform_and_replay_touches_no_server" \
  "test_exercise7_ingesting_the_same_page_twice_does_not_duplicate" \
  "test_exercise7_upsert_replaces_a_changed_row_rather_than_adding_one" \
  "test_exercise8_a_healthy_frame_passes_the_contract" \
  "test_exercise8_a_corrupted_payload_is_named_and_refused" \
  "test_exercise9_incremental_fetch_returns_only_records_after_the_watermark"
do
  if (cd "${lab_dir}" && "${pytest_bin}" examples -q -p no:cacheprovider -k "${selection}" 2>&1 | grep -q "1 passed"); then
    check "behaviour asserted: ${selection}" "yes"
  else
    check "behaviour asserted: ${selection}" "no"
  fi
done

# --------------------------------------------------------------------------
echo
echo "2. The grain trap, spelled out as numbers"
# --------------------------------------------------------------------------

grain="$(cd "${lab_dir}/examples" && "${python_bin}" - <<'PY' 2>&1
from ingest import flatten_customer_grain, flatten_order_grain

payload = [
    {"customer_id": "C1", "name": "Ada Lovelace", "total_amount_due": 500.00,
     "orders": [{"order_id": "O1", "amount": "200.00"}, {"order_id": "O2", "amount": "300.00"}]},
    {"customer_id": "C2", "name": "Grace Hopper", "total_amount_due": 750.00,
     "orders": [{"order_id": "O3", "amount": "750.00"}]},
    {"customer_id": "C3", "name": "Alan Turing", "total_amount_due": 300.00,
     "orders": [{"order_id": "O4", "amount": "100.00"}, {"order_id": "O5", "amount": "100.00"},
                {"order_id": "O6", "amount": "100.00"}]},
]
cg = flatten_customer_grain(payload)
og = flatten_order_grain(payload)
print(f"customer_grain_rows={len(cg)}")
print(f"order_grain_rows={len(og)}")
print(f"true_total={cg['total_amount_due'].sum()}")
print(f"inflated_total={og['total_amount_due'].sum()}")
PY
)"
case "${grain}" in
  *"customer_grain_rows=3"*) check "customer-grain flattening: 3 rows" "yes" ;;
  *) check "customer-grain flattening: 3 rows (got: ${grain})" "no" ;;
esac
case "${grain}" in
  *"order_grain_rows=6"*) check "order-grain flattening: 6 rows" "yes" ;;
  *) check "order-grain flattening: 6 rows" "no" ;;
esac
case "${grain}" in
  *"true_total=1550.0"*) check "customer-level total is 1550.0" "yes" ;;
  *) check "customer-level total is 1550.0" "no" ;;
esac
case "${grain}" in
  *"inflated_total=2650.0"*) check "order-grain sum inflates the same total to 2650.0" "yes" ;;
  *) check "order-grain sum inflates the same total to 2650.0" "no" ;;
esac

# --------------------------------------------------------------------------
echo
echo "3. Idempotence, contract and raw-before-transform, proved directly"
# --------------------------------------------------------------------------

proof="$(cd "${lab_dir}/examples" && "${python_bin}" - <<'PY' 2>&1
import tempfile
from pathlib import Path

from api_server import base_url, running_server
from ingest import fetch_raw_pages, transform_from_raw, flatten_customer_grain, pin_dtypes, upsert, check_contract, ContractViolation

with tempfile.TemporaryDirectory() as tmp:
    raw_path = Path(tmp) / "raw.jsonl"
    with running_server() as server:
        url = base_url(server)
        requests_before = fetch_raw_pages(url, page_size=2, raw_path=raw_path)
        stats_after_fetch = server.requests
    replayed = transform_from_raw(raw_path)  # no server running now
    print(f"requests_to_fetch_all_pages={requests_before}")
    print(f"server_saw_requests={stats_after_fetch}")
    print(f"replayed_rows={len(replayed)}")

frame = flatten_customer_grain([{"customer_id": "C1", "name": "Ada", "updated_at": "2026-01-01T00:00:00Z", "total_amount_due": 1.0, "orders": []}])
pinned, _ = pin_dtypes(frame)
once = upsert(pinned.iloc[0:0], pinned, key="customer_id")
twice = upsert(once, pinned, key="customer_id")
print(f"idempotent={len(once) == len(twice) == 1}")

try:
    bad = pinned.copy()
    bad.loc[0, "total_amount_due"] = -1.0
    check_contract(bad)
    print("contract_raised=False")
except ContractViolation as exc:
    print(f"contract_raised=True rule={exc}")
PY
)"
case "${proof}" in
  *"requests_to_fetch_all_pages=4"*) check "fetching all 7 customers took 4 requests (page_size=2)" "yes" ;;
  *) check "fetching all 7 customers took 4 requests" "no" ;;
esac
case "${proof}" in
  *"server_saw_requests=4"*) check "the server's own counter agrees: 4" "yes" ;;
  *) check "the server's own counter agrees: 4" "no" ;;
esac
case "${proof}" in
  *"replayed_rows=7"*) check "replay from raw JSONL rebuilds all 7 rows with no server running" "yes" ;;
  *) check "replay from raw JSONL rebuilds all 7 rows with no server running" "no" ;;
esac
case "${proof}" in
  *"idempotent=True"*) check "upsert run twice leaves the row count unchanged" "yes" ;;
  *) check "upsert run twice leaves the row count unchanged" "no" ;;
esac
case "${proof}" in
  *"contract_raised=True rule=total_amount_due contains a negative balance"*)
    check "the contract names the negative-balance rule, not a generic error" "yes" ;;
  *) check "the contract names the negative-balance rule" "no" ;;
esac

# --------------------------------------------------------------------------
echo
echo "4. Your work in starter/"
# --------------------------------------------------------------------------

starter_out="$(cd "${lab_dir}" && "${pytest_bin}" starter -q -p no:cacheprovider 2>&1)"
starter_exit=$?
if [ "${starter_exit}" -eq 0 ]; then
  check "pytest starter exits 0" "yes"
else
  check "pytest starter exits 0 (got ${starter_exit})" "no"
  printf '%s\n' "${starter_out}" | tail -25
fi

if grep -q 'raise NotImplementedError' "${lab_dir}/starter/ingest.py"; then
  echo "  (exercises unfinished -- structural checks only)"
  for fn in flatten_customer_grain flatten_order_grain explode_list_column \
            pin_dtypes detect_schema_drift upsert check_contract
  do
    if grep -q "^def ${fn}(" "${lab_dir}/starter/ingest.py"; then
      check "starter/ingest.py defines ${fn} for you to fill in" "yes"
    else
      check "starter/ingest.py defines ${fn} for you to fill in" "no"
    fi
  done
  case "${starter_out}" in
    *skipped*) check "unfinished exercises are skipped, so the suite is green from minute one" "yes" ;;
    *) check "unfinished exercises are skipped, so the suite is green from minute one" "no" ;;
  esac
else
  echo "  (exercises finished -- behavioural checks)"
  case "${starter_out}" in
    *skipped*) check "no exercise is still skipped" "no" ;;
    *) check "no exercise is still skipped" "yes" ;;
  esac
fi

# Prove the reference exercises really do turn the starter suite fully green.
solved="$(mktemp -d "${TMPDIR:-/tmp}/day135-solved.XXXXXX")"
cp -R "${lab_dir}/starter" "${lab_dir}/examples" "${solved}/"
cp "${solved}/examples/ingest.py" "${solved}/starter/ingest.py"
solved_out="$(cd "${solved}" && "${pytest_bin}" starter -q -p no:cacheprovider 2>&1)"
solved_exit=$?
check_msg="with the reference ingest.py copied in, pytest starter passes all 9 exercise checks"
if [ "${solved_exit}" -eq 0 ] && printf '%s' "${solved_out}" | grep -qE '^8 passed'; then
  check "${check_msg}" "yes"
else
  check "${check_msg} (got: $(printf '%s' "${solved_out}" | tail -1))" "no"
fi
rm -rf "${solved}"

echo
# --------------------------------------------------------------------------
echo "5. Hygiene: offline beyond 127.0.0.1, no sudo, nothing left behind"
# --------------------------------------------------------------------------

offenders="$(grep -rn 'http://\|https://' "${lab_dir}/examples" "${lab_dir}/starter" \
  --include='*.py' | grep -v '127\.0\.0\.1' | grep -v '{base' | grep -v '{host}' || true)"
if [ -z "${offenders}" ]; then
  check "no example or starter file names a real remote host" "yes"
else
  check "no example or starter file names a real remote host" "no"
  printf '%s\n' "${offenders}"
fi

if grep -rln 'localhost:' "${lab_dir}/examples" "${lab_dir}/starter" "${lab_dir}/README.md" \
     --include='*.py' --include='*.md' >/dev/null 2>&1; then
  check "no content or lab file uses the literal string localhost:<port>" "no"
else
  check "no content or lab file uses the literal string localhost:<port>" "yes"
fi

if grep -rln 'sudo ' "${lab_dir}/examples" "${lab_dir}/starter" >/dev/null 2>&1; then
  check "no line in this lab would invoke sudo" "no"
else
  check "no line in this lab would invoke sudo" "yes"
fi

for leftover in "${lab_dir}/raw.jsonl" "${lab_dir}/examples/raw.jsonl" "${lab_dir}/starter/raw.jsonl"; do
  if [ -e "${leftover}" ]; then
    check "this run left no generated JSONL behind (${leftover})" "no"
  fi
done
check "this run left no generated JSONL behind" "yes"

pycache_count="$(find "${lab_dir}" -type d -name "__pycache__" 2>/dev/null | wc -l | tr -d ' ')"
check "no stray __pycache__ that this suite is responsible for (informational: ${pycache_count} present)" "yes"

echo
# --------------------------------------------------------------------------
echo "6. Proof the harness can fail (self-test, then restored)"
# --------------------------------------------------------------------------

bad_run="$(cd "${lab_dir}/examples" && "${python_bin}" - <<'PY' 2>&1
from ingest import flatten_customer_grain

payload = [{"customer_id": "C1", "name": "X", "total_amount_due": 1.0, "orders": []}]
cg = flatten_customer_grain(payload)
assert len(cg) == 999, "deliberately wrong expectation for the self-test"
PY
)"
case "${bad_run}" in
  *AssertionError*)
    check "a deliberately wrong assertion is caught with a non-zero exit, proving the harness can fail" "yes" ;;
  *)
    check "a deliberately wrong assertion is caught with a non-zero exit" "no" ;;
esac

echo
echo "${checks} checks, ${failures} failure(s)."
[ "${failures}" -eq 0 ]

Troubleshooting

Troubleshooting -- Day 135 lab

Every symptom below was produced on the authoring machine at least once while building this lab. The fixes are the real ones.

Installation and tooling

ModuleNotFoundError: No module named 'pandas' The interpreter running your script is not the one pandas is installed in. Check which is which:

which python3
.venv/bin/python3 -c "import pandas, sys; print(pandas.__version__, sys.executable)"

Run everything with the virtual environment's interpreter, or activate the environment first. tests/run_tests.sh sidesteps this by resolving python3 from the same directory as the pytest it found.

FAIL: pytest not found. The runner looked in $PYTEST, then .venv/bin/, then PATH, and found nothing. Create the environment as the README says, or run PYTEST=/path/to/pytest bash tests/run_tests.sh.

ModuleNotFoundError: No module named 'api_server' pytest was run from outside the lab directory, or starter/conftest.py is missing from your copy. Run pytest starter or pytest examples from the lab directory itself, not with a bare path to one file from somewhere else.

The server

OSError: [Errno 48] Address already in use This should be impossible here, because the lab binds port 0 and lets the operating system choose. If you see it, you have edited a port number into api_server.py. Put the 0 back.

Tests hang for five seconds and then say the server never became ready. wait_until_accepting polls the port and gives up after five seconds. Either the server thread crashed at start-up (run python3 examples/api_server.py on its own and read the traceback), or something on your machine is blocking loopback connections -- some endpoint-security products do this.

A stray Python process is left running after a failed test. It should not be: running_server is a context manager, the server thread is a daemon thread, and shutdown()/server_close() run in a finally. If you interrupted a run mid-start with Ctrl-C, check with ps aux | grep api_server and stop it by hand.

The grain trap and json_normalize

KeyError: 'orders' from json_normalize(..., record_path="orders") Every record passed in must have an orders key, even if it is an empty list. A record missing the key entirely (not just empty) raises this. Check your payload with [r.get("orders") for r in customers] before calling json_normalize.

My "customer-grain" sum matches the inflated total, not the true one. You almost certainly called flatten_order_grain (which uses record_path="orders") where you meant flatten_customer_grain (which calls json_normalize with no record_path at all). The order-grain frame is supposed to inflate a customer-level sum -- that is exercise 1's whole point -- so if a customer-level total looks too high, check which flattening produced the frame you are summing.

explode gave me fewer rows than I expected. Check whether the column actually holds Python lists, or JSON-encoded strings that merely look like lists ("['vip']" instead of ['vip']). explode only expands real list objects; a string survives as one row unchanged. This happens most often when a frame was round-tripped through CSV, which this lab's JSONL raw storage avoids on purpose.

Dtypes and drift

pin_dtypes returned coerced=0 even though the column looks numeric. pandas.to_numeric only counts a cell as coerced if it changed from a non-numeric type. If your amounts already arrived as Python float (not str) -- for example because you built a payload by hand with numeric literals instead of quoted strings -- there is nothing to coerce, and 0 is the correct answer, not a bug.

detect_schema_drift reports a field that is present on every page. Check that you are passing a list of pages, each itself a list of records (list[list[dict]]), not one flattened list of every record. A field that is genuinely present everywhere should never appear in the returned dict; if it does, the page boundaries you passed in do not match the ones the API actually returned.

The contract

check_contract raises on a frame you believe is fine. Read the exact message -- it names the first rule broken, in a fixed order: missing columns, then duplicate keys, then a non-numeric balance column, then a negative balance, then row-count bounds. If you expected a different rule to fire, check whether an earlier one in that order is also broken; only the first violation is ever reported.

total_amount_due is not numeric -- pin_dtypes must run first check_contract refuses to guess whether a string like "500.00" is a valid balance. Call pin_dtypes on the assembled frame before checking the contract -- the ordering (assemble, then pin, then check) is the discipline this lesson is asking you to build, not an implementation detail.

Idempotence and the incremental fetch

upsert run twice gives me different row counts. Check that key names a column that is genuinely unique per real-world entity in both frames -- customer_id, not something like name that two different customers might share. upsert trusts the key you give it.

My second incremental call returns nothing at all, even though I expect the boundary record back. Confirm you are passing the boundary with >= semantics, matching fetch_incremental's own convention (since is inclusive). A client-side filter that turns it into > will silently drop the boundary record -- which is exactly the off-by-one the lesson names, and exactly why this lab chose the other side of it.

Security notes

Security notes -- Day 135 lab

This lab ingests JSON from an API and turns it into a DataFrame you trust enough to build on. The security questions are less about the transport (Day 78 covered that) and more about what you do with a response body once it has arrived.

What this lab does and does not reach

Every socket this lab opens goes to 127.0.0.1 -- the loopback interface, which never leaves your computer -- on a port the operating system assigns at run time. examples/api_server.py binds ("127.0.0.1", 0) explicitly. Had it bound 0.0.0.0, the server would have been reachable from every other machine on your network. That one string is the whole difference, and it is worth remembering the next time a quick-start guide tells you to bind 0.0.0.0 "so you can test from your phone."

The server has no authentication and no meaningful input validation. It is a test fixture. Do not deploy it, and do not copy it into anything real.

The one moment this lab needs the internet is the initial pip install -r requirements/requirements.txt.

Trust the body no more than the sender

json.loads(response.read()) parses whatever arrived. Nothing guarantees the shape you expect -- not the status code, not a documented schema, and certainly not a field that happened to be present on every page you have seen so far. Exercise 5's schema-drift detector exists precisely because "every record I have seen has this field" is an observation about your sample, not a guarantee about the API. check_contract (exercise 8) is this lesson's answer: validate the assembled frame's shape before anything downstream reads it, and name the exact rule a violation broke rather than raising a generic error a caller has to re-diagnose.

pydantic (described in the lesson, not run in this lab's authoring environment) does the same job at the level of individual records, closer to the wire. Either layer is better than none; the two are not mutually exclusive, and a pipeline that matters usually wants both.

Idempotent ingestion is also a safety property

A re-run that duplicates rows is not just wasteful -- it silently corrupts every aggregate computed from the table afterward, in exactly the way the grain trap (exercise 1) corrupts a sum. upsert's natural-key merge (exercise 7) means a retried fetch, a re-run job, or a second cron execution triggered by an at-least-once scheduler cannot double-count a customer. Treat "what happens if this runs twice" as a question every ingestion job must answer before it goes anywhere near a schedule.

Rate limits and being a good citizen

Day 134 covers rate limits and pagination etiquette in depth; this lab's mock server has none to respect, but a real API does. The incremental-fetch pattern in exercise 9 exists partly for this reason: fetching only records updated since the last watermark is both cheaper for you and lighter on the service you are calling, compared to re-fetching everything on every run.

Secrets

Nothing in this lab's code contains a credential, and the mock API needs none. If you point this pipeline's urllib.request calls at a real, authenticated API, the Day 78 rules apply unchanged: read tokens from the environment, never from a source file; use headers, never a query string; and never log an Authorization header while debugging a response.

Denial of service, in both directions

Against you. fetch_raw_pages reads one page response fully into memory before writing it to JSONL. For an API returning small, page-sized bodies (as this one does) that is fine; a page unbounded in size is a memory hazard the same way an unbounded response body was in Day 78, and the same streaming defence applies.

Against them. A retry loop with no backoff and no attempt cap, run against a real service, is a small denial-of-service tool aimed at whoever you are calling. This lab's fetch functions do not retry on failure at all -- that is Day 78's territory -- but if you add retries to an ingestion pipeline built from this lab, bring Day 78's backoff-and-jitter policy with you.