Lifestyle

Workshop: build a CSV cleanup tool

Build a CSV cleaner that preserves its input and writes a new output. The exercise trims whitespace, lowercases email, keeps the first valid occurrence of an address and skips blank names or malformed addresses. It reports kept, duplicate and invalid counts. Its format check does not prove a mailbox exists.

About 20 min read · Practice 45 min

Workflow illustration, not a product screenshot.
Image: Mokaair (© Mokaair)
Back to directory:Codex learning hub: tutorial directory

Advanced · Desktop / CLI / VS Code / JetBrains

On this page
  1. Goal and preparation
  2. Step 1: Create manually checkable data
  3. Step 2: Ask Codex to plan the cleaning contract
  4. Step 3: Inspect the reference safeguards
  5. Step 4: Run, capture status and verify output
  6. Step 5: Unicode, quotes and malformed rows
  7. Step 6: Retain repeatable acceptance tests
  8. Delivery, recovery and limits

Goal and preparation

Step 1: Create manually checkable data

Open csv-lab in an editor and create UTF-8 contacts.csv exactly below. The header must be name,email in that order. Preserve the spaces around Alice and the first email as test conditions. Inspect with a text editor so spreadsheet re-saving does not change encoding or values. Check py -3 --version on Windows or python3 --version on macOS/Linux. Confirm Python 3.9 or later. This example needs no pip packages, pandas or database.

contacts.csv (preserve test spaces) · csv
name,email
 Alice , ALICE@EXAMPLE.TEST 
Alice duplicate,alice@example.test
Bob,bob@example.test
Missing,
Input rowDecisionOutput
Alice / ALICE@EXAMPLE.TESTValid; trim and lowercaseAlice / alice@example.test
Alice duplicate / alice@example.testDuplicate valid emailOmit
Bob / bob@example.testValidBob / bob@example.test
Missing / blank emailInvalidOmit

Expect kept 2, duplicate 1, invalid 1. Deduplicate normalized email, not names. If an earlier row for that address is invalid because its name is blank, the first later valid row can still be kept. Specify these rules before generating code.

Step 2: Ask Codex to plan the cleaning contract

Open csv-lab in Codex and use to agree on input, output, exceptions and acceptance before implementing. Write only a new output file. Structural corruption must fail the run without a misleading partial result. An invalid email is countable bad data, while missing/extra columns or a wrong header violate the contract. Downstream use depends on that distinction, so ask for more than simply cleaning a CSV.

Implementation request · text
Create clean_contacts.py using Python's standard library only.
Expose clean(source: Path, target: Path) returning kept/duplicate/invalid counts.
Read UTF-8 with optional BOM using csv.DictReader, not string splitting.
Require exactly name,email. Trim both fields and lowercase email.
Keep the first valid row per email; reject empty names and invalid email shapes.
Treat missing/extra columns or malformed CSV as whole-run failures.
Read and validate before creating output. Never change input or overwrite output.
Write UTF-8 with name,email and a newline after each row.
CLI takes source and target paths, reports counts on stderr and exits 0 on success,
1 on data/file errors. Add tests for preservation, duplicates, Unicode, quoted
commas, malformed rows and overwrite refusal. Do not use real contact data.

Step 3: Inspect the reference safeguards

The complete reference below can run independently as clean_contacts.py. Back up your generated version before comparing. The csv module handles quotes, commas and newlines; split(',') cannot replace it. After reading all input, the program creates output with x mode, refusing existing targets. This avoids output on malformed input, but a disk failure during writing can still leave a partial file. Mark that run failed and inspect its artifacts; this is not a transactional system.

clean_contacts.py (complete reference) · python
"""Clean a practice CSV without overwriting either the input or an existing output."""
import argparse
import csv
import json
import re
import sys
from pathlib import Path


def clean(source: Path, target: Path) -> dict[str, int]:
    if source.resolve() == target.resolve():
        raise ValueError("Input and output must differ")
    rows = []
    seen = set()
    counts = {"kept": 0, "duplicate": 0, "invalid": 0}
    with source.open(encoding="utf-8-sig", newline="") as stream:
        reader = csv.DictReader(stream, strict=True)
        if reader.fieldnames != ["name", "email"]:
            raise ValueError("Expected exactly: name,email")
        for row in reader:
            if None in row or any(value is None for value in row.values()):
                raise ValueError("Malformed CSV row")
            name = row["name"].strip()
            email = row["email"].strip().lower()
            # An exercise-level shape check, not proof an address can receive mail.
            if not name or not re.fullmatch(r"[^\s@]+@[^\s@]+\.[^\s@]+", email):
                counts["invalid"] += 1
            elif email in seen:
                counts["duplicate"] += 1
            else:
                seen.add(email)
                rows.append({"name": name, "email": email})
                counts["kept"] += 1
    # Exclusive creation: a rerun never silently replaces an existing result.
    with target.open("x", encoding="utf-8", newline="") as stream:
        writer = csv.DictWriter(stream, fieldnames=["name", "email"], lineterminator="\n")
        writer.writeheader()
        writer.writerows(rows)
    return counts


def main() -> int:
    parser = argparse.ArgumentParser(description=__doc__)
    parser.add_argument("source", type=Path)
    parser.add_argument("target", type=Path)
    args = parser.parse_args()
    try:
        counts = clean(args.source, args.target)
    except (OSError, ValueError, csv.Error) as error:
        print(str(error), file=sys.stderr)
        return 1
    print(json.dumps(counts), file=sys.stderr)
    return 0


if __name__ == "__main__":
    raise SystemExit(main())

Step 4: Run, capture status and verify output

Check that cleaned-01.csv is unused. On macOS/Linux, contacts-before.csv must also be unused so cp cannot overwrite a backup. Record the input SHA256 in PowerShell or copy this fictional input for cmp on macOS/Linux. Counts are written to stderr even on success; text on that channel alone is not failure. Save exit status immediately, then compare output and input.

Windows PowerShell · powershell
$csvBefore = (Get-FileHash -LiteralPath contacts.csv -Algorithm SHA256).Hash
py -3 clean_contacts.py contacts.csv cleaned-01.csv
$csvExit = $LASTEXITCODE
$csvExit
Get-Content -LiteralPath cleaned-01.csv
$csvBefore -eq (Get-FileHash -LiteralPath contacts.csv -Algorithm SHA256).Hash
macOS / Linux · sh
cp contacts.csv contacts-before.csv
python3 clean_contacts.py contacts.csv cleaned-01.csv
csv_exit=$?
echo "$csv_exit"
cat cleaned-01.csv
cmp contacts-before.csv contacts.csv
Expected cleaned-01.csv · csv
name,email
Alice,alice@example.test
Bob,bob@example.test

Expect exit 0, counts kept 2, duplicate 1, invalid 1, two output records in original order, and True or no cmp difference. Repeat the same command: existing cleaned-01.csv must cause exit 1 without changing it. Do not delete it first and claim overwrite protection passed. Then use contacts.csv as both input and output; it must refuse without changing the original. These expected failures are part of successful acceptance.

Step 5: Unicode, quotes and malformed rows

Create contacts-unicode.csv below and output cleaned-unicode.csv. Expect kept 1, duplicate 0, invalid 0, with the comma inside one name field and Unicode preserved. If re-opening looks garbled, check UTF-8 decoding before rewriting input. Create bad.csv containing name,email followed by a newline and Alice, a missing email column. A new cleaned-bad.csv target must not be created and exit must be 1. Reversed headers or an extra third column must also fail.

contacts-unicode.csv · csv
name,email
"林, Alex",alex@example.test

Extension: retain the first valid row

contacts-first-valid.csv · csv
name,email
 ,first@example.test
First valid,FIRST@example.test
Later duplicate,first@example.test

Run the same program with this new input and an unused cleaned-first-valid.csv target. Expect kept 1, duplicate 1, invalid 1. The blank-name row must not reserve the email; First valid is the first valid row, and the final row is the duplicate. If First valid is missing, check that seen is updated only after validation. Preserve contacts.csv and cleaned-01.csv.

Expected cleaned-first-valid.csv content · csv
name,email
First valid,first@example.test

Step 6: Retain repeatable acceptance tests

Save the complete test as test_clean_contacts.py beside the program and contacts.csv. It uses temporary folders to check byte-preserved input, overwrite refusal, Unicode, quoting and structural errors without accumulating test outputs in your practice folder. Run py -3 -m unittest -v test_clean_contacts.py on Windows or python3 -m unittest -v test_clean_contacts.py on macOS/Linux. All three reference tests should pass. If your generated interface differs, align the clean function contract rather than deleting failing cases.

test_clean_contacts.py · python
import csv
import tempfile
import unittest
from pathlib import Path

from clean_contacts import clean


class CleanupTests(unittest.TestCase):
    def test_contract_and_input_preservation(self):
        original = (Path(__file__).parent / "contacts.csv").read_bytes()
        with tempfile.TemporaryDirectory() as directory:
            source = Path(directory) / "input.csv"
            target = Path(directory) / "output.csv"
            source.write_bytes(original)
            self.assertEqual(clean(source, target), {"kept": 2, "duplicate": 1, "invalid": 1})
            self.assertEqual(source.read_bytes(), original)
            with target.open(encoding="utf-8", newline="") as stream:
                self.assertEqual(list(csv.DictReader(stream)), [{"name": "Alice", "email": "alice@example.test"}, {"name": "Bob", "email": "bob@example.test"}])
            with self.assertRaises(FileExistsError):
                clean(source, target)
            with self.assertRaises(ValueError):
                clean(source, source)

    def test_unicode_and_quoted_comma(self):
        with tempfile.TemporaryDirectory() as directory:
            source, target = Path(directory) / "in.csv", Path(directory) / "out.csv"
            source.write_text('name,email\n"林, Alex",alex@example.test\n', encoding="utf-8")
            self.assertEqual(clean(source, target)["kept"], 1)
            self.assertIn('"林, Alex"', target.read_text(encoding="utf-8"))

    def test_bad_header_and_malformed_rows_create_no_output(self):
        with tempfile.TemporaryDirectory() as directory:
            source, target = Path(directory) / "in.csv", Path(directory) / "out.csv"
            for value in ["email,name\na@b.test,A\n", "name,email\nAlice\n", "name,email\nA,a@b.test,extra\n"]:
                source.write_text(value, encoding="utf-8")
                with self.assertRaises(ValueError):
                    clean(source, target)
                self.assertFalse(target.exists())


if __name__ == "__main__":
    unittest.main()

Delivery, recovery and limits

Deliver the fictional source CSV, program, tests, command, counts and evidence of preserved input. Download the complete CSV exercise for comparison; it includes reference code and needs no earlier project. Use new output names for reruns and clean only identified artifacts. Real contacts, very large files or spreadsheet imports need separate access, streaming and formula-text rules. This lesson saves data as text and never executes external content as commands. Continue to with the same baseline and verification discipline.

32. Workshop: build a CSV cleanup tool — Workflow illustration, not a product screenshot. contacts.csv → Cleanup → clean.csv
32. Workshop: build a CSV cleanup tool — Workflow illustration, not a product screenshot. contacts.csv → Cleanup → clean.csv · Image: Mokaair (© Mokaair)
Read the full description

contacts.csv to Cleanup to clean.csv

Back to directory

  • Lifestyle

    Codex learning hub: tutorial directory

    A planned 60-lesson, ten-unit Codex curriculum, from setup and your first task to MD instructions and advanced integrations. Find your next lesson by experience, platform, goal or command; unpublished entries show their status.

  • Lifestyle

    Worktrees and isolated tasks

    A Git worktree gives one repository multiple working directories on different branches. It isolates file edits, but databases, ports and external services may still be shared. File isolation is not full resource isolation.

  • Lifestyle

    Workshop: build a small website

    Plan and build the Small Steps task website from brief.md, with adding, completing, deleting, filtering and local persistence. Separate HTML, CSS, data functions, UI events and tests, verify with Node and browser checks, and document restart and recovery steps.

  • Lifestyle

    Usage and efficiency: reducing rework

    Record task conditions, model options, time and outcomes to reduce unnecessary retries and excess context.

Latest travel guides

Sources

Lifestyle