Programming with PythonFiles, Errors, and Object-Oriented Python › Day 65

Hands-on lab — Day 65: CSV and JSON in the Real World

Commands

Setup

cd labs/sections/programming-with-python/day-065-csv-and-json-in-the-real
python3 --version

Run

head -c 3 data/messy_orders.csv | xxd
python3 examples/wrangle.py
python3 examples/csv_field_parser.py
cat out/orders.jsonl
python3 starter/wrangle.py
python3 starter/csv_field_parser.py

Test

bash tests/run_tests.sh

File tree

data/config.json
data/messy_orders.csv
examples/csv_field_parser.py
examples/wrangle.py
expected-output/FIELDS.md
expected-output/sample-run.txt
expected-output/test-run.txt
metadata.yml
README.md
requirements/README.md
security.md
starter/csv_field_parser.py
starter/wrangle.py
tests/run_tests.sh
troubleshooting.md

Lab README

Day 065 lab — Wrangling Messy Data

Lesson

  • Lesson title: CSV and JSON in the Real World
  • Day number: 65 of 365
  • Lesson article: https://ai-roadmap-365.github.io/day-065-csv-and-json-in-the-real
  • 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-065-csv-and-json-in-the-real when the site is running.

Purpose

Day 64 taught you to open a file and read its bytes. Today the bytes have to mean something to a program that did not write them. This lab hands you a deliberately messy CSV — one that carries a byte-order mark, a comma inside a quoted field, a newline inside a quoted field, doubled quotes, an empty field, and a ragged row — plus a small JSON config, and asks you to get every record out intact.

You will first prove that the obvious approach, line.split(','), mangles the file (and, more unsettling, that it mangles one line while still producing the right number of fields). Then you will parse it properly with csv.DictReader, clean it against the config, and round-trip it to JSON, to JSON Lines, and back to a well-formed CSV, asserting at each step that nothing was lost. Finally you will build the quoting state machine from scratch — about fifty lines — and check that it agrees with the standard library on every row, including a European semicolon dialect.

That last step is the point of the day. CSV and JSON stop being magic once you have implemented the rule that makes them work.

Learning objectives

  • Demonstrate concretely why splitting a CSV line on commas is a bug, and recognise the case where it silently produces wrong data.
  • Read a real-world CSV correctly with csv.DictReader, using encoding='utf-8-sig' and newline='', and explain what each does.
  • Handle the ragged row, the empty field, and the missing value honestly rather than by accident.
  • Serialize records to JSON and to JSON Lines, and prove the round trip is lossless with an assertion rather than by eye.
  • Implement the RFC 4180 quoting state machine from first principles and verify it against the standard library.

Prerequisites

  • The Day 65 lesson (read it first — it walks these exact traps).
  • Day 64: opening files with open() and pathlib, reading and writing text, and encodings.
  • Days 57–63: functions, dicts and lists, comprehensions, modules and imports, the standard library, and small-program design.
  • A text editor and a terminal. No experience beyond this course is assumed; classes, exceptions in depth, and type hints are not needed.

Supported operating systems

  • macOS — fully supported (tested on macOS 26.5.1, Apple Silicon, Python 3.14.0).
  • Linux — fully supported (any distribution with Python 3 and bash).
  • Windows — use WSL and follow the Linux path, or substitute python for python3. Windows is where the newline='' argument stops being theoretical: without it you get a blank line between every written record. Every file here passes it, so behaviour is identical everywhere.

Hardware requirements

Any computer that runs Python 3. The input file is 288 bytes; the whole lab reads and writes about two kilobytes. No special memory, disk, or GPU.

Required software

  • python3 (3.8 or newer; tested on 3.14.0).
  • bash for the test runner (preinstalled on macOS and Linux).
  • Standard library only — csv, json, pathlib, and io. Nothing to install. See requirements/README.md.
  • Optional: xxd (or od) to look at the byte-order mark as raw bytes.

Free and open-source options

Everything here is free and open source: Python, bash, and the standard library. No account, API key, network access, or purchase is needed. The csv and json modules are part of Python itself — the same code that pandas and every data tool ultimately sit on top of or reimplement.

Installation

None beyond Python itself. Move into this directory and you are ready:

cd labs/sections/programming-with-python/day-065-csv-and-json-in-the-real
python3 --version   # confirm Python 3.8+ is available

File structure

day-065-csv-and-json-in-the-real/
├── README.md                       ← you are here
├── metadata.yml                    ← machine-readable lab metadata
├── data/
│   ├── messy_orders.csv            ← the messy input (byte-order mark and all)
│   └── config.json                 ← columns, defaults, encoding, output names
├── starter/
│   ├── wrangle.py                  ← YOUR working file (exercises 1–4)
│   └── csv_field_parser.py         ← YOUR working file (exercise 5: the state machine)
├── examples/
│   ├── wrangle.py                  ← complete reference pipeline
│   └── csv_field_parser.py         ← complete reference parser
├── tests/
│   └── run_tests.sh                ← behaviour checks; exits 0 only if all pass
├── expected-output/
│   ├── sample-run.txt              ← real captured session with the reference
│   ├── test-run.txt                ← real captured run of the test suite
│   └── FIELDS.md                   ← required behaviour on every platform
├── requirements/
│   └── README.md                   ← dependency statement (Python 3 only)
├── troubleshooting.md
└── security.md

Running the pipeline also creates out/ with the three generated files. It is safe to delete at any time.

How to run

From this directory:

## 1. Look at the enemy. Three invisible bytes, then a 7-line file of 6 records.
head -c 3 data/messy_orders.csv | xxd
cat data/messy_orders.csv

## 2. See the finished pipeline: naive split failing, csv.DictReader working,
##    cleaning, and three lossless round trips.
python3 examples/wrangle.py

## 3. See the from-scratch quoting state machine agree with the csv module.
python3 examples/csv_field_parser.py

## 4. Inspect what was written.
cat out/orders.jsonl
cat out/orders-clean.csv

## 5. Your task: complete exercises 1-4 in starter/wrangle.py, then run it.
python3 starter/wrangle.py

## 6. Then complete exercise 5 in starter/csv_field_parser.py and run it.
python3 starter/csv_field_parser.py

## 7. Check your work.
bash tests/run_tests.sh

What the commands do

  • head -c 3 data/messy_orders.csv | xxd — prints the first three bytes as hex. You should see efbb bf: the UTF-8 byte-order mark that Excel writes and that silently corrupts your first column name if you decode the file as plain utf-8.
  • python3 examples/wrangle.py — runs the whole reference pipeline in four labelled stages: naive splitting (and its failures), csv.DictReader (and its successes), cleaning against config.json, then writing JSON / JSON Lines / CSV and asserting each reads back equal to what went in. It creates out/.
  • python3 examples/csv_field_parser.py — parses the messy file twice, once with the from-scratch state machine and once with csv.reader, and prints each row plus whether the two agree. It exits 0 only if they do.
  • cat out/orders.jsonl — five lines, one self-contained JSON object each. Notice the newline inside order 1002's notes appears as \n, an escape inside the line, which is exactly why JSON Lines is safe.
  • python3 starter/wrangle.py and python3 starter/csv_field_parser.py — the same programs, driven by the functions you write.
  • bash tests/run_tests.sh — 26 behaviour checks: the input file really has the traps described, the reference handles every one of them, the round trips really are lossless, and your from-scratch parser really agrees with the standard library. Exits 0 only if every check passes.

Expected output

See expected-output/sample-run.txt — a real captured session. The heart of it:

$ python3 examples/wrangle.py
=== 1. naive line.split(',') ===
lines whose field count is not 5: 3
  line 2: 6 fields
  line 4: 2 fields
  line 7: 3 fields
naive row for order 1001: ['1001', '"Ada Lovelace"', '"widget', ' large"', 'Priority shipping', '49.50']
naive row for order 1003: ['1003', '"Alan ""Turing"" Jr."', 'monitor', 'Fragile', '240.00']
(order 1003 has 5 fields, so the count check passes — and the data is still wrong)

=== 2. csv.DictReader ===
records parsed: 5
first key is 'order_id' (byte-order mark stripped): True
order 1002 notes: 'Leave at door.\nRing the bell twice.'
order 1003 customer: 'Alan "Turing" Jr.'
order 1005 (ragged) total: None

and, at the end:

round trip lossless: JSON yes, JSONL yes, CSV yes

Everything is deterministic, so your output will match. expected-output/FIELDS.md lists the required behaviour of every function on every platform.

Validation steps

  1. head -c 3 data/messy_orders.csv | xxd prints efbb bf — the file really does begin with a byte-order mark.
  2. python3 examples/wrangle.py exits 0 and ends with round trip lossless: JSON yes, JSONL yes, CSV yes.
  3. python3 examples/csv_field_parser.py ends with agree on every row: True and exits 0.
  4. wc -l data/messy_orders.csv reports 7 lines while the pipeline reports 5 records — the quoted newline accounts for the difference.
  5. cat out/orders.jsonl shows exactly 5 lines, each a complete JSON object, with order 1002's newline written as the escape \n.
  6. Complete exercises 1–5 in starter/, run both starter programs, and confirm they match the reference output.
  7. Run the tests (next section) — every check must pass.

Tests

bash tests/run_tests.sh

Expected final line while the starter is unfinished: 26 checks, 0 failure(s). Once you complete all five exercises, the suite holds your files to the same strict standard as the reference, giving 34 checks, 0 failure(s). The command exits 0 on success and non-zero on any failure, so it can run in CI. A full captured run is in expected-output/test-run.txt.

Cleanup

The lab writes only into out/ inside this directory:

rm -rf out

To reset your work, restore the starter from git: git checkout -- starter/. The test runner removes its own temporary directory automatically.

Troubleshooting

See troubleshooting.md for the full list: the KeyError caused by a byte-order mark, blank lines between written records, a record split in two by a quoted newline, TypeError: Object of type ... is not JSON serializable, 'NoneType' object has no attribute 'strip' on the ragged row, JSONDecodeError on a blank JSON Lines line, and the three usual reasons a hand-built parser disagrees with the csv module.

Security notes

See security.md. Short version: parsed data is data, never code — use float() and int(), never eval(); json.loads is safe by design while pickle is not. A field can contain a comma, a newline, a quote, or a megabyte of text, so escape properly at every downstream boundary (parameterised SQL, subprocess argument lists, HTML escaping), watch for spreadsheet formula injection in exported fields, and keep real personal data out of version control.

Extension exercises

  1. Sniff the dialect. Write a small script that uses csv.Sniffer to detect the delimiter of a file, then run it on data/messy_orders.csv and on a semicolon-delimited copy you make yourself. Note where the sniffer guesses well and where it needs a bigger sample.
  2. Break the round trip on purpose. Add a record whose total is 0.1 and one whose order_id is a 20-digit number, then re-run the assertions. Explain in a comment what survives, what does not, and why the CSV round trip is the one that needs float() on the way back in.
  3. Stream instead of loading. Rewrite the JSON Lines reader so it yields one record at a time instead of building a list, and describe in a comment what changes for a 10 GB file. This is the reason training and evaluation data ships as JSONL.
  4. Extend the state machine. Add support for a quoting=NONE style backslash escape (\, meaning a literal comma) as a fifth state, and add a test that your version and the csv module still agree on files that use no backslashes at all.
  • Previous day: Day 64 — Reading and Writing Files (labs/sections/programming-with-python/day-064-reading-and-writing-files/).
  • Next day: Day 66 — Exceptions and Error Handling Strategy (labs/sections/programming-with-python/day-066-exceptions-and-error-handling-strategy/, to be written).
  • Week 10 project: the Expense Tracker — an expense tool with CSV import and export, category handling, and monthly summary reports. The import path you build there is exactly the parsing and cleaning you practise here.

Expected output

FIELDS.md

# Expected output — Day 065 lab

These are real captured runs from the authoring machine (macOS 26.5.1,
Apple Silicon, Python 3.14.0, bash 3.2, 2026-07-19). Every step is
deterministic: the same input file produces the same records, the same
byte counts, and the same exit codes on any platform Python 3 runs on.

## Files

- `sample-run.txt` — the reference pipeline driven end to end
  (`python3 examples/wrangle.py`), the from-scratch parser compared
  against the standard library (`python3 examples/csv_field_parser.py`),
  the first three bytes of the input file shown as hex so the byte-order
  mark is visible, and the two generated files printed.
- `test-run.txt` — a full run of `bash tests/run_tests.sh` with the
  starter still unfinished (26 checks, 0 failures, exit 0). Absolute paths
  are shown as `<repo>`; on your machine they are your real path.

## What the input file contains

`data/messy_orders.csv` is 288 bytes and holds **6 records spread over 7
physical lines** — the mismatch is the whole point.

| Trap | Where | What a correct parser must do |
| --- | --- | --- |
| Byte-order mark (`EF BB BF`) | first three bytes | strip it, so the first column is named `order_id`, not `or der_id` |
| Comma inside a quoted field | order 1001, `"widget, large"` | keep it as one field |
| Newline inside a quoted field | order 1002, notes | keep both lines in one field; the record spans two physical lines |
| Doubled quotes | order 1003, `"Alan ""Turing"" Jr."` | collapse `""` to one `"` |
| Empty field | order 1004, notes | produce `''`, not `None` |
| Ragged row (3 fields, not 5) | order 1005 | produce `None` for the two missing keys |

## Required behaviour on every platform

| Call | Result |
| --- | --- |
| `naive_report(text, 5)` | `[(2, 6), (4, 2), (7, 3)]` |
| `naive_split_rows(text)[4][1]` | `'"Alan ""Turing"" Jr."'` — right field count, wrong data |
| `read_rows(path, 'utf-8-sig', ',')` | 5 dicts; `list(rows[0])[0] == 'order_id'` |
| `rows[0]['items']` | `'widget, large'` |
| `rows[1]['notes']` | `'Leave at door.\nRing the bell twice.'` |
| `rows[2]['customer']` | `'Alan "Turing" Jr.'` |
| `rows[4]['notes']`, `rows[4]['total']` | `None`, `None` |
| `clean_rows(rows, config)[4]` | `notes == ''`, `total == 0.0` (a float) |
| `clean_rows(rows, config)[0]['total']` | `49.5` (a float, not the string `'49.50'`) |
| `read_json(write_json(records))` | equal to `records` |
| `read_jsonl(write_jsonl(records))` | equal to `records`, and the file has exactly 5 lines |
| `read_csv_typed(write_csv(records))` | equal to `records` |
| `parse_csv(text)` (from scratch) | equal to `csv.reader` output on all 6 rows |
| `parse_csv('a,"say ""hi""",d\n')` | `[['a', 'say "hi"', 'd']]` |
| `parse_csv('a,b\r\nc,d\r\n')` | `[['a', 'b'], ['c', 'd']]` |

## Generated files

Running `python3 examples/wrangle.py` creates `out/` with three files:

| File | Size | Shape |
| --- | --- | --- |
| `orders.json` | 703 bytes | one indented JSON array of 5 objects |
| `orders.jsonl` | 565 bytes | 5 lines, one compact JSON object each |
| `orders-clean.csv` | 289 bytes | header plus 5 records over 6 physical lines, re-quoted by `DictWriter` |

The JSON Lines file is smaller than the JSON array here purely because it
carries no indentation; the important difference is that it can be read
one line at a time.

## Platform notes

- The only visible difference between platforms is the shell prompt (`$`)
  shown before each command; the program's own output is identical.
- The `xxd` command used to show the byte-order mark is present on macOS
  and most Linux distributions. If yours lacks it, use
  `od -An -tx1 -N3 data/messy_orders.csv`, which prints `ef bb bf`.
- `orders-clean.csv` is written with `newline=''`, so it uses `\r\n`
  record terminators on every platform (that is what RFC 4180 asks for and
  what the `csv` module writes by default). Its byte count is therefore
  the same everywhere. Counting its bytes is instructive: the file holds
  **6 CRLF pairs and exactly one lone LF** — the six record terminators,
  plus the newline that lives *inside* order 1002's quoted notes field and
  was carried through untouched.
- Floating-point values round-trip exactly here because every total in the
  file is representable in binary (`49.5`, `12.0`, `240.0`, `3.25`,
  `0.0`). That is deliberate — a value like `0.1` would still round-trip
  through JSON, but printing it can surprise you.

sample-run.txt

$ python3 examples/wrangle.py
=== 1. naive line.split(',') ===
lines whose field count is not 5: 3
  line 2: 6 fields
  line 4: 2 fields
  line 7: 3 fields
naive row for order 1001: ['1001', '"Ada Lovelace"', '"widget', ' large"', 'Priority shipping', '49.50']
naive row for order 1003: ['1003', '"Alan ""Turing"" Jr."', 'monitor', 'Fragile', '240.00']
(order 1003 has 5 fields, so the count check passes — and the data is still wrong)

=== 2. csv.DictReader ===
records parsed: 5
first key is 'order_id' (byte-order mark stripped): True
order 1002 notes: 'Leave at door.\nRing the bell twice.'
order 1003 customer: 'Alan "Turing" Jr.'
order 1005 (ragged) total: None

=== 3. clean ===
  1001 Ada Lovelace         49.50
  1002 Grace Hopper         12.00
  1003 Alan "Turing" Jr.   240.00
  1004 Katherine Johnson     3.25
  1005 Barbara Liskov        0.00

=== 4. write and round-trip ===
wrote orders.json  (703 bytes)
wrote orders.jsonl (565 bytes, 5 lines)
wrote orders-clean.csv (289 bytes)
round trip lossless: JSON yes, JSONL yes, CSV yes

$ python3 examples/csv_field_parser.py
rows parsed by the from-scratch parser: 6
rows parsed by the csv module:          6
  row 0: same  ['order_id', 'customer', 'items', 'notes', 'total']
  row 1: same  ['1001', 'Ada Lovelace', 'widget, large', 'Priority shipping', '49.50']
  row 2: same  ['1002', 'Grace Hopper', 'cable, 2 m', 'Leave at door.\nRing the bell twice.', '12.00']
  row 3: same  ['1003', 'Alan "Turing" Jr.', 'monitor', 'Fragile', '240.00']
  row 4: same  ['1004', 'Katherine Johnson', 'notebook', '', '3.25']
  row 5: same  ['1005', 'Barbara Liskov', 'pen']
agree on every row: True

$ head -c 3 data/messy_orders.csv | xxd
00000000: efbb bf                                  ...

$ cat out/orders.jsonl
{"order_id": "1001", "customer": "Ada Lovelace", "items": "widget, large", "notes": "Priority shipping", "total": 49.5}
{"order_id": "1002", "customer": "Grace Hopper", "items": "cable, 2 m", "notes": "Leave at door.\nRing the bell twice.", "total": 12.0}
{"order_id": "1003", "customer": "Alan \"Turing\" Jr.", "items": "monitor", "notes": "Fragile", "total": 240.0}
{"order_id": "1004", "customer": "Katherine Johnson", "items": "notebook", "notes": "", "total": 3.25}
{"order_id": "1005", "customer": "Barbara Liskov", "items": "pen", "notes": "", "total": 0.0}

$ cat out/orders-clean.csv
order_id,customer,items,notes,total
1001,Ada Lovelace,"widget, large",Priority shipping,49.5
1002,Grace Hopper,"cable, 2 m","Leave at door.
Ring the bell twice.",12.0
1003,"Alan ""Turing"" Jr.",monitor,Fragile,240.0
1004,Katherine Johnson,notebook,,3.25
1005,Barbara Liskov,pen,,0.0

test-run.txt

$ bash tests/run_tests.sh
Testing the messy input file ...
  ok: messy_orders.csv really starts with a byte-order mark
  ok: messy_orders.csv really contains a newline inside a quoted field
  ok: config.json is valid JSON with the five expected columns
Testing the wrangling pipeline in <repo>/labs/sections/programming-with-python/day-065-csv-and-json-in-the-real/examples ...
  ok: naive splitting breaks three physical lines
  ok: naive splitting also corrupts a line whose field count looks right
  ok: DictReader returns five records and strips the byte-order mark
  ok: the quoted newline, the embedded comma and the doubled quotes survive
  ok: the ragged row comes back with None for its missing fields
  ok: cleaning fills defaults and turns total into a real float
  ok: the JSON round trip is lossless
  ok: the JSON Lines round trip is lossless and is one line per record
  ok: the CSV round trip is lossless once types are restored
Testing the from-scratch quoting state machine in <repo>/labs/sections/programming-with-python/day-065-csv-and-json-in-the-real/examples ...
  ok: the from-scratch parser agrees with the csv module on the messy file
  ok: the from-scratch parser handles embedded commas, newlines and doubled quotes
  ok: the from-scratch parser handles empty fields, no trailing newline and CRLF
  ok: the from-scratch parser agrees with the csv module on a semicolon dialect
Running examples/wrangle.py end to end ...
  ok: examples/wrangle.py runs end to end and reports a lossless round trip
  ok: the written out/orders.jsonl parses line by line as JSON
Testing starter/ ...
  ok: wrangle.py is valid Python
  ok: csv_field_parser.py is valid Python
Note: starter/ still has unfinished exercises — testing structure only.
  ok: starter defines naive_report
  ok: starter defines read_rows
  ok: starter defines clean_row
  ok: starter defines write_jsonl
  ok: starter defines read_jsonl
  ok: starter defines parse_csv

26 checks, 0 failure(s).
exit: 0

Source files

data/config.json (371 bytes)
{
  "input_csv": "data/messy_orders.csv",
  "encoding": "utf-8-sig",
  "delimiter": ",",
  "columns": ["order_id", "customer", "items", "notes", "total"],
  "defaults": {
    "notes": "",
    "total": "0.00"
  },
  "numeric_columns": ["total"],
  "output_dir": "out",
  "output_json": "orders.json",
  "output_jsonl": "orders.jsonl",
  "output_csv": "orders-clean.csv"
}
data/messy_orders.csv (288 bytes)
order_id,customer,items,notes,total
1001,"Ada Lovelace","widget, large",Priority shipping,49.50
1002,"Grace Hopper","cable, 2 m","Leave at door.
Ring the bell twice.",12.00
1003,"Alan ""Turing"" Jr.",monitor,Fragile,240.00
1004,Katherine Johnson,notebook,,3.25
1005,Barbara Liskov,pen
examples/csv_field_parser.py (4321 bytes)
#!/usr/bin/env python3
"""csv_field_parser.py — a CSV parser built from scratch (REFERENCE).

This is the whole of RFC 4180 quoting in about fifty lines: a state
machine that walks the text one character at a time and decides, for each
character, whether it is data, a field boundary, a record boundary, or a
quote that changes the rules.

The four states
---------------
  field-start      between fields; the next character decides everything
  in-field         inside an UNQUOTED field; a comma or newline ends it
  in-quoted        inside a QUOTED field; commas and newlines are DATA
  quote-in-quoted  saw a quote while quoted; the next character says whether
                   that quote was an escaped quote ("") or the closing quote

Run this file directly to prove the parser agrees with the standard
library on the lab's deliberately messy file:

    python3 examples/csv_field_parser.py
"""
import csv
import io
from pathlib import Path

LAB_DIR = Path(__file__).resolve().parent.parent


def parse_csv(text, delimiter=",", quotechar='"'):
    """Parse CSV text into a list of rows, each row a list of field strings.

    Implements RFC 4180 quoting: a field may be wrapped in quotes, in which
    case it may contain the delimiter, newlines, and doubled quotes ("")
    that stand for one literal quote. Carriage returns outside a quoted
    field are ignored so that CRLF and LF files parse the same way.
    """
    rows = []
    row = []
    field = []
    state = "field-start"

    for char in text:
        if state == "field-start":
            if char == quotechar:
                state = "in-quoted"
            elif char == delimiter:
                row.append("")
            elif char == "\n":
                row.append("")
                rows.append(row)
                row = []
            elif char != "\r":
                field.append(char)
                state = "in-field"

        elif state == "in-field":
            if char == delimiter:
                row.append("".join(field))
                field = []
                state = "field-start"
            elif char == "\n":
                row.append("".join(field))
                field = []
                rows.append(row)
                row = []
                state = "field-start"
            elif char != "\r":
                field.append(char)

        elif state == "in-quoted":
            if char == quotechar:
                state = "quote-in-quoted"
            else:
                field.append(char)  # commas and newlines are plain data here

        elif state == "quote-in-quoted":
            if char == quotechar:
                field.append(quotechar)  # "" means one literal quote
                state = "in-quoted"
            elif char == delimiter:
                row.append("".join(field))
                field = []
                state = "field-start"
            elif char == "\n":
                row.append("".join(field))
                field = []
                rows.append(row)
                row = []
                state = "field-start"
            elif char != "\r":
                field.append(char)
                state = "in-field"

    if field or row or state != "field-start":
        row.append("".join(field))
        rows.append(row)
    return rows


def parse_with_stdlib(text, delimiter=","):
    """Parse the same text with the standard library, for comparison."""
    return [list(row) for row in csv.reader(io.StringIO(text, newline=""), delimiter=delimiter)]


def compare(text, delimiter=","):
    """Return (mine, theirs, agree) for one blob of CSV text."""
    mine = parse_csv(text, delimiter=delimiter)
    theirs = parse_with_stdlib(text, delimiter=delimiter)
    return mine, theirs, mine == theirs


def main():
    text = (LAB_DIR / "data" / "messy_orders.csv").read_text(encoding="utf-8-sig")
    mine, theirs, agree = compare(text)
    print(f"rows parsed by the from-scratch parser: {len(mine)}")
    print(f"rows parsed by the csv module:          {len(theirs)}")
    for index, (a, b) in enumerate(zip(mine, theirs)):
        mark = "same" if a == b else "DIFFERENT"
        print(f"  row {index}: {mark}  {a}")
    print(f"agree on every row: {agree}")
    return 0 if agree else 1


if __name__ == "__main__":
    raise SystemExit(main())
examples/wrangle.py (7036 bytes)
#!/usr/bin/env python3
"""wrangle.py — the finished data-wrangling pipeline (REFERENCE).

Reads a deliberately messy CSV (byte-order mark, embedded commas, a quoted
newline, doubled quotes, an empty field, a ragged row), proves that naive
splitting on commas mangles it, parses it properly with csv.DictReader,
cleans it against a JSON config, writes it back out as JSON, JSON Lines,
and a well-formed CSV, and then reloads each of those to prove the round
trip is lossless.

    python3 examples/wrangle.py

Everything it writes lands in out/ next to this lab; nothing else on your
machine is touched.
"""
import csv
import json
from pathlib import Path

LAB_DIR = Path(__file__).resolve().parent.parent


# --- step 1: the naive parser, shown failing -------------------------------

def naive_split_rows(text, delimiter=","):
    """Split each line on the delimiter — the bug factory, kept for contrast."""
    return [line.split(delimiter) for line in text.splitlines()]


def naive_report(text, expected_columns):
    """Return a list of (line_number, field_count) for lines naive splitting breaks."""
    broken = []
    for number, row in enumerate(naive_split_rows(text), start=1):
        if len(row) != expected_columns:
            broken.append((number, len(row)))
    return broken


# --- step 2: parse properly ------------------------------------------------

def read_rows(csv_path, encoding, delimiter):
    """Read the CSV into a list of dicts using csv.DictReader.

    encoding='utf-8-sig' strips a leading byte-order mark if one is there.
    newline='' is required: the csv module does its own newline handling,
    and without it a quoted field containing a newline can be split in two.
    """
    with open(csv_path, "r", encoding=encoding, newline="") as handle:
        reader = csv.DictReader(handle, delimiter=delimiter)
        return [dict(row) for row in reader]


# --- step 3: clean ---------------------------------------------------------

def clean_row(row, config):
    """Return one tidy record: no None values, stripped text, numbers as numbers."""
    cleaned = {}
    for column in config["columns"]:
        value = row.get(column)
        if value is None:
            value = config["defaults"].get(column, "")
        cleaned[column] = value.strip() if isinstance(value, str) else value
    for column in config["numeric_columns"]:
        cleaned[column] = float(cleaned[column])
    return cleaned


def clean_rows(rows, config):
    """Clean every row."""
    return [clean_row(row, config) for row in rows]


# --- step 4: write and read back -------------------------------------------

def write_json(records, path):
    """Write one JSON array. indent=2 for humans; ensure_ascii=False keeps text readable."""
    path.write_text(
        json.dumps(records, indent=2, ensure_ascii=False, sort_keys=False) + "\n",
        encoding="utf-8",
    )


def read_json(path):
    """Read the JSON array back."""
    return json.loads(path.read_text(encoding="utf-8"))


def write_jsonl(records, path):
    """Write JSON Lines: one compact JSON object per line, no outer array."""
    with open(path, "w", encoding="utf-8") as handle:
        for record in records:
            handle.write(json.dumps(record, ensure_ascii=False) + "\n")


def read_jsonl(path):
    """Read JSON Lines back, one object per non-empty line."""
    records = []
    with open(path, "r", encoding="utf-8") as handle:
        for line in handle:
            line = line.strip()
            if line:
                records.append(json.loads(line))
    return records


def write_csv(records, path, columns, delimiter):
    """Write a well-formed CSV with csv.DictWriter — it quotes whatever needs it."""
    with open(path, "w", encoding="utf-8", newline="") as handle:
        writer = csv.DictWriter(handle, fieldnames=columns, delimiter=delimiter)
        writer.writeheader()
        writer.writerows(records)


def read_csv_typed(path, columns, delimiter, numeric_columns):
    """Read a clean CSV back, restoring the numeric columns (CSV has no types)."""
    with open(path, "r", encoding="utf-8", newline="") as handle:
        rows = [dict(row) for row in csv.DictReader(handle, delimiter=delimiter)]
    for row in rows:
        for column in numeric_columns:
            row[column] = float(row[column])
    return rows


# --- the pipeline ----------------------------------------------------------

def main():
    config = json.loads((LAB_DIR / "data" / "config.json").read_text(encoding="utf-8"))
    csv_path = LAB_DIR / config["input_csv"]
    raw_text = csv_path.read_text(encoding=config["encoding"])
    delimiter = config["delimiter"]
    columns = config["columns"]

    print("=== 1. naive line.split(',') ===")
    broken = naive_report(raw_text, len(columns))
    print(f"lines whose field count is not {len(columns)}: {len(broken)}")
    for number, count in broken:
        print(f"  line {number}: {count} fields")
    naive = naive_split_rows(raw_text)
    print(f"naive row for order 1001: {naive[1]}")
    print(f"naive row for order 1003: {naive[4]}")
    print("(order 1003 has 5 fields, so the count check passes — and the data is still wrong)")

    print()
    print("=== 2. csv.DictReader ===")
    rows = read_rows(csv_path, config["encoding"], delimiter)
    print(f"records parsed: {len(rows)}")
    print(f"first key is 'order_id' (byte-order mark stripped): {list(rows[0])[0] == 'order_id'}")
    print(f"order 1002 notes: {rows[1]['notes']!r}")
    print(f"order 1003 customer: {rows[2]['customer']!r}")
    print(f"order 1005 (ragged) total: {rows[4]['total']!r}")

    print()
    print("=== 3. clean ===")
    records = clean_rows(rows, config)
    for record in records:
        print(f"  {record['order_id']} {record['customer']:<18} {record['total']:>7.2f}")

    print()
    print("=== 4. write and round-trip ===")
    out_dir = LAB_DIR / config["output_dir"]
    out_dir.mkdir(exist_ok=True)
    json_path = out_dir / config["output_json"]
    jsonl_path = out_dir / config["output_jsonl"]
    clean_csv_path = out_dir / config["output_csv"]

    write_json(records, json_path)
    write_jsonl(records, jsonl_path)
    write_csv(records, clean_csv_path, columns, delimiter)

    from_json = read_json(json_path)
    from_jsonl = read_jsonl(jsonl_path)
    from_csv = read_csv_typed(clean_csv_path, columns, delimiter, config["numeric_columns"])

    assert from_json == records, "JSON round trip lost or changed data"
    assert from_jsonl == records, "JSON Lines round trip lost or changed data"
    assert from_csv == records, "CSV round trip lost or changed data"

    print(f"wrote {json_path.name}  ({json_path.stat().st_size} bytes)")
    print(f"wrote {jsonl_path.name} ({jsonl_path.stat().st_size} bytes, {len(records)} lines)")
    print(f"wrote {clean_csv_path.name} ({clean_csv_path.stat().st_size} bytes)")
    print("round trip lossless: JSON yes, JSONL yes, CSV yes")
    return 0


if __name__ == "__main__":
    raise SystemExit(main())
metadata.yml (833 bytes)
lesson_id: D065
day: 65
kind: coding
languages: [python, bash]
setup_commands:
  - cd labs/sections/programming-with-python/day-065-csv-and-json-in-the-real
  - python3 --version
run_commands:
  - head -c 3 data/messy_orders.csv | xxd
  - python3 examples/wrangle.py
  - python3 examples/csv_field_parser.py
  - cat out/orders.jsonl
  - python3 starter/wrangle.py
  - python3 starter/csv_field_parser.py
test_commands:
  - bash tests/run_tests.sh
cleanup_commands:
  - rm -rf out
  - 'git checkout -- starter/  # optional: reset your work'
requires_network: false
requires_api_key: false
estimated_minutes: 30
last_executed: '2026-07-19'
executed_on: 'macOS 26.5.1 (Apple Silicon), Python 3.14.0, bash 3.2 — bash tests/run_tests.sh -> 26 checks, 0 failure(s), exit 0 (34 checks, 0 failure(s) with the starter exercises completed)'
requirements/README.md (1466 bytes)
# Dependencies — Day 065 lab

**Python 3 only. No third-party packages, no network, no API key.**

- `python3` (3.8 or newer; tested on 3.14.0). You set this up on Day 43.
- `bash` for the test runner (preinstalled on macOS and Linux).
- Standard library only: `csv`, `json`, and `pathlib` in the lab files,
  plus `io` in the from-scratch parser so it can hand a string to
  `csv.reader`. There is deliberately no `requirements.txt`.

That last point is the lesson in miniature. CSV and JSON are so central to
data work that Python ships a reader, a writer, an encoder, and a decoder
for them in the box. You will meet `pandas` later, and it is excellent —
but reaching for a 30 MB dependency to read a five-row file is a habit
worth resisting, and you cannot debug what `pandas.read_csv` did to your
quoting unless you understand what the `csv` module does first.

Check your Python is present and new enough:

```bash
python3 --version
```

If that prints `Python 3.8` or higher, you are ready.

Windows users: run the commands inside WSL, or use `python` in place of
`python3` if that is how Python is exposed on your system. One genuine
difference matters on Windows and is covered in `troubleshooting.md`: if
you forget `newline=''` when opening a file for the `csv` module, Windows
translates the `\n` the writer emits into `\r\n`, and you get a blank line
between every record. The lab files all pass `newline=''`, so they behave
identically everywhere.
starter/csv_field_parser.py (4650 bytes)
#!/usr/bin/env python3
"""csv_field_parser.py — YOUR working file: a CSV parser from scratch.

You have used the csv module. Now build the part of it that matters, so
that quoting stops being magic. All of RFC 4180's quoting rules fit in one
state machine that walks the text one character at a time.

The four states
---------------
  field-start      between fields; the next character decides everything
  in-field         inside an UNQUOTED field; a comma or newline ends it
  in-quoted        inside a QUOTED field; commas and newlines are DATA
  quote-in-quoted  saw a quote while quoted; the next character says whether
                   that quote was an escaped quote ("") or the closing quote

Finish Exercise 5, then run this file. It parses the lab's messy CSV twice
— once with your parser, once with the standard library — and prints
whether they agree on every row.

    python3 starter/csv_field_parser.py
"""
import csv
import io
from pathlib import Path

LAB_DIR = Path(__file__).resolve().parent.parent


def parse_csv(text, delimiter=",", quotechar='"'):
    """Parse CSV text into a list of rows, each row a list of field strings."""
    rows = []      # finished rows
    row = []       # fields of the row being built
    field = []     # characters of the field being built
    state = "field-start"

    for char in text:
        # Exercise 5: THE QUOTING STATE MACHINE.
        #
        # Handle each state in turn. The rules, in full:
        #
        # state == "field-start"
        #   char is the quote character -> state becomes "in-quoted"
        #   char is the delimiter       -> the field was empty: row.append("")
        #   char is "\n"                -> empty last field, end the row:
        #                                  row.append(""), rows.append(row), row = []
        #   char is "\r"                -> ignore it (so CRLF and LF agree)
        #   anything else               -> field.append(char); state = "in-field"
        #
        # state == "in-field"
        #   char is the delimiter -> finish the field:
        #        row.append("".join(field)); field = []; state = "field-start"
        #   char is "\n"          -> finish the field AND the row, then
        #        rows.append(row); row = []; state = "field-start"
        #   char is "\r"          -> ignore it
        #   anything else         -> field.append(char)
        #
        # state == "in-quoted"      <-- the state that makes CSV work
        #   char is the quote character -> state = "quote-in-quoted"
        #   anything else               -> field.append(char)
        #        Yes, ANYTHING: commas and newlines are ordinary data in here.
        #        That single line is why hand-splitting on commas is a bug.
        #
        # state == "quote-in-quoted"
        #   char is the quote character -> "" means one literal quote:
        #        field.append(quotechar); state = "in-quoted"
        #   char is the delimiter -> the quoted field ended; finish the field
        #        and go back to "field-start"
        #   char is "\n"          -> the quoted field and the row both ended
        #   char is "\r"          -> ignore it
        #   anything else         -> stray text after a closing quote; the
        #        forgiving choice is field.append(char); state = "in-field"
        raise NotImplementedError("Exercise 5: implement the state machine")

    # Flush the final field/row when the text does not end with a newline.
    if field or row or state != "field-start":
        row.append("".join(field))
        rows.append(row)
    return rows


def parse_with_stdlib(text, delimiter=","):
    """Parse the same text with the standard library, for comparison (provided)."""
    return [list(row) for row in csv.reader(io.StringIO(text, newline=""), delimiter=delimiter)]


def compare(text, delimiter=","):
    """Return (mine, theirs, agree) for one blob of CSV text (provided)."""
    mine = parse_csv(text, delimiter=delimiter)
    theirs = parse_with_stdlib(text, delimiter=delimiter)
    return mine, theirs, mine == theirs


def main():
    text = (LAB_DIR / "data" / "messy_orders.csv").read_text(encoding="utf-8-sig")
    mine, theirs, agree = compare(text)
    print(f"rows parsed by the from-scratch parser: {len(mine)}")
    print(f"rows parsed by the csv module:          {len(theirs)}")
    for index, (a, b) in enumerate(zip(mine, theirs)):
        mark = "same" if a == b else "DIFFERENT"
        print(f"  row {index}: {mark}  {a}")
    print(f"agree on every row: {agree}")
    return 0 if agree else 1


if __name__ == "__main__":
    raise SystemExit(main())
starter/wrangle.py (8644 bytes)
#!/usr/bin/env python3
"""wrangle.py — YOUR working file: the data-wrangling pipeline.

The messy file waits for you at data/messy_orders.csv. It contains, on
purpose, every violation you will meet in the wild:

  * a byte-order mark (three invisible bytes) before the header
  * a comma inside a quoted field          ("widget, large")
  * a newline inside a quoted field        (order 1002's notes)
  * doubled quotes standing for one quote  (Alan ""Turing"" Jr.)
  * an empty field                         (order 1004 has no notes)
  * a ragged row with only three fields    (order 1005)

Your job is to load it correctly, clean it, and round-trip it to JSON and
JSON Lines without losing a byte. The pipeline in main() is written for
you; the four numbered exercises below are the pieces it calls.

Everything you write lands in out/ next to this lab. Nothing else on your
machine is touched.

Finish the exercises, then run:
    python3 starter/wrangle.py
    bash tests/run_tests.sh
"""
import csv
import json
from pathlib import Path

LAB_DIR = Path(__file__).resolve().parent.parent


# --- step 1: the naive parser, shown failing -------------------------------

def naive_split_rows(text, delimiter=","):
    """Split each line on the delimiter — the bug factory, provided complete."""
    return [line.split(delimiter) for line in text.splitlines()]


def naive_report(text, expected_columns):
    """Return a list of (line_number, field_count) for lines naive splitting breaks.

    Line numbers start at 1, counting physical lines of the file.
    """
    # Exercise 1: PROVE THE BUG.
    # 1. Start with an empty list called broken.
    # 2. Loop over naive_split_rows(text) with enumerate(..., start=1) so you
    #    get (number, row) pairs.
    # 3. If len(row) != expected_columns, append the tuple (number, len(row)).
    # 4. Return broken.
    # When you run it you will find three bad lines — and, more unsettling,
    # that one badly parsed line has the RIGHT field count and the WRONG data.
    raise NotImplementedError("Exercise 1: implement naive_report")


# --- step 2: parse properly ------------------------------------------------

def read_rows(csv_path, encoding, delimiter):
    """Read the CSV into a list of plain dicts using csv.DictReader."""
    # Exercise 2: PARSE PROPERLY.
    # 1. open(csv_path, "r", encoding=encoding, newline="") — both keyword
    #    arguments matter. encoding is "utf-8-sig" here, which strips the
    #    byte-order mark; newline="" hands newline handling to the csv module
    #    so a quoted newline does not split a row in two.
    # 2. Build reader = csv.DictReader(handle, delimiter=delimiter).
    # 3. Return [dict(row) for row in reader]  (dict() gives you a plain dict).
    # The ragged row will come back with None for its missing values — that is
    # DictReader telling you the truth, and Exercise 3 decides what to do.
    raise NotImplementedError("Exercise 2: implement read_rows")


# --- step 3: clean ---------------------------------------------------------

def clean_row(row, config):
    """Return one tidy record: no None values, stripped text, numbers as numbers."""
    # Exercise 3: CLEAN.
    # 1. Make an empty dict called cleaned.
    # 2. For each column in config["columns"]:
    #      value = row.get(column)
    #      if value is None: use config["defaults"].get(column, "") instead
    #      store value.strip() when it is a string, otherwise value as-is
    #        (hint: isinstance(value, str))
    # 3. For each column in config["numeric_columns"], replace the stored
    #    string with float(...) of it. CSV has no types; you add them here.
    # 4. Return cleaned.
    raise NotImplementedError("Exercise 3: implement clean_row")


def clean_rows(rows, config):
    """Clean every row (provided complete)."""
    return [clean_row(row, config) for row in rows]


# --- step 4: write and read back -------------------------------------------

def write_json(records, path):
    """Write one JSON array (provided complete)."""
    path.write_text(
        json.dumps(records, indent=2, ensure_ascii=False, sort_keys=False) + "\n",
        encoding="utf-8",
    )


def read_json(path):
    """Read the JSON array back (provided complete)."""
    return json.loads(path.read_text(encoding="utf-8"))


def write_jsonl(records, path):
    """Write JSON Lines: one compact JSON object per line, no outer array."""
    # Exercise 4a: WRITE JSON LINES.
    # 1. open(path, "w", encoding="utf-8") in a with-block.
    # 2. For each record, write json.dumps(record, ensure_ascii=False) then "\n".
    #    Do NOT pass indent — a JSON Lines record must be exactly one line.
    raise NotImplementedError("Exercise 4a: implement write_jsonl")


def read_jsonl(path):
    """Read JSON Lines back, one object per non-empty line."""
    # Exercise 4b: READ JSON LINES.
    # 1. Start with an empty list called records.
    # 2. open(path, "r", encoding="utf-8") and loop over the file line by line.
    # 3. Strip each line; skip it if it is empty; otherwise append
    #    json.loads(line) to records.
    # 4. Return records.
    raise NotImplementedError("Exercise 4b: implement read_jsonl")


def write_csv(records, path, columns, delimiter):
    """Write a well-formed CSV with csv.DictWriter (provided complete)."""
    with open(path, "w", encoding="utf-8", newline="") as handle:
        writer = csv.DictWriter(handle, fieldnames=columns, delimiter=delimiter)
        writer.writeheader()
        writer.writerows(records)


def read_csv_typed(path, columns, delimiter, numeric_columns):
    """Read a clean CSV back, restoring the numeric columns (provided complete)."""
    with open(path, "r", encoding="utf-8", newline="") as handle:
        rows = [dict(row) for row in csv.DictReader(handle, delimiter=delimiter)]
    for row in rows:
        for column in numeric_columns:
            row[column] = float(row[column])
    return rows


# --- the pipeline (provided complete) --------------------------------------

def main():
    config = json.loads((LAB_DIR / "data" / "config.json").read_text(encoding="utf-8"))
    csv_path = LAB_DIR / config["input_csv"]
    raw_text = csv_path.read_text(encoding=config["encoding"])
    delimiter = config["delimiter"]
    columns = config["columns"]

    print("=== 1. naive line.split(',') ===")
    broken = naive_report(raw_text, len(columns))
    print(f"lines whose field count is not {len(columns)}: {len(broken)}")
    for number, count in broken:
        print(f"  line {number}: {count} fields")
    naive = naive_split_rows(raw_text)
    print(f"naive row for order 1001: {naive[1]}")
    print(f"naive row for order 1003: {naive[4]}")
    print("(order 1003 has 5 fields, so the count check passes — and the data is still wrong)")

    print()
    print("=== 2. csv.DictReader ===")
    rows = read_rows(csv_path, config["encoding"], delimiter)
    print(f"records parsed: {len(rows)}")
    print(f"first key is 'order_id' (byte-order mark stripped): {list(rows[0])[0] == 'order_id'}")
    print(f"order 1002 notes: {rows[1]['notes']!r}")
    print(f"order 1003 customer: {rows[2]['customer']!r}")
    print(f"order 1005 (ragged) total: {rows[4]['total']!r}")

    print()
    print("=== 3. clean ===")
    records = clean_rows(rows, config)
    for record in records:
        print(f"  {record['order_id']} {record['customer']:<18} {record['total']:>7.2f}")

    print()
    print("=== 4. write and round-trip ===")
    out_dir = LAB_DIR / config["output_dir"]
    out_dir.mkdir(exist_ok=True)
    json_path = out_dir / config["output_json"]
    jsonl_path = out_dir / config["output_jsonl"]
    clean_csv_path = out_dir / config["output_csv"]

    write_json(records, json_path)
    write_jsonl(records, jsonl_path)
    write_csv(records, clean_csv_path, columns, delimiter)

    from_json = read_json(json_path)
    from_jsonl = read_jsonl(jsonl_path)
    from_csv = read_csv_typed(clean_csv_path, columns, delimiter, config["numeric_columns"])

    assert from_json == records, "JSON round trip lost or changed data"
    assert from_jsonl == records, "JSON Lines round trip lost or changed data"
    assert from_csv == records, "CSV round trip lost or changed data"

    print(f"wrote {json_path.name}  ({json_path.stat().st_size} bytes)")
    print(f"wrote {jsonl_path.name} ({jsonl_path.stat().st_size} bytes, {len(records)} lines)")
    print(f"wrote {clean_csv_path.name} ({clean_csv_path.stat().st_size} bytes)")
    print("round trip lossless: JSON yes, JSONL yes, CSV yes")
    return 0


if __name__ == "__main__":
    raise SystemExit(main())
tests/run_tests.sh (10542 bytes)
#!/usr/bin/env bash
# Tests for the Day 065 lab. Run from the lab directory:
#   bash tests/run_tests.sh
#
# These checks exercise real behaviour, not file existence: the messy CSV
# really does carry a byte-order mark; naive comma-splitting really does
# mangle it; csv.DictReader really does recover the quoted newline, the
# doubled quotes, and the ragged row; the JSON / JSON Lines / CSV round
# trips really are lossless; and the from-scratch quoting state machine
# really does agree with the standard library on every row.
#
# The reference in examples/ is always tested strictly. The learner's
# starter/ is tested structurally while exercises are unfinished, and to
# the same strict standard once they are complete.
# No network, non-interactive. Exits 0 only if every check passes.
set -u

export PYTHONDONTWRITEBYTECODE=1

lab_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
tmp_dir="$(mktemp -d -t day065-tests.XXXXXX)"
trap 'rm -rf "${tmp_dir}"' EXIT

failures=0
checks=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
}

# check_py <label> <module_dir> <python-body>
# Runs an assertion body with module_dir on the import path. A clean exit
# (every assert holds) is a pass.
check_py() {
  local label="$1" module_dir="$2" body="$3"
  if PYTHONPATH="${module_dir}" TMP_DIR="${tmp_dir}" LAB="${lab_dir}" python3 -c "${body}" 2>/dev/null; then
    check "${label}" "yes"
  else
    check "${label}" "no"
  fi
}

# --- the input data itself -------------------------------------------------

echo "Testing the messy input file ..."
check_py "messy_orders.csv really starts with a byte-order mark" "${lab_dir}" \
"import os
raw = open(os.path.join(os.environ['LAB'], 'data', 'messy_orders.csv'), 'rb').read()
assert raw[:3] == b'\xef\xbb\xbf', raw[:3]"

check_py "messy_orders.csv really contains a newline inside a quoted field" "${lab_dir}" \
"import os
text = open(os.path.join(os.environ['LAB'], 'data', 'messy_orders.csv'), encoding='utf-8-sig').read()
assert 'Leave at door.\nRing the bell twice.' in text
assert len(text.splitlines()) == 7  # 6 records spread over 7 physical lines"

check_py "config.json is valid JSON with the five expected columns" "${lab_dir}" \
"import json, os
cfg = json.load(open(os.path.join(os.environ['LAB'], 'data', 'config.json'), encoding='utf-8'))
assert cfg['columns'] == ['order_id', 'customer', 'items', 'notes', 'total']
assert cfg['encoding'] == 'utf-8-sig'"

# --- the pipeline ----------------------------------------------------------

run_wrangle_checks() {
  local module_dir="$1"
  echo "Testing the wrangling pipeline in ${module_dir} ..."

  check_py "naive splitting breaks three physical lines" "${module_dir}" \
"import os, wrangle
text = open(os.path.join(os.environ['LAB'], 'data', 'messy_orders.csv'), encoding='utf-8-sig').read()
broken = wrangle.naive_report(text, 5)
assert [n for n, _ in broken] == [2, 4, 7], broken
assert dict(broken)[2] == 6 and dict(broken)[4] == 2 and dict(broken)[7] == 3"

  check_py "naive splitting also corrupts a line whose field count looks right" "${module_dir}" \
"import os, wrangle
text = open(os.path.join(os.environ['LAB'], 'data', 'messy_orders.csv'), encoding='utf-8-sig').read()
row = wrangle.naive_split_rows(text)[4]
assert len(row) == 5
assert row[1] == '\"Alan \"\"Turing\"\" Jr.\"'  # quotes never removed, escape never resolved"

  check_py "DictReader returns five records and strips the byte-order mark" "${module_dir}" \
"import os, wrangle
rows = wrangle.read_rows(os.path.join(os.environ['LAB'], 'data', 'messy_orders.csv'), 'utf-8-sig', ',')
assert len(rows) == 5, len(rows)
assert list(rows[0])[0] == 'order_id', list(rows[0])[0]"

  check_py "the quoted newline, the embedded comma and the doubled quotes survive" "${module_dir}" \
"import os, wrangle
rows = wrangle.read_rows(os.path.join(os.environ['LAB'], 'data', 'messy_orders.csv'), 'utf-8-sig', ',')
assert rows[0]['items'] == 'widget, large'
assert rows[1]['notes'] == 'Leave at door.\nRing the bell twice.'
assert rows[2]['customer'] == 'Alan \"Turing\" Jr.'"

  check_py "the ragged row comes back with None for its missing fields" "${module_dir}" \
"import os, wrangle
rows = wrangle.read_rows(os.path.join(os.environ['LAB'], 'data', 'messy_orders.csv'), 'utf-8-sig', ',')
assert rows[4]['order_id'] == '1005'
assert rows[4]['notes'] is None and rows[4]['total'] is None"

  check_py "cleaning fills defaults and turns total into a real float" "${module_dir}" \
"import json, os, wrangle
cfg = json.load(open(os.path.join(os.environ['LAB'], 'data', 'config.json'), encoding='utf-8'))
rows = wrangle.read_rows(os.path.join(os.environ['LAB'], 'data', 'messy_orders.csv'), 'utf-8-sig', ',')
recs = wrangle.clean_rows(rows, cfg)
assert all(v is not None for r in recs for v in r.values())
assert recs[4]['notes'] == '' and recs[4]['total'] == 0.0
assert isinstance(recs[0]['total'], float) and recs[0]['total'] == 49.5"

  check_py "the JSON round trip is lossless" "${module_dir}" \
"import json, os, wrangle
from pathlib import Path
cfg = json.load(open(os.path.join(os.environ['LAB'], 'data', 'config.json'), encoding='utf-8'))
rows = wrangle.read_rows(os.path.join(os.environ['LAB'], 'data', 'messy_orders.csv'), 'utf-8-sig', ',')
recs = wrangle.clean_rows(rows, cfg)
p = Path(os.environ['TMP_DIR']) / 'rt.json'
wrangle.write_json(recs, p)
assert wrangle.read_json(p) == recs"

  check_py "the JSON Lines round trip is lossless and is one line per record" "${module_dir}" \
"import json, os, wrangle
from pathlib import Path
cfg = json.load(open(os.path.join(os.environ['LAB'], 'data', 'config.json'), encoding='utf-8'))
rows = wrangle.read_rows(os.path.join(os.environ['LAB'], 'data', 'messy_orders.csv'), 'utf-8-sig', ',')
recs = wrangle.clean_rows(rows, cfg)
p = Path(os.environ['TMP_DIR']) / 'rt.jsonl'
wrangle.write_jsonl(recs, p)
assert wrangle.read_jsonl(p) == recs
lines = [ln for ln in p.read_text(encoding='utf-8').splitlines() if ln.strip()]
assert len(lines) == len(recs) == 5, len(lines)
assert all(json.loads(ln) for ln in lines)"

  check_py "the CSV round trip is lossless once types are restored" "${module_dir}" \
"import json, os, wrangle
from pathlib import Path
cfg = json.load(open(os.path.join(os.environ['LAB'], 'data', 'config.json'), encoding='utf-8'))
rows = wrangle.read_rows(os.path.join(os.environ['LAB'], 'data', 'messy_orders.csv'), 'utf-8-sig', ',')
recs = wrangle.clean_rows(rows, cfg)
p = Path(os.environ['TMP_DIR']) / 'rt.csv'
wrangle.write_csv(recs, p, cfg['columns'], ',')
assert wrangle.read_csv_typed(p, cfg['columns'], ',', cfg['numeric_columns']) == recs
assert '\"widget, large\"' in p.read_text(encoding='utf-8')  # DictWriter re-quotes for us"
}

run_parser_checks() {
  local module_dir="$1"
  echo "Testing the from-scratch quoting state machine in ${module_dir} ..."

  check_py "the from-scratch parser agrees with the csv module on the messy file" "${module_dir}" \
"import os, csv_field_parser as p
text = open(os.path.join(os.environ['LAB'], 'data', 'messy_orders.csv'), encoding='utf-8-sig').read()
mine, theirs, agree = p.compare(text)
assert agree, list(zip(mine, theirs))
assert len(mine) == 6"

  check_py "the from-scratch parser handles embedded commas, newlines and doubled quotes" "${module_dir}" \
"import csv_field_parser as p
assert p.parse_csv('a,\"b,c\",d\n') == [['a', 'b,c', 'd']]
assert p.parse_csv('a,\"line1\nline2\",d\n') == [['a', 'line1\nline2', 'd']]
assert p.parse_csv('a,\"say \"\"hi\"\"\",d\n') == [['a', 'say \"hi\"', 'd']]"

  check_py "the from-scratch parser handles empty fields, no trailing newline and CRLF" "${module_dir}" \
"import csv_field_parser as p
assert p.parse_csv('a,,c\n') == [['a', '', 'c']]
assert p.parse_csv('a,b,c') == [['a', 'b', 'c']]
assert p.parse_csv('a,b\r\nc,d\r\n') == [['a', 'b'], ['c', 'd']]
assert p.parse_csv('a,b,') == [['a', 'b', '']]"

  check_py "the from-scratch parser agrees with the csv module on a semicolon dialect" "${module_dir}" \
"import csv_field_parser as p
text = 'name;amount\nWien;1.234,50\n\"Berlin; Mitte\";2.000,00\n'
mine, theirs, agree = p.compare(text, delimiter=';')
assert agree, list(zip(mine, theirs))
assert mine[2][0] == 'Berlin; Mitte'"
}

run_end_to_end() {
  local script="$1"
  echo "Running ${script} end to end ..."
  local out code
  out="$(cd "${lab_dir}" && python3 "${script}" 2>&1)"
  code=$?
  if [ "${code}" -eq 0 ] && printf '%s' "${out}" | grep -qF "round trip lossless: JSON yes, JSONL yes, CSV yes"; then
    check "${script} runs end to end and reports a lossless round trip" "yes"
  else
    check "${script} runs end to end and reports a lossless round trip" "no"
    echo "    (exit ${code}; output: ${out})"
  fi
}

# --- reference: always strict ---
run_wrangle_checks "${lab_dir}/examples"
run_parser_checks "${lab_dir}/examples"
run_end_to_end "examples/wrangle.py"

check_py "the written out/orders.jsonl parses line by line as JSON" "${lab_dir}/examples" \
"import json, os
from pathlib import Path
p = Path(os.environ['LAB']) / 'out' / 'orders.jsonl'
recs = [json.loads(ln) for ln in p.read_text(encoding='utf-8').splitlines() if ln.strip()]
assert len(recs) == 5
assert recs[2]['customer'] == 'Alan \"Turing\" Jr.'"

# --- learner starter ---
echo "Testing starter/ ..."
starter_wrangle="${lab_dir}/starter/wrangle.py"
starter_parser="${lab_dir}/starter/csv_field_parser.py"

for f in "${starter_wrangle}" "${starter_parser}"; do
  if python3 -c "compile(open('${f}').read(), '${f}', 'exec')" 2>/dev/null; then
    check "$(basename "${f}") is valid Python" "yes"
  else
    check "$(basename "${f}") is valid Python" "no"
  fi
done

if grep -q 'NotImplementedError' "${starter_wrangle}" || grep -q 'NotImplementedError' "${starter_parser}"; then
  echo "Note: starter/ still has unfinished exercises — testing structure only."
  for name in naive_report read_rows clean_row write_jsonl read_jsonl; do
    if grep -q "def ${name}" "${starter_wrangle}"; then
      check "starter defines ${name}" "yes"
    else
      check "starter defines ${name}" "no"
    fi
  done
  if grep -q 'def parse_csv' "${starter_parser}"; then
    check "starter defines parse_csv" "yes"
  else
    check "starter defines parse_csv" "no"
  fi
else
  run_wrangle_checks "${lab_dir}/starter"
  run_parser_checks "${lab_dir}/starter"
  run_end_to_end "starter/wrangle.py"
fi

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

Troubleshooting

Troubleshooting — Day 065 lab

python: command not found

Use python3 explicitly, as every command in this lab does. On macOS and most Linux systems, bare python may be missing or point to an old version. Check with python3 --version.

The starter raises NotImplementedError

Expected until you finish the five exercises. Each unfinished function raises NotImplementedError on purpose so an empty function can never be mistaken for a working one. Exercises 1–4 are in starter/wrangle.py; exercise 5 is the state machine in starter/csv_field_parser.py.

The first column is called order_id and row['order_id'] raises KeyError

You opened the file with encoding='utf-8' instead of encoding='utf-8-sig'. The file begins with a byte-order mark — three bytes, EF BB BF — that Excel and many Windows tools write at the start of a UTF-8 export. Plain utf-8 decodes those bytes into an invisible character that becomes part of your first column name, so the name looks right on screen and does not match anything in code. Prove it to yourself:

head -c 3 data/messy_orders.csv | xxd
python3 -c "print(repr(open('data/messy_orders.csv', encoding='utf-8').read(12)))"
python3 -c "print(repr(open('data/messy_orders.csv', encoding='utf-8-sig').read(12)))"

utf-8-sig strips the mark if it is present and is harmless if it is not, which makes it the safe default for any file that may have come from a spreadsheet.

A blank line appears between every row of my output CSV

You opened the output file without newline=''. The csv writer emits \r\n at the end of each record itself; if the text layer is also translating \n into \r\n — which it does by default on Windows — you get \r\r\n and a blank line between records. The fix is one keyword argument, and it belongs on reading as well as writing:

with open(path, "w", encoding="utf-8", newline="") as handle:

A record containing a newline gets split into two rows

Same cause, other direction: you opened the input without newline=''. The csv module has to see the raw characters so it can tell a newline inside quotes (data) from a newline between records (a boundary). Let the text layer chop the file into lines first and that distinction is gone before csv ever sees it. Order 1002 in this lab exists to catch exactly this bug.

TypeError: Object of type ... is not JSON serializable

JSON has only six kinds of value — object, array, string, number, true / false, and null — so anything else must be converted first. The two you will meet soonest are datetime objects and sets. Either convert before dumping (value.isoformat(), sorted(my_set)) or pass a default function that json.dumps calls for anything it does not recognise:

json.dumps(record, default=str)

AttributeError: 'NoneType' object has no attribute 'strip'

You hit the ragged row. Order 1005 has three fields where the header promises five, so DictReader fills the two missing keys with None. That is DictReader being honest, and Exercise 3 is where you decide what a missing value means — here, the defaults from config.json. Check if value is None before you call a string method on it.

json.decoder.JSONDecodeError: Expecting value: line 1 column 1

Something that is not JSON reached json.loads. The usual causes: an empty line in a JSON Lines file (skip blank lines — read_jsonl does), a file written with indent= being read one line at a time (indented JSON is not JSON Lines), or a trailing comma or comment left in a hand-edited config file. JSON allows neither comments nor trailing commas, no matter how reasonable they look.

My from-scratch parser disagrees with the csv module

Read the disagreement rather than guessing — the comparison prints both rows side by side. Three mistakes account for almost all of them:

  • Treating a comma as a separator while in the in-quoted state. Inside quotes, a comma is data. So is a newline.
  • Forgetting the quote-in-quoted state. A quote inside a quoted field is ambiguous until you see the next character: another quote means one literal quote, a comma or newline means the field ended.
  • Never flushing the final field. If the text does not end with a newline, the last field is still sitting in your accumulator when the loop ends.

bash: tests/run_tests.sh: Permission denied

Run it through bash explicitly, as the README shows: bash tests/run_tests.sh. You do not need to chmod +x anything.

I want to start over

Delete the generated directory and restore the starter from git:

rm -rf out
git checkout -- starter/

Security notes

Security notes — Day 065 lab

  • What the lab does: reads two files that ship with it (data/messy_orders.csv and data/config.json), and writes three files into out/ inside this lab directory. It makes no network connections, needs no privileges, and touches nothing outside the lab. The test runner writes its round-trip files into a throwaway directory made with mktemp -d and removes it on exit.

  • Parsed data is data, never code. The single most important habit in this lab: a CSV field or a JSON value is text you were handed, not something to execute. Use float(value) and int(value) to turn text into numbers — they can only ever produce a number or raise ValueError. Never use eval() or exec() to "read" a value, and never use ast.literal_eval as a substitute for a real parser on untrusted input. Likewise, json.loads is safe by design; Python's pickle is not, and must never be pointed at a file you did not write yourself — unpickling runs code.

  • A separator is not sanitisation. A field arriving from a CSV can contain a comma, a newline, a quote, a null byte, or a megabyte of text. If you paste those values into a SQL string, a shell command, or an HTML page without escaping, you have handed the file's author control of your program. Use the real interface at every boundary: parameterised SQL queries, subprocess argument lists rather than shell strings, and a templating layer that escapes HTML.

  • Spreadsheet formula injection is real. A CSV field beginning with =, +, -, or @ is treated as a formula by Excel, LibreOffice, and Google Sheets when the file is opened. If you export user-supplied text to CSV for other people to open, prefix such fields with a single quote or refuse them; a well-formed CSV can still be a hostile one.

  • Size and shape are attack surface too. json.load on a hostile file will happily allocate everything it describes, and a deeply nested document can exhaust the recursion limit. When the file did not come from you, check its size before reading it, prefer JSON Lines so you can process one record at a time instead of holding the whole document in memory, and treat a failure to parse as an expected outcome rather than a crash.

  • Personal data lives in these files. Order records, names, and notes are exactly the sort of content that turns a convenient CSV into a privacy incident when it is copied to a laptop, pasted into a chat, or committed to version control. Keep real data out of repositories, keep the generated out/ directory local, and delete extracts when the job they were made for is finished.

  • Reading before running: every file in this lab is short and commented. Read examples/wrangle.py, examples/csv_field_parser.py, and tests/run_tests.sh before running them. Running unread scripts is one of the most common ways developers get compromised; the course's rule is that every lab script is small enough to read and understand first.