Programming with Python › Functions and Program Design › Day 63
Hands-on lab — Day 63: Designing a Small Program Well
- ← Back to the Day 63 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-063-designing-a-small-program-well/
Commands
Setup
cd labs/sections/programming-with-python/day-063-designing-a-small-program-well
python3 --version Run
echo "10 20 30 40" | python3 examples/summary.py
printf "5, 7, 9, 11\n" > scores.txt && python3 examples/summary.py scores.txt && rm -f scores.txt
echo "1 two 3" | python3 examples/summary.py
PYTHONPATH=examples python3 -c "import summary_core as c; print(c.summarize([2, 4, 6, 8]))" Test
bash tests/run_tests.sh File tree
examples/summary_core.py examples/summary.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/design-worksheet.md starter/summary_core.py starter/summary.py tests/run_tests.sh troubleshooting.md
Lab README
Day 063 lab — Design It First
Lesson
- Lesson title: Designing a Small Program Well
- Day number: 63 of 365
- Lesson article: https://ai-roadmap-365.github.io/day-063-designing-a-small-program-well
- 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-063-designing-a-small-program-wellwhen the site is running.
Purpose
Day 63 is the Week 9 capstone: it turns the week's pieces — functions,
scope, modules, the standard library, readable code, and recursion — into a
way of designing a small program well. This lab makes that concrete. From
a one-paragraph spec you build summary, a tiny numbers-summarizing tool,
deliberately split into two halves: a pure functional core
(summary_core.py, all logic, no I/O) and a thin imperative shell
(summary.py, all the I/O, no logic). You fill in the core from a starter
whose function signatures and docstrings are written first — docstring-driven
design — then run a test suite that exercises the pure core directly with
plain function calls, no fake files required. This is a smaller, self-contained
rehearsal for the Week 9 project, the Flashcard Study App, which uses the
same core/shell split scaled up to a multi-command program.
Learning objectives
- Start from a spec and examples, then design function signatures (with docstrings) before writing any bodies.
- Split a program into a pure functional core (logic, no I/O) and a thin imperative shell (argv, files, stdout, exit code).
- Give each function a single responsibility and keep the core easy to test.
- Test the pure core directly with plain function calls, and the shell end to end through stdin, a file, and error cases.
- Practise incremental development and YAGNI: build the smallest working version first and leave out what the spec does not ask for.
Prerequisites
- The Day 63 lesson (read it first — it walks this exact tool end to end).
- Days 57–62: functions and return values, scope and
*args/**kwargs, modules and imports, the standard library, readable code, and recursion. - Day 56: the shape of a data-driven program, and running a script from the terminal.
- 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 a little text and prints a few lines; 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 — the shell uses
sys; the pure core uses no imports at all. 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.
Installation
None beyond Python itself. Move into this directory and you are ready:
cd labs/sections/programming-with-python/day-063-designing-a-small-program-well
python3 --version # confirm Python 3.8+ is available
File structure
day-063-designing-a-small-program-well/
├── README.md ← you are here
├── metadata.yml ← machine-readable lab metadata
├── starter/
│ ├── summary_core.py ← YOUR working file (3 numbered exercises: the pure core)
│ ├── summary.py ← the shell, provided complete (do not edit)
│ └── design-worksheet.md ← design a second program before coding it
├── examples/
│ ├── summary_core.py ← complete reference core (pure logic, no I/O)
│ └── summary.py ← complete reference shell (all the I/O)
├── tests/
│ └── run_tests.sh ← core tests (direct calls) + shell tests (end to end)
├── 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:
## 1. See the finished tool first. Feed it numbers on standard input.
echo "10 20 30 40" | python3 examples/summary.py
## 2. Or summarize numbers from a file.
printf "5, 7, 9, 11\n" > scores.txt
python3 examples/summary.py scores.txt
rm -f scores.txt
## 3. Watch the error paths (they exit with code 1).
echo "1 two 3" | python3 examples/summary.py ; echo "exit: $?"
printf "" | python3 examples/summary.py ; echo "exit: $?"
## 4. Call the PURE CORE directly — no files, no shell — the payoff of the split.
PYTHONPATH=examples python3 -c "import summary_core as c; print(c.summarize([2, 4, 6, 8]))"
## 5. Your task: complete the three exercises in starter/summary_core.py, then run it.
echo "3 6 9" | python3 starter/summary.py
## 6. Check your work.
bash tests/run_tests.sh
What the commands do
echo "..." | python3 examples/summary.py— the shell reads the numbers from standard input, calls the pure core to parse and summarize them, and prints the six-line summary to standard output.python3 examples/summary.py scores.txt— the same, but the shell reads the numbers from the file you name instead of standard input.echo "1 two 3" | ...andprintf "" | ...— the error paths: a non-numeric token and empty input each print a message to standard error and exit with code 1, so a script can detect the failure.PYTHONPATH=examples python3 -c "...summarize..."— calls a core function directly with a plain Python list. This works with no files and no shell because the core is pure — the whole point of the design.bash tests/run_tests.sh— runs the pure core through direct function-call checks (parse, summarize, format, and their error cases), then runs the shell end to end through stdin, a file, and error inputs, then checks your starter. Exits 0 only if every check passes.
Expected output
See expected-output/sample-run.txt — a
real captured session:
$ echo "10 20 30 40" | python3 examples/summary.py
count 4
total 100.00
mean 25.00
minimum 10.00
maximum 40.00
above mean 2
$ echo "1 two 3" | python3 examples/summary.py ; echo "exit: $?"
error: 'two' is not a number
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 of the core and the shell on every platform.
Validation steps
echo "10 20 30 40" | python3 examples/summary.pyprints a six-line summary ending inabove mean 2and exits 0.echo "1 two 3" | python3 examples/summary.py; echo $?printserror: 'two' is not a numberto standard error and then1.printf "" | python3 examples/summary.py; echo $?printserror: cannot summarize an empty list of numbersand then1.PYTHONPATH=examples python3 -c "import summary_core as c; print(c.summarize([2,4,6,8])['above_mean'])"prints2— the core called directly, with no I/O.- Complete the three exercises in
starter/summary_core.py, runecho "3 6 9" | python3 starter/summary.py, 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: 15 checks, 0 failure(s). Once you complete all three starter exercises, the suite runs
your core through the same direct checks plus the shell end to end, giving
23 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 nothing on its own. Remove any input file you created:
rm -f scores.txt
To reset your work, restore the starter from git:
git checkout -- starter/summary_core.py. The test runner cleans up its own
temporary input file automatically.
Troubleshooting
See troubleshooting.md for the full list: python vs
python3, ModuleNotFoundError for the core, why the tool has no
subcommands (YAGNI), the two error messages, decimal formatting, and
permissions.
Security notes
See security.md. Short version: the tool makes no network
calls and needs no privileges; the pure core writes nothing at all and the
shell only reads. Its central habit is to validate input at the boundary
with float(), never eval() it — and to keep every side effect in the
one small, readable shell.
Extension exercises
- Add a
medianto the summary. Decide first: does it belong in the pure core or the shell? (The core — it is logic.) Write its signature and docstring before its body, and add a direct test for it. - Add a
--round Nidea withoutargparse: let the shell read an optional second argument for the number of decimal places and pass it into a changedformat_summary(summary, places=2). Keepformat_summarypure. - Refactor
format_summaryso the label/width list is defined once instead of repeated per line, and confirm the tests still pass — proof that a good test suite lets you refactor without fear. - Write your own
tests/test_core.pythat importsparse_numbers,summarize, andformat_summaryand asserts their behaviour, printingall tests passedonly if every assertion holds, and confirm it exits 0.
Navigation
- Previous day: Day 62 — Recursion
(
labs/sections/programming-with-python/day-062-recursion/). - Next day: Day 64 — begins Week 10, Python in Practice
(
labs/sections/programming-with-python/day-064-.../, to be written). - Week 9 project: the Flashcard Study App, a spaced-repetition flashcard CLI organized into clean modules with documented functions — the same functional-core / imperative-shell design you rehearse here, scaled up to a multi-command program.
Expected output
FIELDS.md
# Expected output — Day 063 lab
These are real captured runs from the authoring machine (macOS, Apple
Silicon, Python 3.14.0, bash 3.2, 2026-07-13). The program is
deterministic: given the same input it produces the same output and the
same exit code on every platform Python 3 runs on.
## Files
- `sample-run.txt` — the reference tool driven through a full session:
summarize numbers from stdin, summarize numbers from a file, a rejected
non-numeric token, rejected empty input, and one direct call into the
pure core (`summary_core.summarize(...)`) to show the core works with no
I/O at all.
- `test-run.txt` — a full run of `bash tests/run_tests.sh` with the starter
still unfinished (15 checks, 0 failures). Absolute paths are shown as
`<repo>`; on your machine they are your real repository path.
## Required behaviour on every platform
The pure core (`summary_core.py`) must satisfy exactly:
| Call | Result |
| --- | --- |
| `parse_numbers("1, 2\n3\t4")` | `[1.0, 2.0, 3.0, 4.0]` |
| `parse_numbers(" ")` | `[]` |
| `parse_numbers("1 two 3")` | raises `ValueError` mentioning `'two'` |
| `summarize([2, 4, 6, 8])` | `{"count": 4, "total": 20, "mean": 5.0, "minimum": 2, "maximum": 8, "above_mean": 2}` |
| `summarize([])` | raises `ValueError` (cannot summarize an empty list) |
| `format_summary(summarize([10, 20, 30]))` | six aligned lines; includes `count 3`, `mean 20.00`, `above mean 1` |
The shell (`summary.py`) must satisfy exactly, for a fresh input:
| Command | Output (stream) | Exit code |
| --- | --- | --- |
| `echo "10 20 30" \| python3 summary.py` | the six-line summary, `mean 20.00` (stdout) | 0 |
| `python3 summary.py numbers.txt` (valid file) | the six-line summary (stdout) | 0 |
| `echo "1 two 3" \| python3 summary.py` | `error: 'two' is not a number` (stderr) | 1 |
| `printf "" \| python3 summary.py` | `error: cannot summarize an empty list of numbers` (stderr) | 1 |
| `python3 summary.py missing.txt` | `error: [Errno 2] No such file or directory: ...` (stderr) | 1 |
## Platform notes
- The only visible difference between platforms is the shell prompt (`$`)
shown before each command; the program's own output is identical.
- The exact text of the missing-file error (`[Errno 2] No such file or
directory: ...`) is produced by the operating system through Python's
`OSError`, so the wording can differ slightly between platforms and
Python versions. The tests therefore check only for the `error:` prefix
and exit code 1 on that case, not the full OS message.
- Floating-point formatting uses `:.2f`, which rounds to two decimals
identically across platforms; integer statistics (count, above mean)
print with no decimals.
sample-run.txt
$ echo "10 20 30 40" | python3 examples/summary.py
count 4
total 100.00
mean 25.00
minimum 10.00
maximum 40.00
above mean 2
$ printf "5, 7, 9, 11\n" > scores.txt
$ python3 examples/summary.py scores.txt
count 4
total 32.00
mean 8.00
minimum 5.00
maximum 11.00
above mean 2
$ echo "1 two 3" | python3 examples/summary.py ; echo "exit: $?"
error: 'two' is not a number
exit: 1
$ printf "" | python3 examples/summary.py ; echo "exit: $?"
error: cannot summarize an empty list of numbers
exit: 1
$ rm -f scores.txt
$ PYTHONPATH=examples python3 -c "import summary_core as c; print(c.summarize([2, 4, 6, 8]))"
{'count': 4, 'total': 20, 'mean': 5.0, 'minimum': 2, 'maximum': 8, 'above_mean': 2}
test-run.txt
Testing the pure core in <repo>/labs/sections/programming-with-python/day-063-designing-a-small-program-well/examples (plain function calls, no I/O) ...
ok: parse_numbers reads mixed separators
ok: parse_numbers of blank text is []
ok: parse_numbers rejects a non-number
ok: summarize computes the six statistics
ok: summarize rejects an empty list
ok: format_summary returns aligned text
Testing the shell <repo>/labs/sections/programming-with-python/day-063-designing-a-small-program-well/examples/summary.py end to end ...
ok: stdin: three numbers summarized
ok: stdin: bad token -> error, exit 1
ok: stdin: empty input -> error, exit 1
ok: file input summarized
ok: missing file -> error, exit 1
Testing starter/summary_core.py ...
ok: starter core is valid Python
Note: starter/summary_core.py still has unfinished exercises — testing structure only.
ok: starter defines parse_numbers
ok: starter defines summarize
ok: starter defines format_summary
15 checks, 0 failure(s).
Source files
examples/summary_core.py (3200 bytes)
"""summary_core.py — the functional core of the summary tool.
Every function in this module is PURE: it takes values in and returns
values out, with no reading, no printing, no files, and no global state.
That purity is exactly what makes the core easy to test — you call a
function with an example and check what it returns, with no fake files
and no captured output. The messy work of reading arguments, opening
files, and printing lives in the imperative shell (summary.py), never
here. Keeping logic and I/O apart is the single design idea this program
exists to demonstrate.
The tool's job (its spec): turn free-form text full of numbers into a
small statistical summary — count, total, mean, minimum, maximum, and
how many values sit above the mean.
"""
def parse_numbers(text):
"""Parse free-form text into a list of floats.
Tokens may be separated by commas, spaces, tabs, or newlines. Empty
or blank text yields an empty list. A token that is not a number
raises ValueError naming the offending token — the boundary check
that keeps bad data out of the rest of the core.
Args:
text: the raw text to parse.
Returns:
A list of floats, one per token, in order.
Raises:
ValueError: if any token cannot be read as a number.
"""
tokens = text.replace(",", " ").split()
numbers = []
for token in tokens:
try:
numbers.append(float(token))
except ValueError:
raise ValueError(f"{token!r} is not a number")
return numbers
def summarize(numbers):
"""Return summary statistics for a non-empty list of numbers.
Args:
numbers: a list of numbers (floats or ints).
Returns:
A dict with keys count, total, mean, minimum, maximum, and
above_mean (how many values are strictly greater than the mean).
Raises:
ValueError: if numbers is empty, because a summary of nothing has
no meaning — the caller must decide what to do about it.
"""
if not numbers:
raise ValueError("cannot summarize an empty list of numbers")
count = len(numbers)
total = sum(numbers)
mean = total / count
above_mean = sum(1 for value in numbers if value > mean)
return {
"count": count,
"total": total,
"mean": mean,
"minimum": min(numbers),
"maximum": max(numbers),
"above_mean": above_mean,
}
def format_summary(summary):
"""Render a summary dict as an aligned block of human-readable text.
This is pure string-building: it returns the text rather than
printing it, so the shell decides where the text goes and the tests
can check it directly.
Args:
summary: a dict as returned by summarize().
Returns:
A multi-line string, one statistic per line.
"""
return "\n".join(
[
f"count {summary['count']}",
f"total {summary['total']:.2f}",
f"mean {summary['mean']:.2f}",
f"minimum {summary['minimum']:.2f}",
f"maximum {summary['maximum']:.2f}",
f"above mean {summary['above_mean']}",
]
)
examples/summary.py (1890 bytes)
#!/usr/bin/env python3
"""summary.py — the imperative shell of the summary tool.
This is the ONLY part of the program that touches the outside world. It
reads command-line arguments, opens a file or reads standard input, prints
the result to standard output, prints errors to standard error, and chooses
the exit code. It contains no statistics logic of its own: it calls the
pure functions in summary_core.py and arranges their inputs and outputs.
Because all the messiness lives here and all the logic lives in the core,
the core can be tested with plain function calls while this thin shell just
wires it to the world.
Usage:
python3 summary.py numbers.txt # read from a file
echo "1 2 3 4 5" | python3 summary.py # read from standard input
"""
import sys
import summary_core
def read_input(argv):
"""Return the raw text to summarize: from a named file, or from stdin.
This lives in the shell, not the core, because it performs I/O. It is
the one place in the program that knows where the bytes come from.
"""
if len(argv) > 1:
with open(argv[1], "r", encoding="utf-8") as handle:
return handle.read()
return sys.stdin.read()
def main(argv):
"""Wire the shell to the core: read input, run the core, print, exit.
Any error the core raises (bad token, empty input) or the filesystem
raises (missing file) becomes a clear message on standard error and a
non-zero exit code, so a script calling this tool can detect failure.
"""
try:
text = read_input(argv)
numbers = summary_core.parse_numbers(text)
summary = summary_core.summarize(numbers)
except (ValueError, OSError) as err:
print(f"error: {err}", file=sys.stderr)
return 1
print(summary_core.format_summary(summary))
return 0
if __name__ == "__main__":
sys.exit(main(sys.argv))
metadata.yml (868 bytes)
lesson_id: D063
day: 63
kind: python-program
languages: [python]
setup_commands:
- cd labs/sections/programming-with-python/day-063-designing-a-small-program-well
- python3 --version
run_commands:
- echo "10 20 30 40" | python3 examples/summary.py
- printf "5, 7, 9, 11\n" > scores.txt && python3 examples/summary.py scores.txt && rm -f scores.txt
- echo "1 two 3" | python3 examples/summary.py
- PYTHONPATH=examples python3 -c "import summary_core as c; print(c.summarize([2, 4, 6, 8]))"
test_commands:
- bash tests/run_tests.sh
cleanup_commands:
- rm -f scores.txt
- 'git checkout -- starter/summary_core.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 -> 15 checks, 0 failure(s), exit 0'
requirements/README.md (932 bytes)
# Dependencies — Day 063 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 — in fact only `sys`, and the
built-in `float`, `sum`, `min`, and `max`. The pure core uses no imports
at all. There is deliberately no `requirements.txt`: a small,
well-designed program 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 is pure standard-library Python
and behaves identically everywhere.
starter/design-worksheet.md (2312 bytes)
# Design worksheet — design it first
Use this for the practice assignment: design a *second* small program on
paper before writing any code, the same way this lab's `summary` tool was
designed. Filling this in first — spec, then modules and signatures, then
which parts are pure — is the whole discipline the lesson teaches. Do not
write a function body until every row below is filled.
## 1. Spec and examples (before any code)
- **One sentence: what does the program do?**
- **Input (what comes in, and from where — argv, a file, stdin):**
- **Output (what goes out, and to where — stdout, exit code):**
- **Three concrete examples** (input → output), including one bad input:
1. input: … → output: …
2. input: … → output: …
3. bad input: … → error message (stderr) and exit code:
## 2. Decompose into functions (signatures before bodies)
One row per function. Mark each **pure** (values in, values out, no I/O)
or **shell** (does I/O). Aim for a pure core and a thin shell.
| Function (name + params) | What it returns | Pure or shell? | One responsibility (one sentence) |
| ------------------------ | --------------- | -------------- | --------------------------------- |
| | | | |
| | | | |
| | | | |
| | | | |
## 3. The functional core / imperative shell split
- **Which functions form the pure core (no I/O)?**
- **Which functions form the shell (all the I/O)?**
- **How will you test the core without any files or captured output?**
## 4. Incremental plan (make it work on the simplest case first)
Order the steps you will build and test, smallest working slice first:
1.
2.
3.
## 5. YAGNI check (what you are deliberately NOT building)
List at least two features you could add but will not, because the spec
does not ask for them yet:
-
-
## 6. Recorded behaviour (fill in after you build it)
- One good run (command and its output):
- One bad run (command, stderr message, and `echo $?`):
starter/summary_core.py (3537 bytes)
"""summary_core.py — YOUR working file: the functional core.
The design is already done for you: the module is split into a pure core
(this file) and a thin imperative shell (summary.py, provided complete).
The three functions below are the whole core, and their docstrings — the
signatures and contracts — are written first, on purpose. That is
docstring-driven design: decide what each function promises before you
write a line of its body. Your job is to fill in the bodies so each
function keeps the promise its docstring makes.
Every function here must stay PURE: no print(), no input(), no open(),
no reading argv — just values in and values out. All of that I/O belongs
in summary.py. Purity is why tests/run_tests.sh can check this core with
plain function calls.
Finish the three numbered exercises, then run: bash tests/run_tests.sh
"""
def parse_numbers(text):
"""Parse free-form text into a list of floats.
Tokens may be separated by commas, spaces, tabs, or newlines. Empty
or blank text yields an empty list. A token that is not a number
raises ValueError naming the offending token.
>>> parse_numbers("1, 2, 3")
[1.0, 2.0, 3.0]
>>> parse_numbers("")
[]
"""
# Exercise 1: PARSE (a pure boundary check).
# 1. Turn every comma into a space, then .split() to get tokens.
# 2. For each token, try float(token) and collect the result.
# 3. If float(token) raises ValueError, raise ValueError(f"{token!r} is
# not a number") so the caller learns which token was bad.
# 4. Return the list of floats ([] when there are no tokens).
raise NotImplementedError("Exercise 1: implement parse_numbers")
def summarize(numbers):
"""Return summary statistics for a non-empty list of numbers.
Returns a dict with keys count, total, mean, minimum, maximum, and
above_mean (how many values are strictly greater than the mean).
Raises ValueError if numbers is empty.
"""
# Exercise 2: SUMMARIZE (pure computation).
# 1. If numbers is empty, raise ValueError("cannot summarize an empty
# list of numbers"). An empty input is the caller's decision, not
# something the core should guess about.
# 2. Compute count, total, mean (= total / count), minimum, maximum.
# 3. Compute above_mean: how many values are strictly greater than mean.
# Hint: sum(1 for value in numbers if value > mean)
# 4. Return them in a dict with exactly those six keys.
raise NotImplementedError("Exercise 2: implement summarize")
def format_summary(summary):
"""Render a summary dict as an aligned block of human-readable text.
Pure string-building: return the text, do not print it. The shell
decides where the text goes; the tests check the returned string.
Each of the six statistics goes on its own line. Format the four
real-valued numbers (total, mean, minimum, maximum) to two decimals
with :.2f; count and above_mean are plain integers.
"""
# Exercise 3: FORMAT (pure rendering).
# Build and return a multi-line string. To match the reference and the
# tests exactly, use these label widths and this order:
# count <count>
# total <total, 2 decimals>
# mean <mean, 2 decimals>
# minimum <minimum, 2 decimals>
# maximum <maximum, 2 decimals>
# above mean <above_mean>
# Hint: build a list of f-strings and "\n".join(...) them.
raise NotImplementedError("Exercise 3: implement format_summary")
starter/summary.py (1262 bytes)
#!/usr/bin/env python3
"""summary.py — the imperative shell (PROVIDED, complete).
You do not edit this file. It is here so you can see the other half of the
design: the thin shell that does all the I/O and none of the logic. Once
you finish the three exercises in summary_core.py, this shell will run the
whole tool by calling your pure functions.
echo "1 2 3 4 5" | python3 starter/summary.py
python3 starter/summary.py numbers.txt
"""
import sys
import summary_core
def read_input(argv):
"""Return the raw text to summarize: from a named file, or from stdin.
Lives in the shell, not the core, because it performs I/O.
"""
if len(argv) > 1:
with open(argv[1], "r", encoding="utf-8") as handle:
return handle.read()
return sys.stdin.read()
def main(argv):
"""Read input, run the pure core, print the result, choose the exit code."""
try:
text = read_input(argv)
numbers = summary_core.parse_numbers(text)
summary = summary_core.summarize(numbers)
except (ValueError, OSError) as err:
print(f"error: {err}", file=sys.stderr)
return 1
print(summary_core.format_summary(summary))
return 0
if __name__ == "__main__":
sys.exit(main(sys.argv))
tests/run_tests.sh (5248 bytes)
#!/usr/bin/env bash
# Tests for the Day 063 lab. Run from the lab directory:
# bash tests/run_tests.sh
#
# The point of this suite is to show what a well-designed program buys you:
# because the logic lives in a PURE core (summary_core.py) with no I/O, the
# core is tested with plain function calls — no fake files, no captured
# output. The thin imperative shell (summary.py) is then checked end to end
# through stdin, a file, and error cases. Finally the learner's starter is
# checked: structurally while exercises are unfinished, and to the same
# strict standard once they are complete. No network, non-interactive.
# Exits 0 only if every check passes.
set -u
export PYTHONDONTWRITEBYTECODE=1
lab_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
failures=0
checks=0
check() {
local label="$1" ok="$2"
checks=$((checks + 1))
if [ "${ok}" = "yes" ]; then
echo " ok: ${label}"
else
echo " FAIL: ${label}"
failures=$((failures + 1))
fi
}
# check_core <label> <core_dir> <python-body>
# Runs a small assertion body against summary_core imported from core_dir.
# A clean exit (all asserts pass) is a pass.
check_core() {
local label="$1" core_dir="$2" body="$3"
if PYTHONPATH="${core_dir}" python3 -c "import summary_core as c
${body}" 2>/dev/null; then
check "${label}" "yes"
else
check "${label}" "no"
fi
}
# check_shell <label> <shell_script> <expect_exit> <needle> <stdin-text> [file-arg]
check_shell() {
local label="$1" script="$2" expect_exit="$3" needle="$4" stdin_text="$5" file_arg="${6:-}"
local out code
if [ -n "${file_arg}" ]; then
out="$(python3 "${script}" "${file_arg}" 2>&1)"
else
out="$(printf '%s' "${stdin_text}" | python3 "${script}" 2>&1)"
fi
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_core_checks() {
local core_dir="$1"
echo "Testing the pure core in ${core_dir} (plain function calls, no I/O) ..."
check_core "parse_numbers reads mixed separators" "${core_dir}" \
"assert c.parse_numbers('1, 2\n3\t4') == [1.0, 2.0, 3.0, 4.0]"
check_core "parse_numbers of blank text is []" "${core_dir}" \
"assert c.parse_numbers(' ') == []"
check_core "parse_numbers rejects a non-number" "${core_dir}" \
"import summary_core
try:
c.parse_numbers('1 two 3'); raise SystemExit(1)
except ValueError as e:
assert 'two' in str(e)"
check_core "summarize computes the six statistics" "${core_dir}" \
"s = c.summarize([2, 4, 6, 8])
assert s['count'] == 4 and s['total'] == 20 and s['mean'] == 5.0
assert s['minimum'] == 2 and s['maximum'] == 8 and s['above_mean'] == 2"
check_core "summarize rejects an empty list" "${core_dir}" \
"try:
c.summarize([]); raise SystemExit(1)
except ValueError:
pass"
check_core "format_summary returns aligned text" "${core_dir}" \
"text = c.format_summary(c.summarize([10, 20, 30]))
assert 'count 3' in text
assert 'mean 20.00' in text
assert 'above mean 1' in text"
}
run_shell_checks() {
local script="$1" core_dir="$2"
echo "Testing the shell ${script} end to end ..."
# The shell imports summary_core from its own directory; make sure the
# matching core is the one next to it by running with that dir on the path.
local tmpfile
check_shell "stdin: three numbers summarized" "${script}" 0 "mean 20.00" "10 20 30"
check_shell "stdin: bad token -> error, exit 1" "${script}" 1 "is not a number" "1 two 3"
check_shell "stdin: empty input -> error, exit 1" "${script}" 1 "cannot summarize an empty list" ""
tmpfile="$(mktemp -t summary-test.XXXXXX)"
printf '5, 7, 9, 11\n' > "${tmpfile}"
check_shell "file input summarized" "${script}" 0 "count 4" "" "${tmpfile}"
rm -f "${tmpfile}"
check_shell "missing file -> error, exit 1" "${script}" 1 "error:" "" "${lab_dir}/no-such-file.txt"
}
# --- Reference: always tested strictly ---
run_core_checks "${lab_dir}/examples"
run_shell_checks "${lab_dir}/examples/summary.py" "${lab_dir}/examples"
# --- Learner starter ---
echo "Testing starter/summary_core.py ..."
starter_core="${lab_dir}/starter/summary_core.py"
if python3 -c "compile(open('${starter_core}').read(), '${starter_core}', 'exec')" 2>/dev/null; then
check "starter core is valid Python" "yes"
else
check "starter core is valid Python" "no"
fi
if grep -q 'NotImplementedError' "${starter_core}"; then
echo "Note: starter/summary_core.py still has unfinished exercises — testing structure only."
grep -q 'def parse_numbers' "${starter_core}" && check "starter defines parse_numbers" "yes" || check "starter defines parse_numbers" "no"
grep -q 'def summarize' "${starter_core}" && check "starter defines summarize" "yes" || check "starter defines summarize" "no"
grep -q 'def format_summary' "${starter_core}" && check "starter defines format_summary" "yes" || check "starter defines format_summary" "no"
else
run_core_checks "${lab_dir}/starter"
run_shell_checks "${lab_dir}/starter/summary.py" "${lab_dir}/starter"
fi
echo
echo "${checks} checks, ${failures} failure(s)."
[ "${failures}" -eq 0 ]
Troubleshooting
Troubleshooting — Day 063 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 three exercises in
starter/summary_core.py. 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 three exercises are
done, the tool runs and the test suite holds your core to the same strict
standard as the reference.
ModuleNotFoundError: No module named 'summary_core'
The shell (summary.py) imports the core (summary_core.py) that sits
next to it. When you run python3 examples/summary.py, Python puts the
examples/ directory on its import path automatically, so the import
works. This error usually means you moved one file without the other, or
tried to import the core from a directory that has no summary_core.py.
Keep the core and its shell in the same directory, and run the shell by its
path (python3 examples/summary.py, python3 starter/summary.py).
To call the core directly from another directory, put its folder on the path yourself:
PYTHONPATH=examples python3 -c "import summary_core as c; print(c.summarize([1, 2, 3]))"
find... wait, this tool has no subcommands
Correct — this is deliberately smaller than the Day 56 Records CLI. It has
no argparse and no subcommands; it reads one stream of numbers and prints
one summary. The lesson is about design, so the tool is kept minimal on
purpose (that is YAGNI in action). The Week 9 project, the Flashcard Study
App, is where you scale this same core/shell split up to a multi-command
program.
error: 'x' is not a number
One of the tokens in your input is not a number. parse_numbers rejects
the first non-numeric token by name and the shell exits with code 1. Check
the input for stray words, letters, or symbols. Commas, spaces, tabs, and
newlines are all fine as separators; anything that is not a number between
them is not.
error: cannot summarize an empty list of numbers
You gave the tool no numbers at all (an empty file, or you pressed
Ctrl-D on an empty standard input). A summary of nothing has no meaning, so
the core raises ValueError and the shell exits 1. Provide at least one
number.
The summary shows mean 5.00 but I expected 5
Real-valued statistics (total, mean, minimum, maximum) are formatted to two
decimal places with :.2f, so a whole number shows as 5.00. The counts
(count, above mean) are integers and print with no decimals. This is a
formatting choice in format_summary; change the format string there if
you want different precision (a good refactor exercise).
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 063 lab
-
What the tool does: reads text from a file you name or from standard input, computes a numeric summary, and prints it. It makes no network connections, needs no privileges, and writes no files — the pure core writes nothing at all, and the shell only reads. The test runner creates one throwaway input file with
mktempand removes it. -
A pure core cannot do damage. The design idea of this lab is also a security property: because
summary_core.pyperforms no I/O — noopen(), no network, noos/subprocess— its functions physically cannot delete a file, spend money, or leak data, no matter what input they are handed. All the dangerous capabilities live in the thin shell, in one small, readable place you can audit. Keeping side effects at the edges is a real security practice, not just a tidiness preference. -
Validate input at the boundary; never execute it. Numbers enter through
parse_numbers, which usesfloat()— a safe conversion that can only ever produce a number or raiseValueError. Do not reach foreval()/exec()to "read" a number or an expression from input; those execute the string as Python, so a malicious value could run arbitrary code.float()(and, for whole numbers,int()) is the safe way to turn text into a number. -
Fail loudly, not silently. Every error path — a non-numeric token, empty input, a missing file — prints a message to standard error and returns exit code 1, so a person or a script notices. Silently printing a wrong or empty summary while reporting success is worse than an error.
-
Reading before running: every file in this lab is short and commented. Read
examples/summary_core.py,examples/summary.py, andtests/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.