Programming with Python › Control Flow and Collections › Day 56
Hands-on lab — Day 56: Building a Data-Driven CLI
- ← Back to the Day 56 lesson
- Open the hands-on files on GitHub — clone or download them from the public labs repository
- Local path in your clone:
labs/sections/programming-with-python/day-056-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
- Lesson title: Building a Data-Driven CLI
- Day number: 56 of 365
- Lesson article: https://ai-roadmap-365.github.io/day-056-building-a-data-driven-cli
- 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-056-building-a-data-driven-cliwhen the site is running.
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.loadandjson.dumpso 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(), theif __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
pythonforpython3if 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).bashfor the test runner (preinstalled on macOS and Linux).- Standard library only —
argparse,json, andsysall ship with Python. No packages to install. Seerequirements/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, orno recordswhen 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 holdsmainback 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
python3 examples/records.py --store demo.json add --name "Ada Lovelace" --email ada@example.comprintsadded #1: Ada Lovelace <ada@example.com>.python3 examples/records.py --store demo.json listshows the record; runcat demo.jsonand confirm the JSON persisted to disk.python3 examples/records.py --store demo.json find --field name --query zoe; echo $?prints a "no records match" line and then1.python3 examples/records.py --store demo.json add --name X --email noatsign; echo $?is rejected with a clear error and exits1.- Complete the five exercises in
starter/records.py, run it on the same inputs, and confirm it matches the reference. - 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
- Add an
updatesubcommand that changes the email of a record by id, reusingload_records/save_recordsand the same validation. - Make
deleteidempotent: add a--forceflag 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. - Add a global
--formatoption (plainorjson) solistandfindcan print machine-readable JSON to standard output, ready to pipe into another tool. - Write your own
tests/test_records.pythat importsnext_id,validate_new, andload_recordsand asserts their behaviour, printingall tests passedonly if every assertion holds.
Navigation
- 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 withmktempand removes it on exit. -
Validate input; never execute it. Records enter through argparse and are checked (
validate_newrejects an empty name and an email with no@) before anything is saved. Data is read withjson.load, which parses text into plain Python values — it can never run code. Do not reach foreval()/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 withjsonand 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
--storeat 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.pyandtests/run_tests.shbefore 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.