Programming with PythonControl Flow and Collections › Day 56

Hands-on lab — Day 56: Building a Data-Driven CLI

Commands

Setup

cd labs/sections/programming-with-python/day-056-building-a-data-driven-cli
python3 --version

Run

python3 examples/records.py --store demo.json add --name "Ada Lovelace" --email ada@example.com
python3 examples/records.py --store demo.json list
python3 examples/records.py --store demo.json find --field name --query ada
python3 examples/records.py --store demo.json delete --id 1

Test

bash tests/run_tests.sh

File tree

examples/records.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/cli-worksheet.md
starter/records.py
tests/run_tests.sh
troubleshooting.md

Lab README

Day 056 lab — Records CLI

Lesson

Purpose

Day 56's lesson is the Week 8 capstone: it brings control flow and every collection together into one real command-line tool. This lab makes that concrete. You build Records CLI — a small, data-driven tool with argparse subcommands (add, list, find, delete) that persists a list of dictionaries to a JSON file, validates input, prints results, and returns proper exit codes. You build it from a starter, one exercise at a time, then run an automated test suite that checks real behaviour: output and exit codes, persistence to disk, and error handling. This is a scaled-down rehearsal for the Week 8 project, the Terminal Task Manager (a to-do CLI with add/list/complete/delete over a JSON file) — the same skeleton you will reach for later to wrap models, datasets, and agents as command-line tools.

Learning objectives

  • Parse command-line arguments with argparse: a global option, subcommands, required options, typed options, and choices.
  • Read and write structured data as JSON with json.load and json.dump so it survives between runs.
  • Model records as a list of dictionaries and run the command → load → mutate → save → report loop.
  • Return meaningful exit codes and send error messages to standard error.
  • Keep the tool testable by taking all input from arguments (never an interactive prompt), and prove a function is importable and testable.

Prerequisites

  • The Day 56 lesson (read it first — it explains every part this lab builds).
  • Days 50–55: conditionals and loops, lists, dictionaries, sets and tuples, and comprehensions.
  • Day 49: the shape of a real program — named functions, a main(), the if __name__ == "__main__": guard, and input validation.
  • A text editor and a terminal. No experience beyond this course is assumed.

Supported operating systems

  • macOS — fully supported (tested on macOS with 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 if that is how Python is exposed. The tool is pure standard-library Python and behaves identically everywhere.

Hardware requirements

Any computer that runs Python 3. The tool reads and writes a tiny JSON file and does no heavy computation; it needs 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 — argparse, json, and sys all ship with Python. No packages to install. See requirements/README.md.

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 argparse and json modules are part of Python itself.

Installation

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

cd labs/sections/programming-with-python/day-056-building-a-data-driven-cli
python3 --version   # confirm Python 3.8+ is available

File structure

day-056-building-a-data-driven-cli/
├── README.md                       ← you are here
├── metadata.yml                    ← machine-readable lab metadata
├── starter/
│   ├── records.py                  ← YOUR working file (5 numbered exercises)
│   └── cli-worksheet.md            ← design the CLI before coding it
├── examples/
│   └── records.py                  ← complete reference implementation
├── tests/
│   └── run_tests.sh                ← automated checks (output, exit codes, persistence)
├── 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

How to run

From this directory. The global --store option always comes before the subcommand:

## 1. See the finished tool first. Start empty, then add and inspect.
python3 examples/records.py --store demo.json list
python3 examples/records.py --store demo.json add --name "Ada Lovelace" --email ada@example.com
python3 examples/records.py --store demo.json add --name "Alan Turing" --email alan@example.com
python3 examples/records.py --store demo.json list

## 2. Search and delete; watch the exit codes.
python3 examples/records.py --store demo.json find --field name --query ada
python3 examples/records.py --store demo.json find --field name --query zoe   ; echo "exit: $?"
python3 examples/records.py --store demo.json delete --id 1

## 3. See the persisted JSON, then clean it up.
cat demo.json
rm -f demo.json

## 4. Your task: complete the five exercises in the starter, then run it.
python3 starter/records.py --store mine.json add --name "Grace Hopper" --email grace@example.com

## 5. Prove a function is importable (the payoff of the main guard).
python3 -c "import sys; sys.path.insert(0, 'examples'); from records import next_id; print(next_id([]))"

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

What the commands do

  • add --name X --email Y — loads the store, validates the input, appends a new record with the next id, saves the whole list back to JSON, and prints a confirmation. This is the command → load → mutate → save → report loop.
  • list — loads the store and prints every record, or no records when the store is empty or absent.
  • find --field name --query ada — loads the store and prints records whose chosen field contains the query (case-insensitive substring). Exit code 0 if at least one matches, 1 if none do — so a script can branch on the result.
  • delete --id 1 — loads the store, removes the record with that id, and saves. If no record has that id, it prints an error to standard error and exits 1.
  • python3 -c "...next_id..." — imports one function from the module and calls it without running the whole tool, which works only because the main guard holds main back on import.
  • bash tests/run_tests.sh — drives the reference tool through good and bad inputs against a throwaway store, checks output and exit codes, verifies persistence, exercises corrupt-store handling, imports two functions to check their return values, and checks your starter. Exits 0 only if every check passes.

Expected output

See expected-output/sample-run.txt — a real captured session:

$ python3 examples/records.py --store demo.json add --name "Ada Lovelace" --email ada@example.com
added #1: Ada Lovelace <ada@example.com>

$ python3 examples/records.py --store demo.json find --field name --query zoe   ; echo "exit: $?"
no records match name='zoe'
exit: 1

Successful results print to standard output; errors print to standard error and set a non-zero exit code. The tool is deterministic, so your output will match. expected-output/FIELDS.md lists the required behaviour for every input on every platform.

Validation steps

  1. python3 examples/records.py --store demo.json add --name "Ada Lovelace" --email ada@example.com prints added #1: Ada Lovelace <ada@example.com>.
  2. python3 examples/records.py --store demo.json list shows the record; run cat demo.json and confirm the JSON persisted to disk.
  3. python3 examples/records.py --store demo.json find --field name --query zoe; echo $? prints a "no records match" line and then 1.
  4. python3 examples/records.py --store demo.json add --name X --email noatsign; echo $? is rejected with a clear error and exits 1.
  5. Complete the five exercises in starter/records.py, run it on the same inputs, and confirm it matches the reference.
  6. Run the tests (next section) — every check must pass.

Tests

bash tests/run_tests.sh

Expected final line while the starter is unfinished: 19 checks, 0 failure(s). Once you complete all five starter exercises, the suite runs your version through the same good/bad inputs plus the main-guard check, giving 31 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 tool writes only the JSON store you name with --store. Remove any you created:

rm -f demo.json mine.json records.json

To reset your work, restore the starter from git: git checkout -- starter/records.py. The test runner cleans up its own temporary store automatically.

Troubleshooting

See troubleshooting.md for the full list: python vs python3, why --store goes before the subcommand, argparse's exit code 2 versus the tool's exit code 1, why find can exit 1 on purpose, corrupt stores, importing vs running, and permissions.

Security notes

See security.md. Short version: the tool makes no network calls and needs no privileges; it reads and writes only the store you name. Its central habit is to validate input and parse with json, never eval() it — JSON parsing turns text into data and cannot execute code.

Extension exercises

  1. Add an update subcommand that changes the email of a record by id, reusing load_records/save_records and the same validation.
  2. Make delete idempotent: add a --force flag under which deleting a missing id prints a notice and exits 0 instead of 1, and explain in a comment when each behaviour is the right default.
  3. Add a global --format option (plain or json) so list and find can print machine-readable JSON to standard output, ready to pipe into another tool.
  4. Write your own tests/test_records.py that imports next_id, validate_new, and load_records and asserts their behaviour, printing all tests passed only if every assertion holds.
  • Previous day: Day 55 — Comprehensions and Iterator Thinking (labs/sections/programming-with-python/day-055-comprehensions-and-iterator-thinking/).
  • Next day: Day 57 — begins Week 9, Functions and Program Design (labs/sections/programming-with-python/day-057-.../, to be written).
  • Week 8 project: the Terminal Task Manager, a to-do CLI with add/list/complete/delete persisting to JSON — the same argparse + JSON + dispatch skeleton you build here, one job larger.

Expected output

FIELDS.md

# Expected output — Day 056 lab

These are real captured runs from the authoring machine (macOS, Apple
Silicon, Python 3.14.0, bash 3.2, 2026-07-13). The tool is deterministic:
given the same store and the same commands, it produces the same output and
the same exit codes on every platform Python 3 runs on.

## Files

- `sample-run.txt` — the reference CLI driven through a full session on a
  scratch `demo.json`: list an empty store, add two records, list, find
  (matching and not), delete (present and absent), a rejected bad email,
  the JSON left on disk, and a `python3 -c` import check.
- `test-run.txt` — a full run of `bash tests/run_tests.sh` with the starter
  still unfinished (19 checks, 0 failures). Absolute paths are shown as
  `<repo>`; on your machine they are your real repository path.

## Required behaviour on every platform

A correct CLI must, for a fresh store, produce exactly:

| Command (with `--store demo.json`) | Output (stream) | Exit code |
| --- | --- | --- |
| `list` (empty store) | `no records` (stdout) | 0 |
| `add --name "Ada Lovelace" --email ada@example.com` | `added #1: Ada Lovelace <ada@example.com>` (stdout) | 0 |
| `add --name "Alan Turing" --email alan@example.com` | `added #2: Alan Turing <alan@example.com>` (stdout) | 0 |
| `list` | `#1: ...` then `#2: ...` (stdout) | 0 |
| `find --field name --query ada` | `#1: Ada Lovelace <ada@example.com>` (stdout) | 0 |
| `find --field name --query zoe` | `no records match name='zoe'` (stderr) | 1 |
| `delete --id 1` | `deleted #1` (stdout) | 0 |
| `delete --id 99` | `error: no record with id 99` (stderr) | 1 |
| `add --name "Bad" --email noatsign` | `error: 'noatsign' is not a valid email (needs '@')` (stderr) | 1 |
| `add --name "  " --email x@example.com` | `error: name must not be empty` (stderr) | 1 |

Argparse itself handles usage errors before your code runs: a missing
required option (for example `add --name X` with no `--email`) prints a
usage line to stderr and exits with code **2** — argparse's own convention,
distinct from the code 1 this program uses for its own validation and data
errors.

## Platform notes

- The only visible difference between platforms is the shell prompt (`$`)
  shown before each command; the program's own output is identical.
- The JSON store is written with a two-space indent and a trailing newline,
  so it is human-readable and diffs cleanly under version control.
- `mktemp` is used by the test runner to make a throwaway store. On Linux
  `mktemp -t records-test.XXXXXX` behaves the same as on macOS; if your
  `mktemp` differs, the test still works because the store path is quoted
  and removed on exit.

sample-run.txt

$ python3 examples/records.py --store demo.json list
no records

$ python3 examples/records.py --store demo.json add --name "Ada Lovelace" --email ada@example.com
added #1: Ada Lovelace <ada@example.com>

$ python3 examples/records.py --store demo.json add --name "Alan Turing" --email alan@example.com
added #2: Alan Turing <alan@example.com>

$ python3 examples/records.py --store demo.json list
#1: Ada Lovelace <ada@example.com>
#2: Alan Turing <alan@example.com>

$ python3 examples/records.py --store demo.json find --field name --query ada
#1: Ada Lovelace <ada@example.com>

$ python3 examples/records.py --store demo.json find --field name --query zoe   ; echo "exit: $?"
no records match name='zoe'
exit: 1

$ python3 examples/records.py --store demo.json delete --id 1
deleted #1

$ python3 examples/records.py --store demo.json delete --id 99   ; echo "exit: $?"
error: no record with id 99
exit: 1

$ python3 examples/records.py --store demo.json add --name "Bad" --email noatsign   ; echo "exit: $?"
error: 'noatsign' is not a valid email (needs '@')
exit: 1

$ cat demo.json
[
  {
    "id": 2,
    "name": "Alan Turing",
    "email": "alan@example.com"
  }
]

$ python3 -c "import sys; sys.path.insert(0, 'examples'); from records import next_id; print(next_id([{'id': 4}, {'id': 7}]))"
8

test-run.txt

Testing <repo>/labs/sections/programming-with-python/day-056-building-a-data-driven-cli/examples/records.py ...
  ok: empty store lists 'no records'
  ok: add #1 (Ada)
  ok: add #2 (Alan)
  ok: list shows Ada
  ok: list shows Alan
  ok: find match exits 0
  ok: find no-match exits 1
  ok: find by email field
  ok: delete #1 exits 0
  ok: deleted record is gone
  ok: delete absent exits 1
  ok: empty name rejected
  ok: bad email rejected
Testing corrupt-store handling (examples/records.py) ...
  ok: corrupt store -> error, exit 1
Testing importability of examples/records.py ...
  ok: import next_id computes 1 and 8
  ok: import load_records handles a missing file
Testing starter/records.py ...
  ok: starter is valid Python
Note: starter/records.py still has unfinished exercises — testing structure only.
  ok: starter defines load_records
  ok: starter defines cmd_add

19 checks, 0 failure(s).

Source files

examples/records.py (6386 bytes)
#!/usr/bin/env python3
"""records.py — a small, data-driven command-line tool.

Manages a list of contact records that persist between runs as a JSON file.
It is a complete rehearsal for the Week 8 project (the Terminal Task
Manager): the same skeleton of argparse subcommands, a JSON store, records
modelled as a list of dicts, and the command -> load -> mutate -> save ->
report loop, with proper exit codes and errors on standard error.

Subcommands:
    add     add a new record
    list    list every record
    find    search records by a field
    delete  remove a record by id

The data file is chosen with the global --store option, which goes BEFORE
the subcommand:

    python3 records.py --store data.json add --name "Ada Lovelace" --email ada@example.com
    python3 records.py --store data.json list
    python3 records.py --store data.json find --field name --query ada
    python3 records.py --store data.json delete --id 1

Input comes entirely from command-line arguments (never an interactive
prompt), so the tool can be tested and automated without a human.
"""
import argparse
import json
import sys


def load_records(path):
    """Return the list of records stored at path.

    A missing file is treated as an empty store (the first run is not an
    error). A file that exists but is not valid JSON, or does not hold a
    JSON list, raises ValueError with a readable message.
    """
    try:
        with open(path, "r", encoding="utf-8") as handle:
            data = json.load(handle)
    except FileNotFoundError:
        return []
    except json.JSONDecodeError as err:
        raise ValueError(f"{path} is not valid JSON ({err})")
    if not isinstance(data, list):
        raise ValueError(f"{path} does not hold a list of records")
    return data


def save_records(path, records):
    """Write records to path as pretty-printed, human-diffable JSON."""
    with open(path, "w", encoding="utf-8") as handle:
        json.dump(records, handle, indent=2, ensure_ascii=False)
        handle.write("\n")


def next_id(records):
    """Return the next id: one more than the largest existing id, or 1."""
    return max((record["id"] for record in records), default=0) + 1


def validate_new(name, email):
    """Raise ValueError if a new record's name or email is unacceptable."""
    if not name.strip():
        raise ValueError("name must not be empty")
    if "@" not in email:
        raise ValueError(f"'{email}' is not a valid email (needs '@')")


def format_record(record):
    """Return the one-line, human-readable form of a record."""
    return f"#{record['id']}: {record['name']} <{record['email']}>"


def cmd_add(args):
    """add: load, append a validated record, save, and report it."""
    records = load_records(args.store)
    validate_new(args.name, args.email)
    record = {
        "id": next_id(records),
        "name": args.name.strip(),
        "email": args.email.strip(),
    }
    records.append(record)
    save_records(args.store, records)
    print(f"added {format_record(record)}")
    return 0


def cmd_list(args):
    """list: print every record, or a clear notice when the store is empty."""
    records = load_records(args.store)
    if not records:
        print("no records")
        return 0
    for record in records:
        print(format_record(record))
    return 0


def cmd_find(args):
    """find: print records whose field contains the query.

    Returns exit code 0 when at least one record matches and 1 when none
    do, so a caller (or a script) can branch on "did we find anything?".
    """
    records = load_records(args.store)
    needle = args.query.lower()
    matches = [
        record
        for record in records
        if needle in str(record.get(args.field, "")).lower()
    ]
    if not matches:
        print(f"no records match {args.field}={args.query!r}", file=sys.stderr)
        return 1
    for record in matches:
        print(format_record(record))
    return 0


def cmd_delete(args):
    """delete: remove the record with the given id, or report it is absent."""
    records = load_records(args.store)
    kept = [record for record in records if record["id"] != args.id]
    if len(kept) == len(records):
        print(f"error: no record with id {args.id}", file=sys.stderr)
        return 1
    save_records(args.store, kept)
    print(f"deleted #{args.id}")
    return 0


def build_parser():
    """Build the argparse parser: a global --store plus four subcommands."""
    parser = argparse.ArgumentParser(
        prog="records.py",
        description="Manage a JSON-backed list of contact records.",
    )
    parser.add_argument(
        "--store",
        default="records.json",
        help="path to the JSON data file (default: records.json)",
    )
    subparsers = parser.add_subparsers(
        dest="command",
        required=True,
        metavar="{add,list,find,delete}",
    )

    add_parser = subparsers.add_parser("add", help="add a new record")
    add_parser.add_argument("--name", required=True, help="the contact's name")
    add_parser.add_argument("--email", required=True, help="the contact's email")
    add_parser.set_defaults(func=cmd_add)

    list_parser = subparsers.add_parser("list", help="list every record")
    list_parser.set_defaults(func=cmd_list)

    find_parser = subparsers.add_parser("find", help="search records by a field")
    find_parser.add_argument(
        "--field",
        default="name",
        choices=["name", "email"],
        help="which field to search (default: name)",
    )
    find_parser.add_argument("--query", required=True, help="text to search for")
    find_parser.set_defaults(func=cmd_find)

    delete_parser = subparsers.add_parser("delete", help="delete a record by id")
    delete_parser.add_argument(
        "--id", type=int, required=True, help="the id of the record to delete"
    )
    delete_parser.set_defaults(func=cmd_delete)

    return parser


def main(argv):
    """Entry point. Parse arguments, dispatch to the chosen command, and
    turn any validation or data error into a clear message plus exit code 1.
    """
    parser = build_parser()
    args = parser.parse_args(argv[1:])
    try:
        return args.func(args)
    except ValueError as err:
        print(f"error: {err}", file=sys.stderr)
        return 1


if __name__ == "__main__":
    sys.exit(main(sys.argv))
metadata.yml (857 bytes)
lesson_id: D056
day: 56
kind: python-program
languages: [python]
setup_commands:
  - cd labs/sections/programming-with-python/day-056-building-a-data-driven-cli
  - python3 --version
run_commands:
  - python3 examples/records.py --store demo.json add --name "Ada Lovelace" --email ada@example.com
  - python3 examples/records.py --store demo.json list
  - python3 examples/records.py --store demo.json find --field name --query ada
  - python3 examples/records.py --store demo.json delete --id 1
test_commands:
  - bash tests/run_tests.sh
cleanup_commands:
  - rm -f demo.json
  - 'git checkout -- starter/records.py  # optional: reset your work'
requires_network: false
requires_api_key: false
estimated_minutes: 30
last_executed: '2026-07-13'
executed_on: 'macOS (Apple Silicon), Python 3.14.0, bash tests/run_tests.sh -> 19 checks, 0 failure(s), exit 0'
requirements/README.md (906 bytes)
# Dependencies — Day 056 lab

**Python 3 only. No third-party packages.**

- `python3` (3.8 or newer; tested on 3.14.0). Preinstalled on most Linux
  distributions and installable on macOS; you set this up on Day 43.
- `bash` for the test runner (preinstalled on macOS and Linux).
- Only the Python standard library is used — specifically `argparse` and
  `json` (and `sys`), all of which ship with Python. There is deliberately
  no `requirements.txt`: a command-line tool this size should run on a plain
  Python install with nothing to download 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. The tool itself is pure standard-library
Python and behaves identically everywhere.
starter/cli-worksheet.md (1593 bytes)
# CLI design worksheet

Fill this in *before* writing code for the practice assignment. Designing a
CLI on paper first — its subcommands, arguments, data shape, and exit codes —
is exactly the discipline the lesson teaches. One row per subcommand.

## The tool

- **Name of the tool:**
- **One job it does:**
- **Where the data lives (JSON file path/default):**
- **Shape of one record (the dict keys and their types):**

## Subcommands

| Subcommand | Positional args | Options / flags | What it mutates | Prints (stdout) | Exit code(s) |
| ---------- | --------------- | --------------- | --------------- | --------------- | ------------ |
| add        |                 |                 |                 |                 |              |
| list       |                 |                 |                 |                 |              |
| find       |                 |                 |                 |                 |              |
| delete     |                 |                 |                 |                 |              |

## Validation and errors

List at least three bad inputs the tool must reject, and for each: the
message it prints (to **stderr**) and the exit code it returns.

1.
2.
3.

## Idempotence check

For each subcommand, write whether running it twice with the same arguments
leaves the store in the same state as running it once (idempotent) or not,
and why.

- add:
- list:
- find:
- delete:

## Recorded behaviour (fill in after you build it)

- One good run (command and its output):
- One bad run (command, stderr message, and `echo $?`):
starter/records.py (6323 bytes)
#!/usr/bin/env python3
"""records.py — YOUR working file.

Build this data-driven CLI one exercise at a time. Each numbered exercise
below names exactly what to write. The finished reference is in
examples/records.py — try each exercise yourself before peeking.

When all five exercises are done, this file behaves like the reference:

    python3 starter/records.py --store data.json add --name "Ada" --email ada@example.com
    python3 starter/records.py --store data.json list
    python3 starter/records.py --store data.json find --field name --query ada
    python3 starter/records.py --store data.json delete --id 1

Then run:  bash tests/run_tests.sh
"""
import argparse
import json
import sys


def load_records(path):
    """Return the list of records stored at path (missing file => empty)."""
    # Exercise 1: LOAD JSON.
    # 1. Open path for reading and use json.load to read the data.
    # 2. If the file does not exist (FileNotFoundError), return [] — the
    #    first run is not an error.
    # 3. If json.load raises json.JSONDecodeError, raise ValueError with a
    #    clear message naming the file.
    # 4. If the data is not a list, raise ValueError.
    # 5. Otherwise return the data.
    raise NotImplementedError("Exercise 1: implement load_records")


def save_records(path, records):
    """Write records to path as pretty-printed JSON. (Provided.)"""
    with open(path, "w", encoding="utf-8") as handle:
        json.dump(records, handle, indent=2, ensure_ascii=False)
        handle.write("\n")


def next_id(records):
    """Return the next id: one more than the largest existing id, or 1."""
    # Exercise 2: COMPUTE THE NEXT ID.
    # Return 1 + the maximum "id" among records, or 1 when records is empty.
    # Hint: max((r["id"] for r in records), default=0) + 1
    raise NotImplementedError("Exercise 2: implement next_id")


def validate_new(name, email):
    """Raise ValueError if a new record's name or email is bad. (Provided.)"""
    if not name.strip():
        raise ValueError("name must not be empty")
    if "@" not in email:
        raise ValueError(f"'{email}' is not a valid email (needs '@')")


def format_record(record):
    """Return the one-line, human-readable form of a record. (Provided.)"""
    return f"#{record['id']}: {record['name']} <{record['email']}>"


def cmd_add(args):
    """add: load, append a validated record, save, and report it."""
    # Exercise 3: THE COMMAND LOOP (load -> mutate -> save -> report).
    # 1. records = load_records(args.store)
    # 2. validate_new(args.name, args.email)
    # 3. build record = {"id": next_id(records), "name": args.name.strip(),
    #    "email": args.email.strip()}
    # 4. append it to records, then save_records(args.store, records)
    # 5. print(f"added {format_record(record)}") and return 0
    raise NotImplementedError("Exercise 3: implement cmd_add")


def cmd_list(args):
    """list: print every record, or a notice when empty. (Provided.)"""
    records = load_records(args.store)
    if not records:
        print("no records")
        return 0
    for record in records:
        print(format_record(record))
    return 0


def cmd_find(args):
    """find: print matching records; exit 0 if any, 1 if none."""
    # Exercise 4: SEARCH WITH EXIT CODES.
    # 1. records = load_records(args.store)
    # 2. needle = args.query.lower()
    # 3. matches = every record where needle is in the chosen field,
    #    compared case-insensitively:
    #        str(record.get(args.field, "")).lower()
    # 4. If there are no matches: print a message to sys.stderr and return 1.
    # 5. Otherwise print each match with format_record and return 0.
    raise NotImplementedError("Exercise 4: implement cmd_find")


def cmd_delete(args):
    """delete: remove the record with the given id, or report it. (Provided.)"""
    records = load_records(args.store)
    kept = [record for record in records if record["id"] != args.id]
    if len(kept) == len(records):
        print(f"error: no record with id {args.id}", file=sys.stderr)
        return 1
    save_records(args.store, kept)
    print(f"deleted #{args.id}")
    return 0


def build_parser():
    """Build the argparse parser: a global --store plus four subcommands. (Provided.)"""
    parser = argparse.ArgumentParser(
        prog="records.py",
        description="Manage a JSON-backed list of contact records.",
    )
    parser.add_argument(
        "--store",
        default="records.json",
        help="path to the JSON data file (default: records.json)",
    )
    subparsers = parser.add_subparsers(
        dest="command",
        required=True,
        metavar="{add,list,find,delete}",
    )

    add_parser = subparsers.add_parser("add", help="add a new record")
    add_parser.add_argument("--name", required=True, help="the contact's name")
    add_parser.add_argument("--email", required=True, help="the contact's email")
    add_parser.set_defaults(func=cmd_add)

    list_parser = subparsers.add_parser("list", help="list every record")
    list_parser.set_defaults(func=cmd_list)

    find_parser = subparsers.add_parser("find", help="search records by a field")
    find_parser.add_argument(
        "--field",
        default="name",
        choices=["name", "email"],
        help="which field to search (default: name)",
    )
    find_parser.add_argument("--query", required=True, help="text to search for")
    find_parser.set_defaults(func=cmd_find)

    delete_parser = subparsers.add_parser("delete", help="delete a record by id")
    delete_parser.add_argument(
        "--id", type=int, required=True, help="the id of the record to delete"
    )
    delete_parser.set_defaults(func=cmd_delete)

    return parser


def main(argv):
    """Entry point: parse arguments, dispatch, and turn errors into exit 1. (Provided.)"""
    parser = build_parser()
    args = parser.parse_args(argv[1:])
    try:
        return args.func(args)
    except ValueError as err:
        print(f"error: {err}", file=sys.stderr)
        return 1


# Exercise 5: ADD THE MAIN GUARD.
# Below this comment, add the guard so the program runs only when this file
# is executed directly (not when it is imported), passing main's return
# value to sys.exit:
#
#     if __name__ == "__main__":
#         sys.exit(main(sys.argv))
tests/run_tests.sh (5839 bytes)
#!/usr/bin/env bash
# Tests for the Day 056 lab. Run from the lab directory:
#   bash tests/run_tests.sh
#
# Exercises the complete reference CLI (examples/records.py) against a
# throwaway JSON store: add, list, find (found and not-found), delete
# (present and absent), plus input-validation and corrupt-store errors.
# Every check verifies BOTH the printed output and the process exit code.
# It then imports two functions from the module to check their return
# values, and finally checks the learner's starter — 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

# Keep the working tree clean: do not let imported modules write __pycache__.
export PYTHONDONTWRITEBYTECODE=1

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

# A fresh temporary store per run; removed on exit.
store="$(mktemp -t records-test.XXXXXX)"
rm -f "${store}"                       # start from "no store yet"
cleanup() { rm -f "${store}"; }
trap cleanup EXIT

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_run <label> <script> <expect_exit> <needle> <arg...>
# Runs the CLI with --store, checks the exit code and that combined output
# contains needle.
check_run() {
  local label="$1" script="$2" expect_exit="$3" needle="$4"
  shift 4
  local out code
  out="$(python3 "${script}" --store "${store}" "$@" 2>&1)"
  code=$?
  if [ "${code}" -eq "${expect_exit}" ] && printf '%s' "${out}" | grep -qF "${needle}"; then
    check "${label}" "yes"
  else
    check "${label}" "no"
    echo "    (exit ${code}, expected ${expect_exit}; output: ${out})"
  fi
}

run_cli_checks() {
  local script="$1"
  echo "Testing ${script} ..."
  rm -f "${store}"                              # fresh store for this program
  # Empty store lists cleanly.
  check_run "empty store lists 'no records'" "${script}" 0 "no records" list
  # Add two records: exit 0 and a confirmation line each.
  check_run "add #1 (Ada)"   "${script}" 0 "added #1: Ada Lovelace" add --name "Ada Lovelace" --email ada@example.com
  check_run "add #2 (Alan)"  "${script}" 0 "added #2: Alan Turing"  add --name "Alan Turing"  --email alan@example.com
  # List shows both.
  check_run "list shows Ada"  "${script}" 0 "#1: Ada Lovelace" list
  check_run "list shows Alan" "${script}" 0 "#2: Alan Turing"  list
  # Find: match -> exit 0; no match -> exit 1.
  check_run "find match exits 0"    "${script}" 0 "#1: Ada Lovelace" find --field name --query ada
  check_run "find no-match exits 1" "${script}" 1 "no records match" find --field name --query zoe
  check_run "find by email field"   "${script}" 0 "#2: Alan Turing"  find --field email --query alan@example.com
  # Delete: present -> exit 0; then it is gone; absent -> exit 1.
  check_run "delete #1 exits 0"      "${script}" 0 "deleted #1"          delete --id 1
  check_run "deleted record is gone" "${script}" 1 "no records match"    find --field name --query ada
  check_run "delete absent exits 1"  "${script}" 1 "no record with id 99" delete --id 99
  # Validation: empty name and bad email are rejected with exit 1.
  check_run "empty name rejected" "${script}" 1 "name must not be empty"     add --name "  " --email x@example.com
  check_run "bad email rejected"  "${script}" 1 "is not a valid email"       add --name "Bad" --email noatsign
}

# --- Reference CLI: always tested strictly ---
run_cli_checks "${ref}"

# --- Corrupt store is reported, not crashed (reference only) ---
echo "Testing corrupt-store handling (examples/records.py) ..."
printf 'this is not json {' > "${store}"
corrupt_out="$(python3 "${ref}" --store "${store}" list 2>&1)"
corrupt_code=$?
if [ "${corrupt_code}" -eq 1 ] && printf '%s' "${corrupt_out}" | grep -qF "is not valid JSON"; then
  check "corrupt store -> error, exit 1" "yes"
else
  check "corrupt store -> error, exit 1" "no"
  echo "    (exit ${corrupt_code}; output: ${corrupt_out})"
fi
rm -f "${store}"

# --- Import functions and check return values (main-guard payoff) ---
echo "Testing importability of examples/records.py ..."
if python3 -c "import sys; sys.path.insert(0, '${lab_dir}/examples'); \
from records import next_id; \
assert next_id([]) == 1; assert next_id([{'id': 4}, {'id': 7}]) == 8"; then
  check "import next_id computes 1 and 8" "yes"
else
  check "import next_id computes 1 and 8" "no"
fi
if python3 -c "import sys; sys.path.insert(0, '${lab_dir}/examples'); \
from records import load_records; \
assert load_records('${lab_dir}/does-not-exist.json') == []"; then
  check "import load_records handles a missing file" "yes"
else
  check "import load_records handles a missing file" "no"
fi

# --- Learner starter ---
echo "Testing starter/records.py ..."
if python3 -c "compile(open('${starter}').read(), '${starter}', 'exec')" 2>/dev/null; then
  check "starter is valid Python" "yes"
else
  check "starter is valid Python" "no"
fi

if grep -q 'NotImplementedError' "${starter}"; then
  echo "Note: starter/records.py still has unfinished exercises — testing structure only."
  grep -q 'def load_records' "${starter}" && check "starter defines load_records" "yes" || check "starter defines load_records" "no"
  grep -q 'def cmd_add' "${starter}" && check "starter defines cmd_add" "yes" || check "starter defines cmd_add" "no"
else
  run_cli_checks "${starter}"
  grep -q '__name__ == "__main__"' "${starter}" && check "starter has the main guard" "yes" || check "starter has the main guard" "no"
fi

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

Troubleshooting

Troubleshooting — Day 056 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 when I run it

That is expected until you finish the exercises. Each unfinished function raises NotImplementedError on purpose so you cannot mistake an empty function for a working one. Replace each raise NotImplementedError(...) line with the real body described in the comment above it. Once all five exercises are done, the file behaves like the reference.

records.py: error: unrecognized arguments when I pass --store

The global --store option must come before the subcommand, because it belongs to the top-level parser, not to add/list/find/delete:

python3 examples/records.py --store demo.json add --name X --email x@example.com   # correct
python3 examples/records.py add --store demo.json --name X --email x@example.com   # wrong

This ordering — global options first, then the subcommand and its own options — is standard for tools built on subcommands (think git --version versus git commit -m).

error: the following arguments are required: --email (exit code 2)

That message comes from argparse itself, not from your code: a required option was missing. Argparse prints the usage line and exits with code 2 for usage mistakes, which is different from the code 1 this program uses for its own validation errors (a bad email, an empty name) and data errors (a corrupt store). Two different failure codes, two different causes.

find printed nothing and echo $? shows 1

That is correct behaviour, not a bug: find returns exit code 1 when nothing matches, and prints the "no records match ..." line to standard error. A script can use that exit code to decide what to do next. If you expected a match, check the spelling of your query and which --field you searched (name or email); the search is case-insensitive but matches a substring, so find --field name --query ada finds "Ada Lovelace".

is not valid JSON when I run any command

The store file exists but its contents are not valid JSON (perhaps you edited it by hand and left a stray character). Either fix the JSON or delete the file and start fresh: rm -f demo.json. A missing store is fine — the tool treats it as empty and creates it on the first add.

My edits to the store disappeared / two runs fought over the file

Each add and delete loads the whole file, changes the list in memory, and writes the whole file back. If two processes do that at the same time, the second write can overwrite the first. For this single-user lab that is not a concern, but it is why real multi-user tools reach for a database — a point the lesson makes.

ModuleNotFoundError: No module named 'records' in the import check

Python looks for modules on its search path (sys.path), which does not include the examples/ subfolder by default. The import one-liners add it first:

python3 -c "import sys; sys.path.insert(0, 'examples'); from records import next_id; print(next_id([]))"

Run it from the lab directory (the folder that contains examples/).

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.

Security notes

Security notes — Day 056 lab

  • What the tool does: reads command-line arguments, reads and writes one JSON file you name with --store, and prints results. It makes no network connections, needs no privileges, and touches no file except the store you point it at. The test runner uses a throwaway store created with mktemp and removes it on exit.

  • Validate input; never execute it. Records enter through argparse and are checked (validate_new rejects an empty name and an email with no @) before anything is saved. Data is read with json.load, which parses text into plain Python values — it can never run code. Do not reach for eval()/exec() to "parse" a record or a query; those execute the string as Python, so a malicious value could delete files or open a network connection. Parsing with json and validating at the boundary is the safe pattern, and it is exactly what this tool does.

  • Treat the store as untrusted on read. A JSON file can be edited by anyone with access to it, so the tool does not assume it is well-formed: a corrupt file becomes a clear error and exit code 1, not a crash. When you build the Week 8 project, keep this habit — never trust that the file you load is the file you last wrote.

  • Watch what you write to a shared file. Because each mutation rewrites the whole file, two programs writing at once can lose data (a "race"). It is harmless for this single-user lab, but it is why production tools use a database or file locking. Do not point --store at a file another program is also writing.

  • Fail loudly, not silently. Every error path prints a message to standard error and returns a non-zero exit code, so a person or a script notices. Silent failure — saving nothing, or saving something wrong, while reporting success — is worse than a crash.

  • Reading before running: every file in this lab is short and commented. Read examples/records.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.