Programming with PythonFunctions and Program Design › Day 63

Hands-on lab — Day 63: 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

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 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 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).
  • bash for 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. 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.

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" | ... and printf "" | ... — 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

  1. echo "10 20 30 40" | python3 examples/summary.py prints a six-line summary ending in above mean 2 and exits 0.
  2. echo "1 two 3" | python3 examples/summary.py; echo $? prints error: 'two' is not a number to standard error and then 1.
  3. printf "" | python3 examples/summary.py; echo $? prints error: cannot summarize an empty list of numbers and then 1.
  4. PYTHONPATH=examples python3 -c "import summary_core as c; print(c.summarize([2,4,6,8])['above_mean'])" prints 2 — the core called directly, with no I/O.
  5. Complete the three exercises in starter/summary_core.py, run echo "3 6 9" | python3 starter/summary.py, 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: 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

  1. Add a median to 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.
  2. Add a --round N idea without argparse: let the shell read an optional second argument for the number of decimal places and pass it into a changed format_summary(summary, places=2). Keep format_summary pure.
  3. Refactor format_summary so 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.
  4. Write your own tests/test_core.py that imports parse_numbers, summarize, and format_summary and asserts their behaviour, printing all tests passed only if every assertion holds, and confirm it exits 0.
  • 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 mktemp and removes it.

  • A pure core cannot do damage. The design idea of this lab is also a security property: because summary_core.py performs no I/O — no open(), no network, no os/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 uses float() — a safe conversion that can only ever produce a number or raise ValueError. Do not reach for eval()/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, 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.