Programming with PythonFunctions and Program Design › Day 57

Hands-on lab — Day 57: Functions: Definition, Arguments, and Return Values

Commands

Setup

cd labs/sections/programming-with-python/day-057-functions-definition-arguments-and-return-values
python3 --version

Run

python3 examples/demo.py
python3 -c "import sys; sys.path.insert(0, 'examples'); from library import summarize; print(summarize([7, 3, 9, 4, 6]))"
python3 -c "import sys; sys.path.insert(0, 'examples'); from library import greet; print(greet('Ada', punctuation='.'))"

Test

bash tests/run_tests.sh

File tree

examples/demo.py
examples/library.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/library.py
tests/run_tests.sh
troubleshooting.md

Lab README

Day 057 lab — Function Library

Lesson

Purpose

Day 57's lesson teaches functions from first principles: def, parameters versus arguments, positional/keyword/default arguments, return values (including returning a tuple of several results and the implicit None), docstrings, the difference between returning a value and printing (pure function versus side effect), the mutable-default-argument trap, and DRY. This lab makes that concrete. You build a small library of pure functions — tiny text and number utilities — each with a docstring, each taking its input as arguments and handing back a return value, and none of them printing or changing anything outside themselves. Then you run an automated suite that calls each function and asserts on what it returns, because return values are exactly what makes a pure function easy to test. This is the shape every model call, metric, and data transform takes later: a named, documented, testable function.

Learning objectives

  • Define functions with def, giving each a docstring whose first line says what the function returns.
  • Use positional, keyword, and default arguments, and know the difference between a parameter (in the definition) and an argument (at the call).
  • Return a value the caller uses — and return a tuple to hand back several results at once, which the caller unpacks.
  • Write pure functions (input from arguments, a return value, no side effects) and explain why they are the easy ones to test.
  • Avoid the mutable-default-argument trap by defaulting to None and building a fresh container inside.
  • Factor repeated work into a function (DRY) and prove behaviour by asserting on return values, not by reading printed output.

Prerequisites

  • The Day 57 lesson (read it first — it explains every idea this lab builds).
  • Days 50–56: conditionals and loops, lists, dictionaries, sets and tuples, comprehensions, and the shape of a script run 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 code is pure standard-library Python and behaves identically everywhere.

Hardware requirements

Any computer that runs Python 3. The functions do trivial text and number work; they need 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 — no packages to install. See requirements/README.md.

Free and open-source options

Everything here is free and open source: Python, bash, and the standard library. No account, API key, network access, or purchase is needed. The whole lab is ordinary Python function definitions.

Installation

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

cd labs/sections/programming-with-python/day-057-functions-definition-arguments-and-return-values
python3 --version   # confirm Python 3.8+ is available

File structure

day-057-functions-definition-arguments-and-return-values/
├── README.md                       ← you are here
├── metadata.yml                    ← machine-readable lab metadata
├── starter/
│   ├── library.py                  ← YOUR working file (6 numbered exercises)
│   └── design-worksheet.md         ← design your own library before coding it
├── examples/
│   ├── library.py                  ← complete reference library of pure functions
│   └── demo.py                     ← calls the library and uses its return values
├── tests/
│   └── run_tests.sh                ← assert-based checks on return values and purity
├── expected-output/
│   ├── sample-run.txt              ← real captured run of examples/demo.py
│   ├── test-run.txt                ← real captured run of the test suite
│   └── FIELDS.md                   ← required return values 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 library in action. demo.py imports it and uses what
##    each function returns — no function prints on its own.
python3 examples/demo.py

## 2. Call a function directly and use its return value. summarize returns a
##    tuple you can unpack.
python3 -c "import sys; sys.path.insert(0, 'examples'); from library import summarize; c, t, lo, hi, avg = summarize([7, 3, 9, 4, 6]); print('mean is', avg)"

## 3. Try default and keyword arguments on greet and clamp.
python3 -c "import sys; sys.path.insert(0, 'examples'); from library import greet, clamp; print(greet('Ada'), '|', greet('Ada', punctuation='.'), '|', clamp(42, high=100))"

## 4. Your task: complete the six exercises in the starter, then test it.
##    (Editing starter/library.py.)

## 5. Check your work — and the reference — with the assert-based suite.
bash tests/run_tests.sh

What the commands do

  • python3 examples/demo.py — imports the pure functions and prints how a caller uses their return values: composing calls, unpacking the tuple from summarize, and passing keyword arguments to greet. The functions themselves never print; the caller does.
  • python3 -c "...summarize..." — imports one function and unpacks its returned tuple (count, total, min, max, mean), showing that one call can hand back several results.
  • python3 -c "...greet, clamp..." — shows default arguments (greet('Ada') uses greeting="Hello") and keyword arguments (clamp(42, high=100)) at the call site.
  • bash tests/run_tests.sh — imports the reference library and the starter, calls each function, and asserts on the return value: exact results, the returned tuple, default and keyword arguments, purity (same args → same result, inputs unmutated), the mutable-default trap avoided, raised errors, and that every public function has a docstring. Exits 0 only if every check passes.

Expected output

See expected-output/sample-run.txt — a real captured run of python3 examples/demo.py. Two lines from it:

summarize([7, 3, 9, 4, 6]) ->
    count=5 total=29 min=3 max=9 mean=5.8

Every function is pure and deterministic, so your output will match exactly. expected-output/FIELDS.md lists the required return value for every function on every platform.

Validation steps

  1. python3 examples/demo.py prints the block shown above without error.
  2. python3 -c "import sys; sys.path.insert(0,'examples'); from library import word_count; print(word_count('the quick brown fox'))" prints 4.
  3. python3 -c "import sys; sys.path.insert(0,'examples'); from library import summarize; print(summarize([2,4,6]))" prints the tuple (3, 12, 2, 6, 4.0).
  4. Complete the six exercises in starter/library.py; each function must return its value and carry a docstring.
  5. Run the tests (next section) — every check must pass.

Tests

bash tests/run_tests.sh

Expected final line while the starter is unfinished: 30 checks, 0 failure(s). — the reference library is fully tested and the starter is checked structurally. Once you complete all six starter exercises, the suite runs your library through the same assertions, giving 46 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 lab writes no data files. Python may create __pycache__ folders when it imports the module; remove them if you like:

find . -name __pycache__ -type d -prune -exec rm -rf {} +

To reset your work, restore the starter from version control (for example git restore starter/library.py in the full repository).

Troubleshooting

See troubleshooting.md for the full list: python vs python3, the NotImplementedError stubs, ModuleNotFoundError and the import path, "my function prints but the test fails" (return, do not print), floats versus ints, the mutable-default trap in tally, and unpacking the tuple from summarize.

Security notes

See security.md. Short version: the code makes no network calls, needs no privileges, and writes no files. Pure functions have a tiny contact with the world, which is a security property as well as good design; never build behaviour out of eval()/exec(), and validate at the boundary so bad input raises a clear error instead of a silently wrong number.

Extension exercises

  1. Add a median(numbers) function that returns the middle value (or the mean of the two middle values) of a sorted copy — without mutating the input, keeping it pure — and add asserts for it to the suite.
  2. Add a titlecase_words(text) function and refactor any repeated split/join logic shared with reverse_words and normalize_whitespace into one small helper (DRY).
  3. Extend summarize to also return the range (max - min) as a sixth tuple element, and update every caller and test that unpacks it — noticing how a changed return shape ripples out.
  4. Write your own tests/test_library.py that imports the functions and uses assert statements directly, printing all tests passed only if every assertion holds, and confirm it exits 0.
  • Previous day: Day 56 — Building a Data-Driven CLI (labs/sections/programming-with-python/day-056-building-a-data-driven-cli/).
  • Next day: Day 58 — continues Week 9, Functions and Program Design (labs/sections/programming-with-python/day-058-.../, to be written).
  • This week (Week 9): functions and program design — you build the named, documented, testable building blocks that every later data and model tool is assembled from.

Expected output

FIELDS.md

# Expected output — Day 057 lab

These are real captured runs from the authoring machine (macOS, Apple
Silicon, Python 3.14.0, bash 3.2, 2026-07-13). Every function in the library
is **pure and deterministic**: given the same arguments it returns the same
value on every platform Python 3 runs on, so your output will match.

## Files

- `sample-run.txt` — the output of `python3 examples/demo.py`, which imports
  the library and *uses* what each function returns: composing calls,
  unpacking a returned tuple, and passing keyword arguments.
- `test-run.txt` — a full run of `bash tests/run_tests.sh` with the starter
  still unfinished (30 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 library must return exactly these values:

| Call | Returns |
| --- | --- |
| `word_count("the quick brown fox")` | `4` |
| `word_count("   ")` | `0` |
| `normalize_whitespace("  a   b  ")` | `'a b'` |
| `reverse_words("one two three")` | `'three two one'` |
| `is_palindrome("A man, a plan, a canal: Panama")` | `True` |
| `celsius_to_fahrenheit(100)` | `212.0` |
| `clamp(1.5)` | `1.0` (default range `[0.0, 1.0]`) |
| `clamp(-3, low=-10, high=10)` | `-3` |
| `mean([2, 4, 6])` | `4.0` |
| `summarize([2, 4, 6])` | `(3, 12, 2, 6, 4.0)` (a tuple) |
| `greet("Ada")` | `'Hello, Ada!'` |
| `greet("Grace", greeting="Welcome", punctuation=".")` | `'Welcome, Grace.'` |
| `tally(["a", "b", "a"])` | `{'a': 2, 'b': 1}` |
| `tally(["a"], {"a": 5})` | `{'a': 6}` |

## Required errors

| Call | Result |
| --- | --- |
| `mean([])` | raises `ValueError` (mean of nothing is undefined) |
| `summarize([])` | raises `ValueError` (cannot summarize an empty sequence) |
| `clamp(5, low=10, high=0)` | raises `ValueError` (low greater than high) |

## Purity guarantees the tests check

- **Repeatable:** calling any function twice with the same arguments returns
  equal results.
- **No leaked state:** `tally` uses a `None` default (not `{}`), so a second
  call never remembers items from a first — the mutable-default trap is
  avoided.
- **No mutation of inputs:** `summarize([3, 1, 2])` leaves the list `[3, 1, 2]`
  unchanged; `tally(items, start)` does not mutate `start`.

## Platform notes

- The only visible difference between platforms is the shell prompt (`$`)
  shown before the demo command; the program's own output is identical.
- `2 + 2 == 4.0` and other float results print the same on CPython 3.8–3.14.
  `celsius_to_fahrenheit` returns a float (because of the `/ 5`), so `212.0`,
  not `212`, is correct.
- No files are written, no network is used, and no temporary state is left
  behind — the tests import the module and assert on return values only.

sample-run.txt

$ python3 examples/demo.py
normalize_whitespace -> 'the quick brown fox'
word_count           -> 4
reverse_words        -> 'fox brown quick the'
is_palindrome('Racecar')        -> True
is_palindrome('function')       -> False
greet('Ada')                    -> 'Hello, Ada!'
greet('Grace', 'Welcome')       -> 'Welcome, Grace!'
greet('Alan', punctuation='.')  -> 'Hello, Alan.'
clamp(1.5)                      -> 1.0
clamp(42, high=100)             -> 42
summarize([7, 3, 9, 4, 6]) ->
    count=5 total=29 min=3 max=9 mean=5.8
mean(readings)                  -> 5.8
tally(['a','b','a'])            -> {'a': 2, 'b': 1}
tally(['x'])                    -> {'x': 1}

test-run.txt

Testing <repo>/labs/sections/programming-with-python/day-057-functions-definition-arguments-and-return-values/examples/library.py ...
  ok: word_count returns 4
  ok: word_count of blanks returns 0
  ok: normalize_whitespace collapses
  ok: reverse_words reverses order
  ok: celsius_to_fahrenheit(100)=212
  ok: celsius_to_fahrenheit(0)=32
  ok: mean([2,4,6]) == 4.0
  ok: clamp uses default range [0,1]
  ok: clamp with keyword range
  ok: greet default greeting
  ok: greet keyword arguments
  ok: summarize returns a 5-tuple
  ok: summarize tuple unpacks
  ok: tally counts frequencies
  ok: tally with a starting count
  ok: tally does not leak across calls
  ok: tally does not mutate its input
  ok: word_count is pure (repeatable)
  ok: summarize does not mutate list
  ok: mean([]) raises ValueError
  ok: summarize([]) raises ValueError
  ok: all public functions documented
Testing examples/demo.py runs ...
  ok: demo.py runs and prints results
Testing starter/library.py ...
  ok: starter is valid Python
Note: starter/library.py still has unfinished exercises — testing structure only.
  ok: starter defines word_count
  ok: starter defines reverse_words
  ok: starter defines celsius_to_fahrenheit
  ok: starter defines clamp
  ok: starter defines summarize
  ok: starter defines tally

30 checks, 0 failure(s).

Source files

examples/demo.py (2361 bytes)
#!/usr/bin/env python3
"""demo.py — drive the function library and use its return values.

Run this from the lab directory to see the library in action:

    python3 examples/demo.py

It imports the pure functions from library.py and *uses what they return* —
composing calls, unpacking a returned tuple, and passing keyword arguments —
without any function printing on its own. All the printing happens here, in
the caller, which is exactly the pure-function-plus-thin-shell shape the
lesson teaches.
"""
import sys
from pathlib import Path

# Make the sibling library.py importable no matter where this is run from.
sys.path.insert(0, str(Path(__file__).resolve().parent))

from library import (
    clamp,
    greet,
    is_palindrome,
    mean,
    normalize_whitespace,
    reverse_words,
    summarize,
    tally,
    word_count,
)


def main():
    """Call the library functions and print how their return values are used."""
    sentence = "  the  quick   brown fox  "
    clean = normalize_whitespace(sentence)
    print(f"normalize_whitespace -> {clean!r}")
    print(f"word_count           -> {word_count(clean)}")
    print(f"reverse_words        -> {reverse_words(clean)!r}")

    print(f"is_palindrome('Racecar')        -> {is_palindrome('Racecar')}")
    print(f"is_palindrome('function')       -> {is_palindrome('function')}")

    # Default vs keyword arguments.
    print(f"greet('Ada')                    -> {greet('Ada')!r}")
    print(f"greet('Grace', 'Welcome')       -> {greet('Grace', 'Welcome')!r}")
    print(f"greet('Alan', punctuation='.')  -> {greet('Alan', punctuation='.')!r}")

    # Default arguments on clamp.
    print(f"clamp(1.5)                      -> {clamp(1.5)}")
    print(f"clamp(42, high=100)             -> {clamp(42, high=100)}")

    # Unpacking a returned tuple of several results.
    readings = [7, 3, 9, 4, 6]
    count, total, low, high, average = summarize(readings)
    print(f"summarize({readings}) ->")
    print(f"    count={count} total={total} min={low} max={high} mean={average}")
    print(f"mean(readings)                  -> {mean(readings)}")

    # Purity of tally: two calls do not leak state into each other.
    print(f"tally(['a','b','a'])            -> {tally(['a', 'b', 'a'])}")
    print(f"tally(['x'])                    -> {tally(['x'])}")


if __name__ == "__main__":
    main()
examples/library.py (5348 bytes)
#!/usr/bin/env python3
"""library.py — a small library of well-documented, pure functions.

Every function here takes its input as arguments and hands back a *return
value*; none of them read the keyboard, print results, or change anything
outside themselves. That makes them "pure": for the same arguments they
give the same answer, and calling them has no side effects. Pure functions
are the easy ones to test, because a test just calls the function and
checks what comes back.

The module is a grab-bag of tiny text and number utilities that together
show every idea from the Day 57 lesson:

    - def syntax and a good docstring on every function
    - positional, keyword, and default arguments      (greet, clamp)
    - a return value the caller uses, and the implicit None
    - returning a tuple to hand back several results   (summarize)
    - the mutable-default-argument trap, avoided with None (tally)

Import it and call the functions; nothing runs on import, because there is
no top-level code — only definitions. See examples/demo.py for a session
that calls these functions and uses their return values.
"""


def word_count(text):
    """Return the number of whitespace-separated words in text.

    >>> word_count("the quick brown fox")
    4
    >>> word_count("   ")
    0
    """
    return len(text.split())


def normalize_whitespace(text):
    """Return text with every run of whitespace collapsed to one space and
    the ends stripped.

    >>> normalize_whitespace("  hello   world  ")
    'hello world'
    """
    return " ".join(text.split())


def reverse_words(text):
    """Return text with its words in reverse order.

    >>> reverse_words("one two three")
    'three two one'
    """
    return " ".join(reversed(text.split()))


def is_palindrome(text):
    """Return True if text reads the same forwards and backwards, ignoring
    case and any character that is not a letter or digit.

    >>> is_palindrome("A man, a plan, a canal: Panama")
    True
    >>> is_palindrome("hello")
    False
    """
    cleaned = [character.lower() for character in text if character.isalnum()]
    return cleaned == cleaned[::-1]


def celsius_to_fahrenheit(celsius):
    """Return the Fahrenheit value for a Celsius temperature.

    >>> celsius_to_fahrenheit(100)
    212.0
    >>> celsius_to_fahrenheit(0)
    32.0
    """
    return celsius * 9 / 5 + 32


def clamp(value, low=0.0, high=1.0):
    """Return value limited to the range [low, high].

    low and high are default arguments, so clamp(x) limits x to [0.0, 1.0],
    while clamp(x, -10, 10) uses your own range. Passing them by keyword —
    clamp(x, high=100) — is clearer at the call site than a bare number.

    >>> clamp(1.5)
    1.0
    >>> clamp(-3, low=-10, high=10)
    -3
    """
    if low > high:
        raise ValueError("low must not be greater than high")
    return max(low, min(value, high))


def mean(numbers):
    """Return the arithmetic mean of a sequence of numbers.

    Raises ValueError on an empty sequence, because the mean of nothing is
    undefined — a clear error is better than a hidden division by zero.

    >>> mean([2, 4, 6])
    4.0
    """
    if not numbers:
        raise ValueError("mean of an empty sequence is undefined")
    return sum(numbers) / len(numbers)


def summarize(numbers):
    """Return a tuple (count, total, minimum, maximum, mean) for numbers.

    This is the multiple-results pattern: instead of five separate function
    calls that each re-scan the data, one call hands back a tuple the caller
    can unpack:

        count, total, low, high, average = summarize(readings)

    Raises ValueError on an empty sequence.

    >>> summarize([2, 4, 6])
    (3, 12, 2, 6, 4.0)
    """
    if not numbers:
        raise ValueError("cannot summarize an empty sequence")
    count = len(numbers)
    total = sum(numbers)
    return (count, total, min(numbers), max(numbers), total / count)


def greet(name, greeting="Hello", punctuation="!"):
    """Return a greeting line for name.

    name is required (positional); greeting and punctuation have defaults.
    All three can be passed by position or by keyword, so every call below
    is valid:

        greet("Ada")                       -> 'Hello, Ada!'
        greet("Ada", "Welcome")            -> 'Welcome, Ada!'
        greet("Ada", punctuation=".")      -> 'Hello, Ada.'

    >>> greet("Ada")
    'Hello, Ada!'
    >>> greet("Grace", greeting="Welcome", punctuation=".")
    'Welcome, Grace.'
    """
    return f"{greeting}, {name}{punctuation}"


def tally(items, counts=None):
    """Return a frequency dict counting how often each item appears.

    The default for counts is None, NOT {}. A mutable default (counts={})
    would be created once when the function is defined and then SHARED
    across every call, so counts would remember items from previous calls —
    the classic mutable-default-argument bug. Using None and building a
    fresh dict inside avoids it. When a starting tally is passed in, it is
    copied rather than mutated, so this function stays pure.

    >>> tally(["a", "b", "a"])
    {'a': 2, 'b': 1}
    >>> tally(["a"], {"a": 5})
    {'a': 6}
    """
    result = dict(counts) if counts is not None else {}
    for item in items:
        result[item] = result.get(item, 0) + 1
    return result
metadata.yml (868 bytes)
lesson_id: D057
day: 57
kind: python-program
languages: [python]
setup_commands:
  - cd labs/sections/programming-with-python/day-057-functions-definition-arguments-and-return-values
  - python3 --version
run_commands:
  - python3 examples/demo.py
  - python3 -c "import sys; sys.path.insert(0, 'examples'); from library import summarize; print(summarize([7, 3, 9, 4, 6]))"
  - python3 -c "import sys; sys.path.insert(0, 'examples'); from library import greet; print(greet('Ada', punctuation='.'))"
test_commands:
  - bash tests/run_tests.sh
cleanup_commands:
  - 'find . -name __pycache__ -type d -prune -exec rm -rf {} +  # optional: remove import caches'
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 -> 30 checks, 0 failure(s), exit 0'
requirements/README.md (947 bytes)
# Dependencies — Day 057 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 Python's built-in language features are used — `def`, arguments,
  return values, and the standard modules `sys`, `pathlib`, and `inspect`
  that ship with Python. There is deliberately no `requirements.txt`: a
  library of pure functions 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 functions are pure standard-library
Python and behave identically everywhere.
starter/design-worksheet.md (1931 bytes)
# Function design worksheet

Fill this in *before* writing code for the practice assignment. Designing a
function on paper first — its name, what it takes, what it returns, and
whether it is pure — is the discipline the lesson teaches. One row per
function you plan to add to your own library.

## The library

- **What family of things does it work with (text? numbers? dates?):**
- **File name (a module of functions, one per idea):**

## Functions

| Function name | Parameters (with defaults) | Returns (type + meaning) | Pure? (no printing / no outside change) | One example call and its result |
| ------------- | -------------------------- | ------------------------ | --------------------------------------- | ------------------------------- |
|               |                            |                          |                                         |                                 |
|               |                            |                          |                                         |                                 |
|               |                            |                          |                                         |                                 |

## Arguments to practise

- **One function that uses a default argument, and why the default is a good
  choice:**
- **One function that returns a TUPLE of several results, and what a caller
  unpacks from it:**
- **If any function takes a list or dict argument, confirm its default is
  `None` (not `[]` or `{}`) and say why:**

## Docstrings

For each function, its docstring's first line should say what it *returns*.
Write that first line here for each:

1.
2.
3.

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

- One call and the value it returned:
- One error case (the bad input, and the exception raised):
- Proof it is pure (the same call twice giving the same value, or the input
  unchanged after the call):
starter/library.py (3954 bytes)
#!/usr/bin/env python3
"""library.py — YOUR working file: a library of pure functions.

Build this function library one exercise at a time. Each numbered exercise
names exactly what to write, including the docstring it must carry. The
finished reference is in examples/library.py — try each exercise yourself
before peeking.

Rules for every function you write here:
    - Give it a docstring: one line saying what it RETURNS, then a short
      example. The tests check that each public function has a docstring.
    - Make it PURE: take input only from the arguments, return a value, and
      do not print or change anything outside the function.

When all six exercises are done, run:  bash tests/run_tests.sh
"""


def word_count(text):
    """Return the number of whitespace-separated words in text.

    >>> word_count("the quick brown fox")
    4
    """
    # Exercise 1: RETURN A VALUE.
    # Split text on whitespace with text.split() and return how many pieces
    # there are (use len). Do not print — return the number.
    raise NotImplementedError("Exercise 1: implement word_count")


def normalize_whitespace(text):
    """Return text with runs of whitespace collapsed to one space and the
    ends stripped. (Provided as a worked example — read it, then match its
    shape in your own functions.)

    >>> normalize_whitespace("  hello   world  ")
    'hello world'
    """
    return " ".join(text.split())


def reverse_words(text):
    """Return text with its words in reverse order.

    >>> reverse_words("one two three")
    'three two one'
    """
    # Exercise 2: BUILD AND RETURN A STRING.
    # Split text into words, reverse the list (reversed(...) or [::-1]), and
    # join the words back with a single space. Return the joined string.
    raise NotImplementedError("Exercise 2: implement reverse_words")


def celsius_to_fahrenheit(celsius):
    """Return the Fahrenheit value for a Celsius temperature.

    >>> celsius_to_fahrenheit(100)
    212.0
    """
    # Exercise 3: ONE PARAMETER, ONE RETURN.
    # Apply the formula celsius * 9 / 5 + 32 and return the result.
    raise NotImplementedError("Exercise 3: implement celsius_to_fahrenheit")


def clamp(value, low=0.0, high=1.0):
    """Return value limited to the range [low, high], using DEFAULT arguments
    so clamp(x) limits to [0.0, 1.0].

    >>> clamp(1.5)
    1.0
    >>> clamp(-3, low=-10, high=10)
    -3
    """
    # Exercise 4: DEFAULT ARGUMENTS.
    # low and high already have defaults in the signature above — do not
    # change them. Return the value pulled back into range:
    #     max(low, min(value, high))
    raise NotImplementedError("Exercise 4: implement clamp")


def summarize(numbers):
    """Return a tuple (count, total, minimum, maximum, mean) for numbers.

    >>> summarize([2, 4, 6])
    (3, 12, 2, 6, 4.0)
    """
    # Exercise 5: RETURN A TUPLE OF SEVERAL RESULTS.
    # 1. If numbers is empty, raise ValueError("cannot summarize an empty
    #    sequence").
    # 2. Otherwise compute count (len), total (sum), min, max, and the mean
    #    (total / count), and return them as a 5-tuple in that order.
    raise NotImplementedError("Exercise 5: implement summarize")


def tally(items, counts=None):
    """Return a frequency dict counting how often each item appears.

    The default for counts is None, NOT {} — a mutable default would be
    shared across calls and remember old items. Build a fresh dict inside.

    >>> tally(["a", "b", "a"])
    {'a': 2, 'b': 1}
    >>> tally(["a"], {"a": 5})
    {'a': 6}
    """
    # Exercise 6: AVOID THE MUTABLE-DEFAULT TRAP.
    # 1. Start result as a COPY of counts when counts is not None
    #    (result = dict(counts)), otherwise an empty dict {}.
    # 2. For each item, do result[item] = result.get(item, 0) + 1.
    # 3. Return result. (Keep the None default — do not write counts={}.)
    raise NotImplementedError("Exercise 6: implement tally")
tests/run_tests.sh (5729 bytes)
#!/usr/bin/env bash
# Tests for the Day 057 lab. Run from the lab directory:
#   bash tests/run_tests.sh
#
# The library is a set of PURE functions, so the tests are simple: import a
# function, call it, and assert on the value it returns. Every check drives
# real behaviour — return values, a returned tuple, default and keyword
# arguments, purity (calling twice gives the same answer and does not mutate
# the input), the mutable-default-argument trap, and that every public
# function carries a docstring. It first tests the complete reference
# (examples/library.py), then the learner's starter — structurally while the
# 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: no __pycache__ from the imports below.
export PYTHONDONTWRITEBYTECODE=1

lab_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
ref_dir="${lab_dir}/examples"
starter_dir="${lab_dir}/starter"
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
}

# assert <dir> <label> <python-assertion-body>
# Runs a python snippet with library.py importable from <dir>. A small
# _raises(func, arg) helper is provided for "must raise ValueError" checks.
# The snippet fails the check if it raises anything or exits non-zero.
assert() {
  local dir="$1" label="$2" body="$3"
  if python3 -c "import sys; sys.path.insert(0, '${dir}')
from library import *
def _raises(func, arg):
    try:
        func(arg)
        return False
    except ValueError:
        return True
${body}" 2>/dev/null; then
    check "${label}" "yes"
  else
    check "${label}" "no"
  fi
}

run_library_checks() {
  local dir="$1"
  echo "Testing ${dir}/library.py ..."

  # --- Return values ---
  assert "${dir}" "word_count returns 4"            "assert word_count('the quick brown fox') == 4"
  assert "${dir}" "word_count of blanks returns 0"  "assert word_count('   ') == 0"
  assert "${dir}" "normalize_whitespace collapses"  "assert normalize_whitespace('  a   b  ') == 'a b'"
  assert "${dir}" "reverse_words reverses order"    "assert reverse_words('one two three') == 'three two one'"
  assert "${dir}" "celsius_to_fahrenheit(100)=212"  "assert celsius_to_fahrenheit(100) == 212.0"
  assert "${dir}" "celsius_to_fahrenheit(0)=32"     "assert celsius_to_fahrenheit(0) == 32.0"
  assert "${dir}" "mean([2,4,6]) == 4.0"            "assert mean([2, 4, 6]) == 4.0"

  # --- Default and keyword arguments ---
  assert "${dir}" "clamp uses default range [0,1]"  "assert clamp(1.5) == 1.0"
  assert "${dir}" "clamp with keyword range"        "assert clamp(-3, low=-10, high=10) == -3"
  assert "${dir}" "greet default greeting"          "assert greet('Ada') == 'Hello, Ada!'"
  assert "${dir}" "greet keyword arguments"         "assert greet('Grace', greeting='Welcome', punctuation='.') == 'Welcome, Grace.'"

  # --- Returning a tuple of several results ---
  assert "${dir}" "summarize returns a 5-tuple"     "assert summarize([2, 4, 6]) == (3, 12, 2, 6, 4.0)"
  assert "${dir}" "summarize tuple unpacks"         "c, t, lo, hi, avg = summarize([7, 3, 9]); assert (c, t, lo, hi) == (3, 19, 3, 9)"

  # --- The mutable-default-argument trap is avoided ---
  assert "${dir}" "tally counts frequencies"         "assert tally(['a', 'b', 'a']) == {'a': 2, 'b': 1}"
  assert "${dir}" "tally with a starting count"       "assert tally(['a'], {'a': 5}) == {'a': 6}"
  assert "${dir}" "tally does not leak across calls"  "tally(['x', 'x']); assert tally(['y']) == {'y': 1}"
  assert "${dir}" "tally does not mutate its input"   "start = {'a': 5}; tally(['a'], start); assert start == {'a': 5}"

  # --- Purity: same args -> same result, input unchanged ---
  assert "${dir}" "word_count is pure (repeatable)"  "assert word_count('a b c') == word_count('a b c') == 3"
  assert "${dir}" "summarize does not mutate list"   "data = [3, 1, 2]; summarize(data); assert data == [3, 1, 2]"

  # --- Errors are raised, not hidden ---
  assert "${dir}" "mean([]) raises ValueError"       "assert _raises(mean, [])"
  assert "${dir}" "summarize([]) raises ValueError"  "assert _raises(summarize, [])"

  # --- Every public function carries a docstring ---
  assert "${dir}" "all public functions documented" "import library, inspect
missing = [n for n, f in inspect.getmembers(library, inspect.isfunction) if not (f.__doc__ or '').strip()]
assert not missing, missing"
}

# --- Reference library: always tested strictly ---
run_library_checks "${ref_dir}"

# --- The demo runs and uses the return values ---
echo "Testing examples/demo.py runs ..."
if python3 "${ref_dir}/demo.py" >/dev/null 2>&1; then
  check "demo.py runs and prints results" "yes"
else
  check "demo.py runs and prints results" "no"
fi

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

if grep -q 'NotImplementedError' "${starter_dir}/library.py"; then
  echo "Note: starter/library.py still has unfinished exercises — testing structure only."
  for fn in word_count reverse_words celsius_to_fahrenheit clamp summarize tally; do
    if grep -q "def ${fn}" "${starter_dir}/library.py"; then
      check "starter defines ${fn}" "yes"
    else
      check "starter defines ${fn}" "no"
    fi
  done
else
  run_library_checks "${starter_dir}"
fi

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

Troubleshooting

Troubleshooting — Day 057 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 import or test 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 six exercises are done, the file behaves like the reference and the test suite holds it to the full standard.

ModuleNotFoundError: No module named 'library'

Python looks for modules on its search path (sys.path), which does not include the examples/ or starter/ subfolder by default. Import checks add it first, exactly as the tests and demo.py do:

python3 -c "import sys; sys.path.insert(0, 'examples'); import library; print(library.word_count('a b c'))"

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

My function prints the answer but the test still fails

A test calls your function and checks its return value — it never reads what you print. A function that prints but does not return hands back None, so word_count("a b") == 2 is None == 2, which is false. Fix: return the value; let the caller (like demo.py) do the printing. This is the whole point of the "return a value, do not print" rule.

celsius_to_fahrenheit(100) gives 212 but the test wants 212.0

Both are equal in Python (212 == 212.0 is True), so the test passes. The reference returns a float because the formula divides by 5; if you wrote * 9 // 5 (floor division) you would get a wrong integer. Use * 9 / 5 + 32.

tally remembers items from a previous call

You almost certainly wrote def tally(items, counts={}). That empty dict is created once, when the function is defined, and is shared by every call, so it accumulates across calls — the mutable-default-argument trap. Fix: use def tally(items, counts=None) and build a fresh dict inside the body, as Exercise 6 describes.

summarize returns five separate things and I cannot capture them

summarize returns one object — a tuple of five values. Capture it whole (result = summarize(data)) or unpack it in one line:

python3 -c "import sys; sys.path.insert(0,'examples'); from library import summarize; c,t,lo,hi,avg = summarize([2,4,6]); print(c, t, lo, hi, avg)"

The number of names on the left must match the length of the tuple, or Python raises ValueError: too many values to unpack.

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 057 lab

  • What the code does: defines a set of pure functions that transform text and numbers and return values. It makes no network connections, needs no privileges, writes no files, and reads no input except the arguments each function is called with. demo.py and the test runner only import the module and print or assert on return values.

  • Pure functions are safe functions. Because these functions take input only from their arguments and return a value without touching the outside world, they cannot leak data, corrupt a file, or surprise a caller with a side effect. This is not only good design — it is a security property: the smaller a function's contact with the world, the smaller its attack surface and the easier it is to reason about.

  • Never build behaviour out of eval() or exec(). A tempting shortcut for a "calculator" or "formula" function is to pass a user string to eval(). Do not: eval and exec execute their argument as Python, so a malicious value could delete files or open a network connection. Every function here does its work with ordinary operations on its arguments — arithmetic, string methods, comprehensions — which can never execute arbitrary code.

  • Validate at the boundary; fail loudly. mean and summarize raise a clear ValueError on an empty sequence, and clamp raises when low exceeds high, rather than returning a silently wrong number or dividing by zero. An exception a caller can catch is safer than a plausible-looking wrong answer that flows into the next computation unnoticed.

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