Programming with Python › Functions and Program Design › Day 58
Hands-on lab — Day 58: Scope, Closures, and *args/**kwargs
- ← Back to the Day 58 lesson
- Open the hands-on files on GitHub — clone or download them from the public labs repository
- Local path in your clone:
labs/sections/programming-with-python/day-058-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
- Lesson title: Scope, Closures, and *args/**kwargs
- Day number: 58 of 365
- Lesson article: https://ai-roadmap-365.github.io/day-058-scope-closures-and-args-kwargs
- Lab files: everything you need is in this directory — follow “How to run” below.
- Browse the course locally: from the repository root, this lab also appears in the course website at
/labs/day-058-scope-closures-and-args-kwargswhen the site is running.
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
*argsthat accept any number of positional inputs, and**kwargsthat 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
nonlocalto 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
pythonforpython3if 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).bashfor 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'smain()demo, printing one call of each function: the variadictotal, the keyword-onlyaverage, amake_counterclosure advancing0 1 2 3, an independent second counter, twomake_multiplierclosures with their own factors, andbuild_requestfilling 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 behindif __name__ == "__main__":.- The two-counter one-liner — shows that
cadvances0, 1, 2while a second counterdreturns100in the middle, proving each closure holds its own capturedcount. 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 thatDEFAULTSis 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
python3 examples/flexible.pyprints the demo above, includingcounter: 0 1 2 3andsecond counter is independent: 100.- The two-counter one-liner (command 3 above) prints
0 1 100 2, proving the closures keep independent state. average(1, 2, 3, ndigits=4)returns2.0andaverage(1, 2, 3, 4)returns2.5— the4is a number, becausendigitsis keyword-only.build_request("hello", temperature=0.2)overrides onlytemperature;modelstaysdemo.- Complete the five exercises in
starter/flexible.py, run its demo, and confirm it matches the reference. - Run the tests (next section) — every check must pass.
Tests
bash tests/run_tests.sh
Expected final line while the starter is unfinished: 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
- Write a minimal
loggeddecorator (a closure) that wraps any function to print its arguments and return value, forwarding withdef wrapper(*args, **kwargs):so it works ontotal,average, andbuild_requestalike. - Return two closures from one factory — a
next_valueand aresetthat share the same capturedcountvianonlocal— and show a reset through one is visible through the other. - 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 amake_multiplier(i)factory, and explain why in a comment. - Add a
summarize(*numbers, **options)that returns a dict with the count, total, and average, lettingoptionssetndigits, and assert its output.
Navigation
- 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,
**kwargsconfiguration, 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 -cprocesses and assert return values; nothing is persisted. -
Forward
**kwargsdeliberately, not blindly.build_requestshows 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**kwargsstraight 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 wholekwargsdict. -
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;
DEFAULTSis 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 theglobalkeyword. -
Reading before running: every file in this lab is short and commented. Read
examples/flexible.pyandtests/run_tests.shbefore running them. Running unread scripts is one of the most common ways developers get compromised; the course's rule is that every lab script is small enough to read and understand first.