Programming with PythonFunctions and Program Design › Day 58

Hands-on lab — Day 58: Scope, Closures, and *args/**kwargs

Commands

Setup

cd labs/sections/programming-with-python/day-058-scope-closures-and-args-kwargs
python3 --version

Run

python3 examples/flexible.py
python3 -c "import sys; sys.path.insert(0, 'examples'); from flexible import make_counter; c = make_counter(); print(c(), c(), c())"
python3 starter/flexible.py

Test

bash tests/run_tests.sh

File tree

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

Lab README

Day 058 lab — Flexible Functions

Lesson

Purpose

Day 58's lesson deepens functions with scope, closures, and variadic arguments. This lab makes those ideas concrete. You build Flexible Functions — a small module with *args/**kwargs aggregators (total, average), two closure factories (make_counter, make_multiplier) that capture private, remembered state, and a **kwargs configuration merger (build_request) in the exact style ML and LLM libraries use to pass generation settings. You build it from a starter, one exercise at a time, then run an automated test suite that imports your functions and proves the behaviour that matters: captured state persists, two closures stay independent, keyword-only arguments cannot be passed by position, and caller kwargs override defaults. It rehearses the machinery behind callbacks, hooks, decorators, and the model.generate(prompt, **kwargs) calls you will make throughout the rest of the course.

Learning objectives

  • Write functions with *args that accept any number of positional inputs, and **kwargs that gather and forward arbitrary named options.
  • Define a keyword-only argument (with a bare * / after *args) so an important option must be passed by name.
  • Build closures — inner functions that capture enclosing variables — and use nonlocal to keep private, remembered state (a counter, a factory).
  • Merge caller options over defaults with {**DEFAULTS, **kwargs}, the ML configuration pattern.
  • Keep functions testable by taking all input from arguments, and prove a closure remembers state and stays independent from another instance.

Prerequisites

  • The Day 58 lesson (read it first — it explains every part this lab builds).
  • Day 57: defining functions, parameters and arguments, return values, and default arguments.
  • Days 50–56: conditionals and loops, lists, dictionaries, comprehensions, and building a small program.
  • 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 module is pure standard-library Python and behaves identically everywhere.

Hardware requirements

Any computer that runs Python 3. The module does no heavy computation and reads or writes no files; 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 — in fact the module imports nothing; it uses only core language features. No packages to install. See requirements/README.md.

Free and open-source options

Everything here is free and open source: Python, bash, and core language features. 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-058-scope-closures-and-args-kwargs
python3 --version   # confirm Python 3.8+ is available

File structure

day-058-scope-closures-and-args-kwargs/
├── README.md                       ← you are here
├── metadata.yml                    ← machine-readable lab metadata
├── starter/
│   ├── flexible.py                 ← YOUR working file (5 numbered exercises)
│   └── functions-worksheet.md      ← design the functions before coding them
├── examples/
│   └── flexible.py                 ← complete reference implementation
├── tests/
│   └── run_tests.sh                ← automated checks (functions, demo, starter)
├── expected-output/
│   ├── sample-run.txt              ← real captured demo + import check
│   ├── 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 module's demo first.
python3 examples/flexible.py

## 2. Import one function and use it on its own (proves it is importable).
python3 -c "import sys; sys.path.insert(0, 'examples'); from flexible import make_counter; c = make_counter(); print(c(), c(), c())"

## 3. Prove two closures keep independent state.
python3 -c "import sys; sys.path.insert(0, 'examples'); from flexible import make_counter; c = make_counter(); d = make_counter(100); print(c(), c(), d(), c())"

## 4. Your task: complete the five exercises in the starter, then run its demo.
python3 starter/flexible.py

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

What the commands do

  • python3 examples/flexible.py — runs the reference module's main() demo, printing one call of each function: the variadic total, the keyword-only average, a make_counter closure advancing 0 1 2 3, an independent second counter, two make_multiplier closures with their own factors, and build_request filling defaults then honouring an override.
  • python3 -c "...make_counter..." — imports one function from the module and calls it without running the whole demo, which works because the module guards its demo behind if __name__ == "__main__":.
  • The two-counter one-liner — shows that c advances 0, 1, 2 while a second counter d returns 100 in the middle, proving each closure holds its own captured count.
  • python3 starter/flexible.py — runs your version's demo; once the five exercises are complete it matches the reference exactly.
  • bash tests/run_tests.sh — imports the reference functions and asserts their behaviour (variadic sum, keyword-only average, closure state and independence, factory capture, kwargs merge and override, and that DEFAULTS is not mutated), checks the demo output, and checks your starter. Exits 0 only if every check passes.

Expected output

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

$ python3 examples/flexible.py
total() -> 0
total(2, 4, 6) -> 12
average(10, 20, 30) -> 20.0
average(1, 2, 3, ndigits=4) -> 2.0
counter: 0 1 2 3
second counter is independent: 100
triple(5) -> 15 ; tenfold(5) -> 50
build_request('hello') -> {'prompt': 'hello', 'temperature': 0.7, 'max_tokens': 256, 'model': 'demo'}
build_request('hello', temperature=0.2) -> temperature=0.2, model=demo

The module is deterministic, so your output will match. expected-output/FIELDS.md lists the required value of every call on every platform.

Validation steps

  1. python3 examples/flexible.py prints the demo above, including counter: 0 1 2 3 and second counter is independent: 100.
  2. The two-counter one-liner (command 3 above) prints 0 1 100 2, proving the closures keep independent state.
  3. average(1, 2, 3, ndigits=4) returns 2.0 and average(1, 2, 3, 4) returns 2.5 — the 4 is a number, because ndigits is keyword-only.
  4. build_request("hello", temperature=0.2) overrides only temperature; model stays demo.
  5. Complete the five exercises in starter/flexible.py, run its demo, 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: 18 checks, 0 failure(s). Once you complete all five starter exercises, the suite runs your version through the same strict function and demo checks plus a nonlocal check, giving 30 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 module writes no files, so there is nothing to remove. To reset your work, restore the starter from git: git checkout -- starter/flexible.py.

Troubleshooting

See troubleshooting.md for the full list: python vs python3, the UnboundLocalError that means a missing nonlocal, why two counters can wrongly advance together, why ndigits is keyword-only, merging kwargs in the right order, and importing vs running.

Security notes

See security.md. Short version: the module makes no network calls, needs no privileges, and reads or writes no files. Its cautionary lesson is to forward **kwargs deliberately, not blindly — validate or allow-list the keys you accept, because a function that passes user-supplied keyword arguments straight through can expose options that were never meant to be set.

Extension exercises

  1. Write a minimal logged decorator (a closure) that wraps any function to print its arguments and return value, forwarding with def wrapper(*args, **kwargs): so it works on total, average, and build_request alike.
  2. Return two closures from one factory — a next_value and a reset that share the same captured count via nonlocal — and show a reset through one is visible through the other.
  3. Demonstrate the loop-capture trap: build three functions in a loop that should each multiply by their index, show the naive version makes them identical, then fix it with a default argument (lambda n, i=i: n * i) or a make_multiplier(i) factory, and explain why in a comment.
  4. Add a summarize(*numbers, **options) that returns a dict with the count, total, and average, letting options set ndigits, and assert its output.
  • Previous day: Day 57 — Functions: Definition, Arguments, and Return Values (labs/sections/programming-with-python/day-057-functions-definition-arguments-and-return-values/).
  • Next day: Day 59 — Modules, Imports, and Project Layout (labs/sections/programming-with-python/day-059-modules-imports-and-project-layout/, to be written).
  • Week 9 project: the Flashcard Study App, a spaced-repetition flashcard CLI organized into clean modules with documented functions — the closures, **kwargs configuration, and disciplined scope you practise here are the building blocks it stands on.

Expected output

FIELDS.md

# Expected output — Day 058 lab

These are real captured runs from the authoring machine (macOS, Apple
Silicon, Python 3.14.0, bash 3.2, 2026-07-13). The module is deterministic:
the same inputs produce the same values and the same printed demo on every
platform Python 3 runs on.

## Files

- `sample-run.txt` — the reference module's demo (`python3 examples/flexible.py`)
  followed by a `python3 -c` import check that drives two independent counters.
- `test-run.txt` — a full run of `bash tests/run_tests.sh` with the starter
  still unfinished (18 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 module must produce exactly these values:

| Call | Result |
| --- | --- |
| `total()` | `0` |
| `total(2, 4, 6)` | `12` |
| `average(10, 20, 30)` | `20.0` |
| `average()` | `0.0` |
| `average(1, 2, 3, ndigits=4)` | `2.0` |
| `average(1, 2, 3, 4)` | `2.5` (the `4` is a number; `ndigits` is keyword-only) |
| `make_counter()` called four times | `0`, `1`, `2`, `3` |
| a second `make_counter(100)` | `100`, independent of the first |
| `make_counter(10, 5)` called three times | `10`, `15`, `20` |
| `make_multiplier(3)(5)` | `15` |
| `make_multiplier(10)(5)` | `50` |
| `build_request("hello")` | `{'prompt': 'hello', 'temperature': 0.7, 'max_tokens': 256, 'model': 'demo'}` |
| `build_request("hi", temperature=0.2, max_tokens=500)` | overrides `temperature` and `max_tokens`; `model` stays `'demo'` |

`DEFAULTS` must be unchanged after any `build_request` call — the merge
builds a *new* dict rather than mutating the module-level defaults.

## Test-count summary

- **Unfinished starter:** the reference is tested strictly (functions + demo)
  and the starter structurally, for a total of **18 checks, 0 failures**.
- **Finished starter:** once every `NotImplementedError` is gone, the suite
  runs the same strict function and demo checks against your starter and adds
  a `nonlocal` check, for a total of **30 checks, 0 failures**.

## Platform notes

- The only visible difference between platforms is the shell prompt (`$`)
  shown before each command; the program's own output is identical.
- Dictionaries print in insertion order (guaranteed since Python 3.7), so the
  `build_request` output order is stable across platforms.
- No files are written and no network is used; the tests import the module in
  separate `python3 -c` processes, and `PYTHONDONTWRITEBYTECODE=1` keeps the
  tree free of `__pycache__`.

sample-run.txt

$ python3 examples/flexible.py
total() -> 0
total(2, 4, 6) -> 12
average(10, 20, 30) -> 20.0
average(1, 2, 3, ndigits=4) -> 2.0
counter: 0 1 2 3
second counter is independent: 100
triple(5) -> 15 ; tenfold(5) -> 50
build_request('hello') -> {'prompt': 'hello', 'temperature': 0.7, 'max_tokens': 256, 'model': 'demo'}
build_request('hello', temperature=0.2) -> temperature=0.2, model=demo

$ python3 -c "import sys; sys.path.insert(0, 'examples'); from flexible import make_counter; c = make_counter(); d = make_counter(100); print(c(), c(), d(), c())"
0 1 100 2

test-run.txt

$ bash tests/run_tests.sh
Testing functions imported from <repo>/labs/sections/programming-with-python/day-058-scope-closures-and-args-kwargs/examples/flexible.py ...
  ok: total() is 0 and total(2,4,6) is 12
  ok: average(10,20,30) is 20.0 and empty is 0.0
  ok: average ndigits is keyword-only
  ok: make_counter yields 0,1,2 (private state)
  ok: two counters are independent
  ok: make_counter respects start and step
  ok: make_multiplier captures its own factor
  ok: build_request fills all defaults
  ok: build_request lets caller override, model kept
  ok: build_request does not mutate DEFAULTS
Testing the demo of <repo>/labs/sections/programming-with-python/day-058-scope-closures-and-args-kwargs/examples/flexible.py ...
  ok: demo prints total(2, 4, 6) -> 12
  ok: demo prints counter: 0 1 2 3
  ok: demo prints triple(5) -> 15
  ok: demo prints the default request dict
Testing starter/flexible.py ...
  ok: starter is valid Python
Note: starter/flexible.py still has unfinished exercises — testing structure only.
  ok: starter defines total
  ok: starter defines make_counter
  ok: starter defines build_request

18 checks, 0 failure(s).

Source files

examples/flexible.py (4526 bytes)
#!/usr/bin/env python3
"""flexible.py — flexible functions with *args, **kwargs, and closures.

A small, self-contained module that demonstrates the Day 58 ideas as working,
testable functions:

  * total(*numbers)               -- variadic positional arguments
  * average(*numbers, ndigits=2)  -- a keyword-only argument
  * make_counter(start, step)     -- a closure with private, remembered state
  * make_multiplier(factor)       -- a closure factory
  * build_request(prompt, **kw)   -- the **kwargs config-merge pattern used by
                                     nearly every ML/LLM library

Every input comes from function arguments (never an interactive prompt), so
the module is fully importable and testable. Run it directly to see a demo:

    python3 flexible.py

Or import a function and use it on its own:

    python3 -c "import sys; sys.path.insert(0, 'examples'); \
from flexible import make_counter; c = make_counter(); print(c(), c(), c())"
"""

# Module-level (global) defaults — the "building stockroom" of settings that
# build_request fills in when the caller does not override them. These mirror
# the generation settings a real LLM call takes (temperature, token budget).
DEFAULTS = {"temperature": 0.7, "max_tokens": 256, "model": "demo"}


def total(*numbers):
    """Return the sum of any number of positional arguments.

    *numbers gathers every positional argument into a tuple, so total() is 0,
    total(5) is 5, and total(2, 4, 6) is 12. Because sum() of an empty tuple
    is 0, the empty case needs no special handling.
    """
    return sum(numbers)


def average(*numbers, ndigits=2):
    """Return the mean of the given numbers, rounded to ndigits.

    ndigits is keyword-only: it sits after the *numbers gather, so it can only
    be passed by name (average(1, 2, 3, ndigits=4)) and a stray positional
    number can never be mistaken for it. An empty call returns 0.0.
    """
    if not numbers:
        return 0.0
    return round(sum(numbers) / len(numbers), ndigits)


def make_counter(start=0, step=1):
    """Return a function that yields start, start+step, start+2*step, ...

    This is a closure: the inner counter() captures the enclosing 'count'
    variable and, via 'nonlocal', updates it in place. Each call to
    make_counter builds a fresh, independent 'count', so two counters never
    interfere with each other.
    """
    count = start

    def counter():
        nonlocal count            # rebind the enclosing count, not a new local
        value = count
        count += step
        return value

    return counter


def make_multiplier(factor):
    """Return a function that multiplies its argument by the captured factor.

    make_multiplier(3) returns a 'triple' function and make_multiplier(10)
    returns a 'tenfold' function; each remembers its own 'factor' because the
    inner multiply() closes over the enclosing scope.
    """
    def multiply(n):
        return n * factor         # 'factor' comes from the enclosing scope

    return multiply


def build_request(prompt, **kwargs):
    """Merge caller keyword arguments over DEFAULTS — the ML config pattern.

    **kwargs gathers every extra keyword argument into a dict. Spreading
    {**DEFAULTS, **kwargs} builds a new dict where the caller's values win,
    because they are applied last. This is exactly how a high-level helper
    accepts generation settings and forwards them to a real model call.
    """
    config = {**DEFAULTS, **kwargs}
    return {"prompt": prompt, **config}


def main():
    """Print a short, deterministic demonstration of every function."""
    print(f"total() -> {total()}")
    print(f"total(2, 4, 6) -> {total(2, 4, 6)}")
    print(f"average(10, 20, 30) -> {average(10, 20, 30)}")
    print(f"average(1, 2, 3, ndigits=4) -> {average(1, 2, 3, ndigits=4)}")

    counter = make_counter()
    print(f"counter: {counter()} {counter()} {counter()} {counter()}")
    other = make_counter(100)
    print(f"second counter is independent: {other()}")

    triple = make_multiplier(3)
    tenfold = make_multiplier(10)
    print(f"triple(5) -> {triple(5)} ; tenfold(5) -> {tenfold(5)}")

    default_request = build_request("hello")
    print(f"build_request('hello') -> {default_request}")
    overridden = build_request("hello", temperature=0.2)
    print(
        "build_request('hello', temperature=0.2) -> "
        f"temperature={overridden['temperature']}, model={overridden['model']}"
    )
    return 0


if __name__ == "__main__":
    main()
metadata.yml (745 bytes)
lesson_id: D058
day: 58
kind: python-program
languages: [python]
setup_commands:
  - cd labs/sections/programming-with-python/day-058-scope-closures-and-args-kwargs
  - python3 --version
run_commands:
  - python3 examples/flexible.py
  - python3 -c "import sys; sys.path.insert(0, 'examples'); from flexible import make_counter; c = make_counter(); print(c(), c(), c())"
  - python3 starter/flexible.py
test_commands:
  - bash tests/run_tests.sh
cleanup_commands:
  - 'git checkout -- starter/flexible.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 -> 18 checks, 0 failure(s), exit 0'
requirements/README.md (936 bytes)
# Dependencies — Day 058 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 earlier in the
  course.
- `bash` for the test runner (preinstalled on macOS and Linux).
- Only the Python standard library is used — in fact only core language
  features (`*args`, `**kwargs`, closures, `nonlocal`) and no imports at all
  in the module itself. There is deliberately no `requirements.txt`: these
  are built-in language features that run on any plain Python install.

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 module is pure standard-library Python
and behaves identically everywhere.
starter/flexible.py (3781 bytes)
#!/usr/bin/env python3
"""flexible.py — YOUR working file.

Build these flexible functions one exercise at a time. Each numbered exercise
below names exactly what to write. The finished reference is in
examples/flexible.py — try each exercise yourself before peeking.

When all five exercises are done, running this file prints the same demo as
the reference:

    python3 starter/flexible.py

Then run:  bash tests/run_tests.sh
"""

# Module-level (global) defaults, provided. build_request fills these in when
# the caller does not override them. They mirror LLM generation settings.
DEFAULTS = {"temperature": 0.7, "max_tokens": 256, "model": "demo"}


def total(*numbers):
    """Return the sum of any number of positional arguments."""
    # Exercise 1: VARIADIC POSITIONAL ARGUMENTS.
    # *numbers gathers every positional argument into a tuple. Return their
    # sum. Hint: sum() of an empty tuple is 0, so total() should return 0
    # with no special case.
    raise NotImplementedError("Exercise 1: implement total")


def average(*numbers, ndigits=2):
    """Return the mean of the numbers, rounded to ndigits (keyword-only)."""
    # Exercise 2: A KEYWORD-ONLY ARGUMENT.
    # ndigits sits after *numbers, so it is keyword-only. If there are no
    # numbers, return 0.0. Otherwise return round(sum(numbers)/len(numbers),
    # ndigits).
    raise NotImplementedError("Exercise 2: implement average")


def make_counter(start=0, step=1):
    """Return a function yielding start, start+step, start+2*step, ..."""
    # Exercise 3: A CLOSURE WITH PRIVATE STATE.
    # 1. Set count = start.
    # 2. Define an inner function counter() that:
    #      - declares 'nonlocal count' (so it rebinds the enclosing count),
    #      - remembers the current value, adds step to count, returns the
    #        remembered value.
    # 3. Return the inner counter function (do NOT call it here).
    raise NotImplementedError("Exercise 3: implement make_counter")


def make_multiplier(factor):
    """Return a function that multiplies its argument by the captured factor."""
    # Exercise 4: A CLOSURE FACTORY.
    # Define an inner function multiply(n) that returns n * factor (factor is
    # captured from this enclosing scope), then return multiply.
    raise NotImplementedError("Exercise 4: implement make_multiplier")


def build_request(prompt, **kwargs):
    """Merge caller keyword arguments over DEFAULTS (the ML config pattern)."""
    # Exercise 5: **kwargs CONFIG MERGE.
    # 1. **kwargs gathers extra keyword arguments into a dict.
    # 2. Build config = {**DEFAULTS, **kwargs} so the caller's values win
    #    (they are spread last).
    # 3. Return {"prompt": prompt, **config}.
    raise NotImplementedError("Exercise 5: implement build_request")


def main():
    """Print a short, deterministic demonstration of every function. (Provided.)"""
    print(f"total() -> {total()}")
    print(f"total(2, 4, 6) -> {total(2, 4, 6)}")
    print(f"average(10, 20, 30) -> {average(10, 20, 30)}")
    print(f"average(1, 2, 3, ndigits=4) -> {average(1, 2, 3, ndigits=4)}")

    counter = make_counter()
    print(f"counter: {counter()} {counter()} {counter()} {counter()}")
    other = make_counter(100)
    print(f"second counter is independent: {other()}")

    triple = make_multiplier(3)
    tenfold = make_multiplier(10)
    print(f"triple(5) -> {triple(5)} ; tenfold(5) -> {tenfold(5)}")

    default_request = build_request("hello")
    print(f"build_request('hello') -> {default_request}")
    overridden = build_request("hello", temperature=0.2)
    print(
        "build_request('hello', temperature=0.2) -> "
        f"temperature={overridden['temperature']}, model={overridden['model']}"
    )
    return 0


if __name__ == "__main__":
    main()
starter/functions-worksheet.md (1480 bytes)
# Flexible-functions design worksheet

Fill this in *before* writing code for the practice assignment. Designing each
function on paper first — its signature, what it gathers or captures, and its
edge cases — is exactly the discipline this lesson teaches. One row per
function.

## The functions

| Function | Signature | Uses *args / **kwargs / closure? | What it gathers or captures | Returns |
| -------- | --------- | -------------------------------- | --------------------------- | ------- |
| (variadic aggregator) |  |  |  |  |
| (closure factory)     |  |  |  |  |
| (**kwargs config merge) |  |  |  |  |

## Calls and edge cases

For each function, write one ordinary call and one edge case (empty input, an
override, or a second independent instance) with the value you expect.

1. Aggregator — good call / edge case:
2. Closure factory — good call / edge case (prove it remembers state or that two
   instances are independent):
3. Config merge — good call / edge case (an override that wins over a default):

## Scope check

For your closure factory, answer in one line each:

- What variable does the inner function capture, and from which scope?
- Does the inner function need `nonlocal`? Why or why not?
- Do two instances made by two factory calls share state or not? Why?

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

- The demo output of `python3 <your-file>.py`:
- The three `assert` lines you wrote and that they all passed (script exited 0):
tests/run_tests.sh (5323 bytes)
#!/usr/bin/env bash
# Tests for the Day 058 lab. Run from the lab directory:
#   bash tests/run_tests.sh
#
# Exercises the reference module (examples/flexible.py) by importing its
# functions and asserting their behaviour: variadic total, keyword-only
# average, the make_counter closure (private, independent state), the
# make_multiplier factory, and the build_request **kwargs config merge. It
# also runs the module demo and checks its output, confirms DEFAULTS is not
# mutated, and finally checks the learner's starter — structurally while the
# exercises are unfinished, and to the same strict standard once complete.
# No network, non-interactive. Exits 0 only if every check passes.
set -u

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

lab_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
ref_dir="${lab_dir}/examples"
starter_dir="${lab_dir}/starter"
ref="${ref_dir}/flexible.py"
starter="${starter_dir}/flexible.py"
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_py <label> <module_dir> <python-body>
# Imports 'flexible' from module_dir and runs the assertion body; passes when
# python3 exits 0.
check_py() {
  local label="$1" mod_dir="$2" body="$3"
  if python3 -c "import sys; sys.path.insert(0, '${mod_dir}'); import flexible
${body}" 2>/dev/null; then
    check "${label}" "yes"
  else
    check "${label}" "no"
  fi
}

run_fn_checks() {
  local mod_dir="$1"
  echo "Testing functions imported from ${mod_dir}/flexible.py ..."
  check_py "total() is 0 and total(2,4,6) is 12" "${mod_dir}" \
    "assert flexible.total() == 0; assert flexible.total(2, 4, 6) == 12"
  check_py "average(10,20,30) is 20.0 and empty is 0.0" "${mod_dir}" \
    "assert flexible.average(10, 20, 30) == 20.0; assert flexible.average() == 0.0"
  check_py "average ndigits is keyword-only" "${mod_dir}" \
    "assert flexible.average(1, 2, 3, ndigits=4) == 2.0; assert flexible.average(1, 2, 3, 4) == 2.5"
  check_py "make_counter yields 0,1,2 (private state)" "${mod_dir}" \
    "c = flexible.make_counter(); assert [c(), c(), c()] == [0, 1, 2]"
  check_py "two counters are independent" "${mod_dir}" \
    "c = flexible.make_counter(); d = flexible.make_counter(100); assert c() == 0; assert d() == 100; assert c() == 1"
  check_py "make_counter respects start and step" "${mod_dir}" \
    "e = flexible.make_counter(10, 5); assert [e(), e(), e()] == [10, 15, 20]"
  check_py "make_multiplier captures its own factor" "${mod_dir}" \
    "t = flexible.make_multiplier(3); x = flexible.make_multiplier(10); assert t(5) == 15; assert x(5) == 50"
  check_py "build_request fills all defaults" "${mod_dir}" \
    "r = flexible.build_request('hello'); assert r == {'prompt': 'hello', 'temperature': 0.7, 'max_tokens': 256, 'model': 'demo'}"
  check_py "build_request lets caller override, model kept" "${mod_dir}" \
    "r = flexible.build_request('hi', temperature=0.2, max_tokens=500); assert r['temperature'] == 0.2 and r['max_tokens'] == 500 and r['model'] == 'demo'"
  check_py "build_request does not mutate DEFAULTS" "${mod_dir}" \
    "flexible.build_request('hi', temperature=0.2); assert flexible.DEFAULTS == {'temperature': 0.7, 'max_tokens': 256, 'model': 'demo'}"
}

# check_demo <label> <script> <needle>
check_demo() {
  local label="$1" script="$2" needle="$3"
  local out
  out="$(python3 "${script}" 2>&1)"
  if printf '%s' "${out}" | grep -qF "${needle}"; then
    check "${label}" "yes"
  else
    check "${label}" "no"
    echo "    (output: ${out})"
  fi
}

run_demo_checks() {
  local script="$1"
  echo "Testing the demo of ${script} ..."
  check_demo "demo prints total(2, 4, 6) -> 12"      "${script}" "total(2, 4, 6) -> 12"
  check_demo "demo prints counter: 0 1 2 3"          "${script}" "counter: 0 1 2 3"
  check_demo "demo prints triple(5) -> 15"           "${script}" "triple(5) -> 15"
  check_demo "demo prints the default request dict"  "${script}" "'model': 'demo'"
}

# --- Reference module: always tested strictly ---
run_fn_checks "${ref_dir}"
run_demo_checks "${ref}"

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

if grep -q 'NotImplementedError' "${starter}"; then
  echo "Note: starter/flexible.py still has unfinished exercises — testing structure only."
  grep -q 'def total' "${starter}"        && check "starter defines total" "yes"        || check "starter defines total" "no"
  grep -q 'def make_counter' "${starter}" && check "starter defines make_counter" "yes" || check "starter defines make_counter" "no"
  grep -q 'def build_request' "${starter}" && check "starter defines build_request" "yes" || check "starter defines build_request" "no"
else
  run_fn_checks "${starter_dir}"
  run_demo_checks "${starter}"
  grep -q 'nonlocal' "${starter}" && check "starter uses nonlocal in the closure" "yes" || check "starter uses nonlocal in the closure" "no"
fi

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

Troubleshooting

Troubleshooting — Day 058 lab

python: command not found

Use python3 explicitly, as every command in this lab does. On macOS and most Linux systems, bare python may be missing or point to an old version. Check with python3 --version.

The starter raises NotImplementedError when I run it

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

UnboundLocalError: local variable 'count' referenced before assignment

Your inner counter function assigns to count (with count += step), but you did not declare nonlocal count. Without it, Python treats count as a brand-new local for the whole inner function, so reading it before the local is assigned fails. Add nonlocal count as the first line inside counter.

Both of my counters advance together

Each make_counter call must create its own count inside the function body (count = start). If you captured a module-level variable or otherwise shared one variable across calls, every returned closure updates the same state. The fix is to keep count a local of make_counter, so each call gets a fresh, independent one.

average(1, 2, 3, 4) gave 2.5, not a rounding change

That is correct. ndigits is keyword-only because it sits after *numbers, so the 4 is treated as another number to average ((1+2+3+4)/4 = 2.5), not as the number of digits. To set the rounding you must pass it by name: average(1, 2, 3, 4, ndigits=1).

build_request ignores my overrides

You merged the dictionaries in the wrong order. Write {**DEFAULTS, **kwargs} so the caller's kwargs are spread last and therefore win. {**kwargs, **DEFAULTS} would let the defaults overwrite the caller's values, which is the opposite of what you want.

TypeError: build_request() got an unexpected keyword argument 'temperature'

Your signature is missing the **kwargs parameter, so extra keyword arguments have nowhere to be gathered. Make the signature def build_request(prompt, **kwargs): — the **kwargs must be there to accept arbitrary named options.

ModuleNotFoundError: No module named 'flexible' in an import one-liner

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

python3 -c "import sys; sys.path.insert(0, 'examples'); from flexible import make_counter; print(make_counter()())"

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

bash: tests/run_tests.sh: Permission denied

Run it through bash explicitly, as the README shows: bash tests/run_tests.sh. You do not need to chmod +x anything.

Security notes

Security notes — Day 058 lab

  • What the module does: defines pure functions and closures and prints a short demo. It makes no network connections, needs no privileges, reads no files, and writes no files. The tests import the module in separate python3 -c processes and assert return values; nothing is persisted.

  • Forward **kwargs deliberately, not blindly. build_request shows the real-world pattern: a function gathers arbitrary keyword arguments and merges or forwards them. In production code this is a genuine risk — a function that passes user-supplied **kwargs straight into a database call, a model call, or an object constructor can let a caller set options that were never meant to be exposed (a "mass assignment" bug). The safe habit is to validate or allow-list the keys you accept and forward, rather than trusting the whole kwargs dict.

  • Closures keep captured state private — and alive. A variable captured by a closure is reachable only through the inner function, which is good for encapsulation (a counter's state cannot be reached in and corrupted). The flip side: a closure keeps its captured variables alive for as long as the closure exists, so a closure that captured a large object holds that object in memory. Be mindful of what long-lived closures capture.

  • Prefer arguments and return values over globals. The module changes no global state; DEFAULTS is read but never mutated (the merge builds a new dict). Mutating module-level globals from many functions makes behaviour depend on call order and hides data flow — avoid it except for deliberate, declared cases with the global keyword.

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