Programming with PythonFunctions and Program Design › Day 62

Hands-on lab — Day 62: Recursion

Commands

Setup

cd labs/sections/programming-with-python/day-062-recursion
python3 --version

Run

python3 examples/recursion.py factorial --n 5
python3 examples/recursion.py sum --values 1,2,3,4,5
python3 examples/recursion.py flatten --data "[1, [2, [3, 4]], 5]"
python3 examples/recursion.py treesum --data '{"a": 1, "b": [2, 3]}'
python3 examples/recursion.py fib --n 30

Test

bash tests/run_tests.sh

File tree

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

Lab README

Day 062 lab — Recursive Thinking

Lesson

  • Lesson title: Recursion
  • Day number: 62 of 365
  • Lesson article: https://ai-roadmap-365.github.io/day-062-recursion
  • 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-062-recursion when the site is running.

Purpose

Day 62's lesson teaches recursion: a function that calls itself, built from a base case (when to stop) and a recursive case (a smaller subproblem). This lab makes that concrete. You implement five recursive functions from a starter, one exercise at a time: factorial, a recursive list_sum, a flatten of an arbitrarily nested list, a tree_sum that walks a nested dict/list, and a naive fib_naive that counts its own calls. Then you run a fib command that compares naive recursion against an lru_cache-memoized version and reports the call counts, so the exponential blow-up of naive Fibonacci — and the cure — are things you measure, not just read about. The recursive shapes here (walking a tree, divide-and-conquer) are exactly the ones you meet in AI: parse trees, decision trees, and nested JSON.

Learning objectives

  • Write a recursive function with a correct base case and recursive case, and explain why every call must move toward the base case.
  • Use recursion where it genuinely fits — walking an arbitrarily nested list or dict/tree — rather than forcing a loop.
  • Recognise tree recursion (Fibonacci) and why naive Fibonacci is exponential.
  • Fix exponential recursion with memoization using functools.lru_cache, and measure the reduction in call count.
  • Understand the call stack, RecursionError, and sys.setrecursionlimit, and know when a loop is the better choice.

Prerequisites

  • The Day 62 lesson (read it first — it explains every part this lab builds).
  • Day 57: functions — definition, arguments, and return values. Recursion is just a function calling itself, so you must be comfortable with functions.
  • Days 52–55: lists and dictionaries, which the nested-data exercises walk.
  • 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 exercises do a little arithmetic and walk small data structures; 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 — functools, argparse, json, and sys all ship with Python. 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 functools.lru_cache decorator and the json module are part of Python itself.

Installation

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

cd labs/sections/programming-with-python/day-062-recursion
python3 --version   # confirm Python 3.8+ is available

File structure

day-062-recursion/
├── README.md                       ← you are here
├── metadata.yml                    ← machine-readable lab metadata
├── starter/
│   ├── recursion.py                ← YOUR working file (5 numbered exercises)
│   └── thinking-worksheet.md       ← name the base/recursive case before coding
├── examples/
│   └── recursion.py                ← complete reference implementation
├── tests/
│   └── run_tests.sh                ← automated checks (output, exit codes, call counts)
├── expected-output/
│   ├── sample-run.txt              ← real captured session with the reference
│   ├── test-run.txt                ← real captured run of the test suite
│   └── FIELDS.md                   ← required behaviour + the fib call-count table
├── requirements/
│   └── README.md                   ← dependency statement (Python 3 only)
├── troubleshooting.md
└── security.md

How to run

From this directory:

## 1. See the finished tool first — every subcommand is one recursive idea.
python3 examples/recursion.py factorial --n 5
python3 examples/recursion.py sum --values 1,2,3,4,5
python3 examples/recursion.py flatten --data "[1, [2, [3, 4]], 5]"
python3 examples/recursion.py treesum --data '{"a": 1, "b": {"c": 2, "d": [3, 4]}}'

## 2. Watch the exponential blow-up of naive recursion, and the cure.
python3 examples/recursion.py fib --n 10
python3 examples/recursion.py fib --n 30

## 3. Your task: complete the five exercises in the starter, then run it.
python3 starter/recursion.py factorial --n 6

## 4. Prove a function is importable (the payoff of the main guard).
python3 -c "import sys; sys.path.insert(0, 'examples'); from recursion import flatten; print(flatten([1, [2, [3]]]))"

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

What the commands do

  • factorial --n 5 — computes 5! by recursion: base case n <= 1 returns 1, recursive case returns n * factorial(n - 1).
  • sum --values 1,2,3,4,5 — adds a list by recursion: base case empty list returns 0, recursive case is first + list_sum(rest).
  • flatten --data "[1, [2, [3, 4]], 5]" — flattens an arbitrarily nested list; the function recurses into each sublist until it reaches leaf values.
  • treesum --data '{...}' — walks a nested dict/list tree and adds every number in it; numbers are the base case, lists and dicts are the recursive case. Non-numbers are ignored.
  • fib --n 30 — computes the 30th Fibonacci number two ways and prints the call counts: the naive version's exponential number of calls and the lru_cache-memoized version's linear number of computations. This is the headline comparison of the lab.
  • python3 -c "...flatten..." — imports one function and calls it without running the whole tool, which works only because the main guard holds main back on import.
  • bash tests/run_tests.sh — drives the reference module through every subcommand (good and bad input), checks output and exit codes, imports the pure functions to check return values, and asserts the memoized Fibonacci makes far fewer calls than the naive one. Exits 0 only if every check passes.

Expected output

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

$ python3 examples/recursion.py fib --n 10
fib(10) = 55
naive recursion: 177 calls
memoized (lru_cache): 11 computations, 8 cache hits
the naive version made 16x more calls than the memoized version computed

The call counts are fixed mathematics, so your output will match exactly. expected-output/FIELDS.md lists the required behaviour for every input and the full fib call-count table.

Validation steps

  1. python3 examples/recursion.py factorial --n 5 prints factorial(5) = 120; factorial --n 0 prints factorial(0) = 1 (the base case).
  2. python3 examples/recursion.py flatten --data "[1, [2, [3, 4]], 5]" prints flatten -> [1, 2, 3, 4, 5].
  3. python3 examples/recursion.py treesum --data '{"a": 1, "b": [2, 3]}' prints treesum -> 6.
  4. python3 examples/recursion.py fib --n 10 prints fib(10) = 55 and a naive recursion: 177 calls line with far more calls than the memoized computations.
  5. python3 examples/recursion.py factorial --n -3; echo $? is rejected with a clear error and exits 1.
  6. Complete the five exercises in starter/recursion.py, run it on the same inputs, and confirm it matches the reference.
  7. Run the tests (next section) — every check must pass.

Tests

bash tests/run_tests.sh

Expected final line while the starter is unfinished: 20 checks, 0 failure(s). Once you complete all five starter exercises, the suite runs your version through the same inputs plus the main-guard check, giving 31 checks, 0 failure(s). The command exits 0 on success and non-zero on any failure, so it can run in CI. A full captured run is in expected-output/test-run.txt.

Cleanup

The tool writes no files, so there is nothing to delete. To reset your work, restore the starter from git: git checkout -- starter/recursion.py. The test runner leaves nothing behind.

Troubleshooting

See troubleshooting.md for the full list: python vs python3, the all-important RecursionError (missing base case vs genuinely deep input) and sys.setrecursionlimit, why fib --n 40 seems to hang (naive recursion is exponential), JSON quoting for --data, argparse's exit code 2 versus the tool's exit code 1, importing vs running, and permissions.

Security notes

See security.md. Short version: the tool makes no network calls, needs no privileges, and reads and writes no files. It parses --data with json, never eval(), and the recursion-depth limit is a safety feature that turns runaway or maliciously deep input into a clean error instead of an exhausted machine.

Extension exercises

  1. Add a depth subcommand that returns the maximum nesting depth of a JSON structure by recursion (base case: a non-container is depth 0; recursive case: 1 + the max depth of the children).
  2. Rewrite factorial and list_sum as plain loops, keep both versions, and write a comment explaining which you would ship and why (hint: Python has no tail-call optimization, so deep recursion risks RecursionError).
  3. Add your own memoized function — for example a recursive count_paths through a grid — with @lru_cache, and print cache_info() to show the hits and misses.
  4. Write your own tests/test_recursion.py that imports factorial, flatten, and tree_sum and asserts their behaviour, printing all tests passed only if every assertion holds.
  • Previous day: Day 61 — Writing Readable Code (labs/sections/programming-with-python/day-061-writing-readable-code/).
  • Next day: Day 63 — Designing a Small Program Well (labs/sections/programming-with-python/day-063-designing-a-small-program-well/).
  • This week (Week 9): Functions and program design — functions, scope, modules, the standard library, readable code, recursion, and a capstone on designing a small program well.

Expected output

FIELDS.md

# Expected output — Day 062 lab

These are real captured runs from the authoring machine (macOS, Apple
Silicon, Python 3.14.0, bash 3.2, 2026-07-13). The functions are pure and
deterministic: given the same input they produce the same output and the same
exit codes on every platform Python 3 runs on.

## Files

- `sample-run.txt` — the reference module driven through every subcommand:
  `factorial` (including the base case `factorial(0)` and a rejected
  negative), recursive `sum`, `flatten` of a nested list, `treesum` over a
  nested dict/list tree, `fib` for n = 10 and n = 30 (with call counts), and
  a `python3 -c` import check of `flatten`.
- `test-run.txt` — a full run of `bash tests/run_tests.sh` with the starter
  still unfinished (20 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:

| Command | Output (stream) | Exit code |
| --- | --- | --- |
| `factorial --n 5` | `factorial(5) = 120` (stdout) | 0 |
| `factorial --n 0` | `factorial(0) = 1` (stdout) | 0 |
| `factorial --n -3` | `error: factorial is undefined for negative numbers` (stderr) | 1 |
| `sum --values 1,2,3,4,5` | `sum([1, 2, 3, 4, 5]) = 15` (stdout) | 0 |
| `sum --values ""` | `sum([]) = 0` (stdout) | 0 |
| `sum --values 1,x,3` | `error: --values must be comma-separated integers, got '1,x,3'` (stderr) | 1 |
| `flatten --data "[1, [2, [3, 4]], 5]"` | `flatten -> [1, 2, 3, 4, 5]` (stdout) | 0 |
| `flatten --data "[1, 2"` | `error: --data is not valid JSON (...)` (stderr) | 1 |
| `treesum --data '{"a": 1, "b": {"c": 2, "d": [3, 4]}}'` | `treesum -> 10` (stdout) | 0 |
| `fib --n 10` | `fib(10) = 55`, then `naive recursion: 177 calls`, then `memoized (lru_cache): 11 computations, 8 cache hits` (stdout) | 0 |

## The fib call counts (deterministic, machine-independent)

The Fibonacci call counts are fixed mathematics, not timing, so they are the
same everywhere:

| n | fib(n) | naive calls | memoized computations (`misses`) |
| --- | --- | --- | --- |
| 10 | 55 | 177 | 11 |
| 25 | 75025 | 242785 | 26 |
| 30 | 832040 | 2692537 | 31 |

The naive call count is `2 * fib(n + 1) - 1`; the memoized version computes
each of `fib(0)` through `fib(n)` exactly once, so it does `n + 1`
computations. This is why the test asserts a **call-count** reduction (naive
≫ memoized), never a wall-clock time — the ratio is identical on a slow
laptop and a fast server.

## Platform notes

- The only visible difference between platforms is the shell prompt (`$`)
  shown before each command; the program's own output is identical.
- Passing very deeply nested `--data` (thousands of levels) can raise a
  `RecursionError`; the tool catches it and prints a clear message with exit
  code 1 rather than a traceback. The default recursion limit is about 1000
  frames (see `sys.setrecursionlimit`), and the exact depth at which it
  triggers can vary slightly between Python builds — the message and exit
  code do not.

sample-run.txt

$ python3 examples/recursion.py factorial --n 5
factorial(5) = 120

$ python3 examples/recursion.py factorial --n 0
factorial(0) = 1

$ python3 examples/recursion.py factorial --n -3   ; echo "exit: $?"
error: factorial is undefined for negative numbers
exit: 1

$ python3 examples/recursion.py sum --values 1,2,3,4,5
sum([1, 2, 3, 4, 5]) = 15

$ python3 examples/recursion.py flatten --data "[1, [2, [3, 4]], 5]"
flatten -> [1, 2, 3, 4, 5]

$ python3 examples/recursion.py treesum --data '{"a": 1, "b": {"c": 2, "d": [3, 4]}}'
treesum -> 10

$ python3 examples/recursion.py fib --n 10
fib(10) = 55
naive recursion: 177 calls
memoized (lru_cache): 11 computations, 8 cache hits
the naive version made 16x more calls than the memoized version computed

$ python3 examples/recursion.py fib --n 30
fib(30) = 832040
naive recursion: 2692537 calls
memoized (lru_cache): 31 computations, 28 cache hits
the naive version made 86856x more calls than the memoized version computed

$ python3 -c "import sys; sys.path.insert(0, 'examples'); from recursion import flatten; print(flatten([1, [2, [3, [4]]]]))"
[1, 2, 3, 4]

test-run.txt

Testing <repo>/labs/sections/programming-with-python/day-062-recursion/examples/recursion.py ...
  ok: factorial 5 = 120
  ok: factorial 0 = 1 (base case)
  ok: factorial negative rejected
  ok: sum 1..5 = 15
  ok: sum empty = 0 (base case)
  ok: sum bad values rejected
  ok: flatten nested list
  ok: flatten rejects bad JSON
  ok: treesum nested tree = 10
  ok: fib 10 value = 55
  ok: fib 10 naive = 177 calls
  ok: fib 10 memoized 11 computations
Testing importability of examples/recursion.py ...
  ok: import factorial computes 1 and 120
  ok: import list_sum computes 0 and 15
  ok: import flatten handles deep nesting
  ok: import tree_sum walks a tree and ignores non-numbers
Testing that memoization slashes the call count (examples/recursion.py) ...
  ok: memoized fib(25) computes 26 vs naive's 242785 calls
Testing starter/recursion.py ...
  ok: starter is valid Python
Note: starter/recursion.py still has unfinished exercises — testing structure only.
  ok: starter defines factorial
  ok: starter defines fib_naive

20 checks, 0 failure(s).

Source files

examples/recursion.py (9035 bytes)
#!/usr/bin/env python3
"""recursion.py — worked examples of recursion, as a small CLI.

Every subcommand demonstrates one recursive function so you can watch the
base case and the recursive case do their work on real input:

    factorial  n! by recursion (the "hello world" of recursion)
    sum        add a flat list of numbers by recursion
    flatten    turn an arbitrarily nested list into one flat list
    treesum    add every number in a nested dict/list (a tree walk)
    fib        Fibonacci — naive recursion vs an lru_cache-memoized version,
               reporting call counts so the exponential blow-up is visible

Input comes entirely from command-line arguments (never an interactive
prompt), so the tool can be tested and automated without a human. The nested
data for `flatten` and `treesum` is passed as JSON, which maps cleanly onto
Python lists and dicts.

    python3 recursion.py factorial --n 5
    python3 recursion.py sum --values 1,2,3,4,5
    python3 recursion.py flatten --data "[1, [2, [3, 4]], 5]"
    python3 recursion.py treesum --data '{"a": 1, "b": {"c": 2, "d": [3, 4]}}'
    python3 recursion.py fib --n 30
"""
import argparse
import json
import sys
from functools import lru_cache


def factorial(n):
    """Return n! by recursion.

    Base case: 0! and 1! are 1. Recursive case: n! = n * (n - 1)!. Each call
    shrinks n toward the base case, so the recursion is guaranteed to stop.
    """
    if n < 0:
        raise ValueError("factorial is undefined for negative numbers")
    if n <= 1:                      # base case — stop here, no further calls
        return 1
    return n * factorial(n - 1)     # recursive case — a smaller subproblem


def list_sum(numbers):
    """Return the sum of a flat list by recursion.

    Base case: the empty list sums to 0. Recursive case: the sum is the first
    element plus the sum of the rest. Each call works on a shorter list.
    """
    if not numbers:                 # base case — nothing left to add
        return 0
    return numbers[0] + list_sum(numbers[1:])   # recursive case


def flatten(items):
    """Return a single flat list from an arbitrarily nested list.

    This is a problem recursion suits perfectly: the structure is nested to
    an unknown depth, and the function calls itself on each sublist until it
    reaches plain (non-list) values, which are the base case.
    """
    result = []
    for item in items:
        if isinstance(item, list):
            result.extend(flatten(item))   # recursive case — dive into the sublist
        else:
            result.append(item)            # base case — a leaf value
    return result


def tree_sum(node):
    """Add every number reachable inside a nested dict/list tree.

    A node is a number (a leaf), a list, or a dict. Numbers are the base
    case; lists and dicts are the recursive case — sum the results of walking
    each child. Anything else (strings, None) contributes 0.
    """
    if isinstance(node, bool):
        return 0                                  # bool is a subclass of int; ignore
    if isinstance(node, (int, float)):
        return node                               # base case — a numeric leaf
    if isinstance(node, list):
        return sum(tree_sum(child) for child in node)          # recurse over items
    if isinstance(node, dict):
        return sum(tree_sum(value) for value in node.values())  # recurse over values
    return 0                                      # strings, None, etc.


def fib_naive(n, counter):
    """Return the nth Fibonacci number by naive tree recursion.

    counter is a one-element list used to count how many times this function
    is called, so we can measure the exponential explosion. Each call spawns
    two more (for n >= 2), which is why naive Fibonacci is so wasteful.
    """
    counter[0] += 1
    if n < 2:                       # base cases: fib(0) = 0, fib(1) = 1
        return n
    return fib_naive(n - 1, counter) + fib_naive(n - 2, counter)


def make_memoized_fib():
    """Return an lru_cache-memoized Fibonacci function.

    lru_cache remembers each fib(n) the first time it is computed, so every
    later request for the same n is a cache hit instead of a re-computation.
    That turns the exponential tree into a linear number of computations.
    """
    @lru_cache(maxsize=None)
    def fib(n):
        if n < 2:
            return n
        return fib(n - 1) + fib(n - 2)
    return fib


def cmd_factorial(args):
    """factorial: print n! computed by recursion."""
    result = factorial(args.n)
    print(f"factorial({args.n}) = {result}")
    return 0


def cmd_sum(args):
    """sum: parse a comma-separated list of ints and add it by recursion."""
    text = args.values.strip()
    if not text:
        numbers = []
    else:
        try:
            numbers = [int(piece) for piece in text.split(",")]
        except ValueError:
            raise ValueError(f"--values must be comma-separated integers, got {args.values!r}")
    print(f"sum({numbers}) = {list_sum(numbers)}")
    return 0


def _load_json(text, expect):
    """Parse text as JSON and confirm its top-level type, or raise ValueError."""
    try:
        data = json.loads(text)
    except json.JSONDecodeError as err:
        raise ValueError(f"--data is not valid JSON ({err})")
    if expect == "list" and not isinstance(data, list):
        raise ValueError("--data must be a JSON list, e.g. [1, [2, 3]]")
    if expect == "tree" and not isinstance(data, (list, dict)):
        raise ValueError("--data must be a JSON object or list")
    return data


def cmd_flatten(args):
    """flatten: parse a nested JSON list and print it flattened by recursion."""
    nested = _load_json(args.data, expect="list")
    print(f"flatten -> {flatten(nested)}")
    return 0


def cmd_treesum(args):
    """treesum: parse a nested JSON tree and print the sum of its numbers."""
    tree = _load_json(args.data, expect="tree")
    print(f"treesum -> {tree_sum(tree)}")
    return 0


def cmd_fib(args):
    """fib: compute fib(n) two ways and report the call counts.

    The naive version counts every call; the memoized version reports its
    cache statistics. The contrast is the whole point: memoization collapses
    an exponential number of calls into a linear number of computations.
    """
    n = args.n
    counter = [0]
    naive_value = fib_naive(n, counter)
    naive_calls = counter[0]

    fib = make_memoized_fib()
    memo_value = fib(n)
    info = fib.cache_info()

    assert naive_value == memo_value, "the two methods must agree"
    print(f"fib({n}) = {memo_value}")
    print(f"naive recursion: {naive_calls} calls")
    print(f"memoized (lru_cache): {info.misses} computations, {info.hits} cache hits")
    ratio = naive_calls // max(info.misses, 1)
    print(f"the naive version made {ratio}x more calls than the memoized version computed")
    return 0


def build_parser():
    """Build the argparse parser: five subcommands, one per recursive idea."""
    parser = argparse.ArgumentParser(
        prog="recursion.py",
        description="Worked examples of recursion: factorial, sum, flatten, treesum, fib.",
    )
    subparsers = parser.add_subparsers(
        dest="command",
        required=True,
        metavar="{factorial,sum,flatten,treesum,fib}",
    )

    fac = subparsers.add_parser("factorial", help="compute n! by recursion")
    fac.add_argument("--n", type=int, required=True, help="a non-negative integer")
    fac.set_defaults(func=cmd_factorial)

    add = subparsers.add_parser("sum", help="add a comma-separated list by recursion")
    add.add_argument("--values", required=True, help="e.g. 1,2,3,4")
    add.set_defaults(func=cmd_sum)

    flat = subparsers.add_parser("flatten", help="flatten a nested JSON list")
    flat.add_argument("--data", required=True, help='e.g. "[1, [2, [3, 4]], 5]"')
    flat.set_defaults(func=cmd_flatten)

    tree = subparsers.add_parser("treesum", help="sum every number in a nested JSON tree")
    tree.add_argument("--data", required=True, help='e.g. \'{"a": 1, "b": [2, 3]}\'')
    tree.set_defaults(func=cmd_treesum)

    fib = subparsers.add_parser("fib", help="Fibonacci: naive vs lru_cache memoized")
    fib.add_argument("--n", type=int, required=True, help="which Fibonacci number")
    fib.set_defaults(func=cmd_fib)

    return parser


def main(argv):
    """Entry point: parse arguments, dispatch, and turn a ValueError into
    a clear message plus exit code 1."""
    parser = build_parser()
    args = parser.parse_args(argv[1:])
    try:
        return args.func(args)
    except ValueError as err:
        print(f"error: {err}", file=sys.stderr)
        return 1
    except RecursionError:
        print(
            "error: maximum recursion depth exceeded — the input is too deeply "
            "nested for the default limit (see sys.setrecursionlimit)",
            file=sys.stderr,
        )
        return 1


if __name__ == "__main__":
    sys.exit(main(sys.argv))
metadata.yml (831 bytes)
lesson_id: D062
day: 62
kind: python-program
languages: [python]
setup_commands:
  - cd labs/sections/programming-with-python/day-062-recursion
  - python3 --version
run_commands:
  - 'python3 examples/recursion.py factorial --n 5'
  - 'python3 examples/recursion.py sum --values 1,2,3,4,5'
  - 'python3 examples/recursion.py flatten --data "[1, [2, [3, 4]], 5]"'
  - 'python3 examples/recursion.py treesum --data ''{"a": 1, "b": [2, 3]}'''
  - 'python3 examples/recursion.py fib --n 30'
test_commands:
  - bash tests/run_tests.sh
cleanup_commands:
  - 'git checkout -- starter/recursion.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 -> 20 checks, 0 failure(s), exit 0'
requirements/README.md (925 bytes)
# Dependencies — Day 062 lab

**Python 3 only. No third-party packages.**

- `python3` (3.8 or newer; tested on 3.14.0). Preinstalled on most Linux
  distributions and installable on macOS; you set this up on Day 43.
- `bash` for the test runner (preinstalled on macOS and Linux).
- Only the Python standard library is used — specifically `functools`
  (`lru_cache`), `argparse`, `json`, and `sys`, all of which ship with
  Python. There is deliberately no `requirements.txt`: a recursion exercise
  this size 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 code is pure standard-library Python
and behaves identically everywhere.
starter/recursion.py (8050 bytes)
#!/usr/bin/env python3
"""recursion.py — YOUR working file.

Build these recursive functions one exercise at a time. Each numbered
exercise names exactly what to write, including the base case and the
recursive case. The finished reference is in examples/recursion.py — try each
exercise yourself before peeking.

When all five exercises are done, this file behaves like the reference:

    python3 starter/recursion.py factorial --n 5
    python3 starter/recursion.py sum --values 1,2,3,4,5
    python3 starter/recursion.py flatten --data "[1, [2, [3, 4]], 5]"
    python3 starter/recursion.py treesum --data '{"a": 1, "b": [2, 3]}'
    python3 starter/recursion.py fib --n 30

Then run:  bash tests/run_tests.sh
"""
import argparse
import json
import sys
from functools import lru_cache


def factorial(n):
    """Return n! by recursion."""
    # Exercise 1: FACTORIAL.
    # 1. If n < 0, raise ValueError("factorial is undefined for negative numbers").
    # 2. BASE CASE: if n <= 1, return 1 (0! and 1! are both 1). This stops
    #    the recursion — without it the calls never end.
    # 3. RECURSIVE CASE: return n * factorial(n - 1). Each call shrinks n
    #    toward the base case.
    raise NotImplementedError("Exercise 1: implement factorial")


def list_sum(numbers):
    """Return the sum of a flat list by recursion."""
    # Exercise 2: RECURSIVE LIST SUM.
    # 1. BASE CASE: an empty list sums to 0 — `if not numbers: return 0`.
    # 2. RECURSIVE CASE: return numbers[0] + list_sum(numbers[1:]).
    #    Each call works on a shorter list (the "rest").
    raise NotImplementedError("Exercise 2: implement list_sum")


def flatten(items):
    """Return a single flat list from an arbitrarily nested list."""
    # Exercise 3: FLATTEN A NESTED LIST.
    # 1. Make an empty result list.
    # 2. For each item: if it is a list (isinstance(item, list)), it is the
    #    RECURSIVE CASE — extend result with flatten(item). Otherwise it is a
    #    leaf (the BASE CASE) — append it.
    # 3. Return result.
    raise NotImplementedError("Exercise 3: implement flatten")


def tree_sum(node):
    """Add every number reachable inside a nested dict/list tree."""
    # Exercise 4: WALK A NESTED TREE.
    # A node is a number (leaf), a list, or a dict. Handle each:
    # 1. `if isinstance(node, bool): return 0`  (bool is a subclass of int)
    # 2. BASE CASE: `if isinstance(node, (int, float)): return node`
    # 3. RECURSIVE CASE (list): return sum(tree_sum(child) for child in node)
    # 4. RECURSIVE CASE (dict): return sum(tree_sum(v) for v in node.values())
    # 5. Anything else (strings, None): return 0
    raise NotImplementedError("Exercise 4: implement tree_sum")


def fib_naive(n, counter):
    """Return fib(n) by naive tree recursion, counting every call."""
    # Exercise 5: NAIVE FIBONACCI (tree recursion).
    # 1. Increment the call counter: counter[0] += 1  (already written below;
    #    keep it first so every call is counted).
    # 2. BASE CASES: if n < 2, return n  (fib(0) = 0, fib(1) = 1).
    # 3. RECURSIVE CASE: return fib_naive(n - 1, counter) + fib_naive(n - 2, counter).
    #    Notice each call makes TWO more — that is why this is exponential.
    counter[0] += 1
    raise NotImplementedError("Exercise 5: implement fib_naive")


def make_memoized_fib():
    """Return an lru_cache-memoized Fibonacci function. (Provided.)

    lru_cache remembers each fib(n) the first time it is computed, so later
    requests for the same n are cache hits instead of re-computations. That
    turns the exponential tree into a linear number of computations.
    """
    @lru_cache(maxsize=None)
    def fib(n):
        if n < 2:
            return n
        return fib(n - 1) + fib(n - 2)
    return fib


def cmd_factorial(args):
    """factorial: print n! computed by recursion. (Provided.)"""
    print(f"factorial({args.n}) = {factorial(args.n)}")
    return 0


def cmd_sum(args):
    """sum: parse a comma-separated list and add it by recursion. (Provided.)"""
    text = args.values.strip()
    if not text:
        numbers = []
    else:
        try:
            numbers = [int(piece) for piece in text.split(",")]
        except ValueError:
            raise ValueError(f"--values must be comma-separated integers, got {args.values!r}")
    print(f"sum({numbers}) = {list_sum(numbers)}")
    return 0


def _load_json(text, expect):
    """Parse text as JSON and confirm its top-level type. (Provided.)"""
    try:
        data = json.loads(text)
    except json.JSONDecodeError as err:
        raise ValueError(f"--data is not valid JSON ({err})")
    if expect == "list" and not isinstance(data, list):
        raise ValueError("--data must be a JSON list, e.g. [1, [2, 3]]")
    if expect == "tree" and not isinstance(data, (list, dict)):
        raise ValueError("--data must be a JSON object or list")
    return data


def cmd_flatten(args):
    """flatten: parse a nested JSON list and print it flattened. (Provided.)"""
    print(f"flatten -> {flatten(_load_json(args.data, expect='list'))}")
    return 0


def cmd_treesum(args):
    """treesum: parse a nested JSON tree and print the sum of its numbers. (Provided.)"""
    print(f"treesum -> {tree_sum(_load_json(args.data, expect='tree'))}")
    return 0


def cmd_fib(args):
    """fib: compute fib(n) two ways and report the call counts. (Provided.)"""
    n = args.n
    counter = [0]
    naive_value = fib_naive(n, counter)
    naive_calls = counter[0]

    fib = make_memoized_fib()
    memo_value = fib(n)
    info = fib.cache_info()

    assert naive_value == memo_value, "the two methods must agree"
    print(f"fib({n}) = {memo_value}")
    print(f"naive recursion: {naive_calls} calls")
    print(f"memoized (lru_cache): {info.misses} computations, {info.hits} cache hits")
    ratio = naive_calls // max(info.misses, 1)
    print(f"the naive version made {ratio}x more calls than the memoized version computed")
    return 0


def build_parser():
    """Build the argparse parser: five subcommands, one per idea. (Provided.)"""
    parser = argparse.ArgumentParser(
        prog="recursion.py",
        description="Worked examples of recursion: factorial, sum, flatten, treesum, fib.",
    )
    subparsers = parser.add_subparsers(
        dest="command",
        required=True,
        metavar="{factorial,sum,flatten,treesum,fib}",
    )

    fac = subparsers.add_parser("factorial", help="compute n! by recursion")
    fac.add_argument("--n", type=int, required=True, help="a non-negative integer")
    fac.set_defaults(func=cmd_factorial)

    add = subparsers.add_parser("sum", help="add a comma-separated list by recursion")
    add.add_argument("--values", required=True, help="e.g. 1,2,3,4")
    add.set_defaults(func=cmd_sum)

    flat = subparsers.add_parser("flatten", help="flatten a nested JSON list")
    flat.add_argument("--data", required=True, help='e.g. "[1, [2, [3, 4]], 5]"')
    flat.set_defaults(func=cmd_flatten)

    tree = subparsers.add_parser("treesum", help="sum every number in a nested JSON tree")
    tree.add_argument("--data", required=True, help='e.g. \'{"a": 1, "b": [2, 3]}\'')
    tree.set_defaults(func=cmd_treesum)

    fib = subparsers.add_parser("fib", help="Fibonacci: naive vs lru_cache memoized")
    fib.add_argument("--n", type=int, required=True, help="which Fibonacci number")
    fib.set_defaults(func=cmd_fib)

    return parser


def main(argv):
    """Entry point: parse arguments, dispatch, and turn errors into exit 1. (Provided.)"""
    parser = build_parser()
    args = parser.parse_args(argv[1:])
    try:
        return args.func(args)
    except ValueError as err:
        print(f"error: {err}", file=sys.stderr)
        return 1
    except RecursionError:
        print(
            "error: maximum recursion depth exceeded — the input is too deeply "
            "nested for the default limit (see sys.setrecursionlimit)",
            file=sys.stderr,
        )
        return 1


if __name__ == "__main__":
    sys.exit(main(sys.argv))
starter/thinking-worksheet.md (1778 bytes)
# Recursion design worksheet

Fill this in *before* writing code for the practice assignment. The whole
skill of recursion is naming the **base case** and the **recursive case**
before you touch the keyboard — this worksheet forces exactly that. One row
per function you design.

## The problem

- **What are you computing (one sentence):**
- **What is the input, and how is it "smaller" on each call:**
- **What does a single, plain (non-recursive) piece of the input look like:**

## Base case and recursive case

For each recursive function you write, fill one row.

| Function | Base case (when to STOP, and what to return) | Recursive case (the smaller subproblem, and how you combine it) |
| -------- | -------------------------------------------- | --------------------------------------------------------------- |
|          |                                              |                                                                 |
|          |                                              |                                                                 |

## Termination check

For each function, answer: **why is every recursive call guaranteed to move
closer to the base case?** (If you cannot answer this, the function may recurse
forever and hit `RecursionError`.)

1.
2.

## Recursion vs iteration

For each function, would a plain loop be simpler or clearer? Say which you
chose and why. (Recursion shines for tree/nested-structure problems; a loop
is often better for a flat sequence.)

-
-

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

- One good run (command and its output):
- One rejected/edge run (command, message, and `echo $?`):
- If you wrote a Fibonacci-style function twice (naive and memoized), the two
  call counts you measured:
tests/run_tests.sh (6791 bytes)
#!/usr/bin/env bash
# Tests for the Day 062 lab. Run from the lab directory:
#   bash tests/run_tests.sh
#
# Exercises the complete reference module (examples/recursion.py) across all
# five recursive subcommands: factorial (incl. the base case and a rejected
# negative), recursive list sum (incl. the empty-list base case), flatten of
# a nested list, treesum over a nested dict/list tree, and fib (naive vs
# memoized). Every check verifies BOTH the printed output and the exit code.
# It then imports the pure functions to check return values, and — the key
# recursion assertion — imports fib_naive and the memoized fib and asserts
# the naive version makes FAR MORE calls than the memoized version computes
# (a call-count comparison, not a fragile wall-clock timing). Finally it
# checks the learner's starter — structurally while exercises are unfinished,
# and to the same strict standard once they are complete. No network,
# non-interactive. Exits 0 only if every check passes.
set -u

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

lab_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
ref="${lab_dir}/examples/recursion.py"
starter="${lab_dir}/starter/recursion.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_run <label> <script> <expect_exit> <needle> <arg...>
# Runs the module and checks the exit code and that combined output contains
# the needle.
check_run() {
  local label="$1" script="$2" expect_exit="$3" needle="$4"
  shift 4
  local out code
  out="$(python3 "${script}" "$@" 2>&1)"
  code=$?
  if [ "${code}" -eq "${expect_exit}" ] && printf '%s' "${out}" | grep -qF "${needle}"; then
    check "${label}" "yes"
  else
    check "${label}" "no"
    echo "    (exit ${code}, expected ${expect_exit}; output: ${out})"
  fi
}

run_cli_checks() {
  local script="$1"
  echo "Testing ${script} ..."
  # factorial: recursive case and the base case, plus a rejected negative.
  check_run "factorial 5 = 120"          "${script}" 0 "factorial(5) = 120"   factorial --n 5
  check_run "factorial 0 = 1 (base case)" "${script}" 0 "factorial(0) = 1"    factorial --n 0
  check_run "factorial negative rejected" "${script}" 1 "undefined for negative" factorial --n -3
  # recursive list sum, incl. the empty-list base case.
  check_run "sum 1..5 = 15"              "${script}" 0 "= 15"                 sum --values 1,2,3,4,5
  check_run "sum empty = 0 (base case)"  "${script}" 0 "sum([]) = 0"          sum --values ""
  check_run "sum bad values rejected"    "${script}" 1 "comma-separated integers" sum --values 1,x,3
  # flatten a nested list to any depth.
  check_run "flatten nested list"        "${script}" 0 "flatten -> [1, 2, 3, 4, 5]" flatten --data "[1, [2, [3, 4]], 5]"
  check_run "flatten rejects bad JSON"   "${script}" 1 "not valid JSON"       flatten --data "[1, 2"
  # treesum walks a nested dict/list tree.
  check_run "treesum nested tree = 10"   "${script}" 0 "treesum -> 10"        treesum --data '{"a": 1, "b": {"c": 2, "d": [3, 4]}}'
  # fib: value, naive call count, and that memoized computes fewer.
  check_run "fib 10 value = 55"          "${script}" 0 "fib(10) = 55"         fib --n 10
  check_run "fib 10 naive = 177 calls"   "${script}" 0 "naive recursion: 177 calls" fib --n 10
  check_run "fib 10 memoized 11 computations" "${script}" 0 "memoized (lru_cache): 11 computations" fib --n 10
}

# --- Reference module: always tested strictly ---
run_cli_checks "${ref}"

# --- Import pure functions and check return values (importability payoff) ---
echo "Testing importability of examples/recursion.py ..."
if python3 -c "import sys; sys.path.insert(0, '${lab_dir}/examples'); \
from recursion import factorial; \
assert factorial(0) == 1; assert factorial(5) == 120"; then
  check "import factorial computes 1 and 120" "yes"
else
  check "import factorial computes 1 and 120" "no"
fi
if python3 -c "import sys; sys.path.insert(0, '${lab_dir}/examples'); \
from recursion import list_sum; \
assert list_sum([]) == 0; assert list_sum([1, 2, 3, 4, 5]) == 15"; then
  check "import list_sum computes 0 and 15" "yes"
else
  check "import list_sum computes 0 and 15" "no"
fi
if python3 -c "import sys; sys.path.insert(0, '${lab_dir}/examples'); \
from recursion import flatten; \
assert flatten([1, [2, [3, 4]], 5]) == [1, 2, 3, 4, 5]"; then
  check "import flatten handles deep nesting" "yes"
else
  check "import flatten handles deep nesting" "no"
fi
if python3 -c "import sys; sys.path.insert(0, '${lab_dir}/examples'); \
from recursion import tree_sum; \
assert tree_sum({'a': 1, 'b': {'c': 2, 'd': [3, 4]}}) == 10; \
assert tree_sum({'x': 'skip', 'y': True, 'z': 5}) == 5"; then
  check "import tree_sum walks a tree and ignores non-numbers" "yes"
else
  check "import tree_sum walks a tree and ignores non-numbers" "no"
fi

# --- The key recursion assertion: memoization slashes the call count ---
# Robust: compares CALL COUNTS (naive calls vs memoized computations), never
# wall-clock time, so it is deterministic on every machine.
echo "Testing that memoization slashes the call count (examples/recursion.py) ..."
if python3 -c "import sys; sys.path.insert(0, '${lab_dir}/examples'); \
from recursion import fib_naive, make_memoized_fib; \
c = [0]; v1 = fib_naive(25, c); naive = c[0]; \
fib = make_memoized_fib(); v2 = fib(25); misses = fib.cache_info().misses; \
assert v1 == v2 == 75025, ('values', v1, v2); \
assert misses == 26, ('unique computations', misses); \
assert naive > 50 * misses, ('naive should dwarf memoized', naive, misses)"; then
  check "memoized fib(25) computes 26 vs naive's 242785 calls" "yes"
else
  check "memoized fib(25) computes 26 vs naive's 242785 calls" "no"
fi

# --- Learner starter ---
echo "Testing starter/recursion.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/recursion.py still has unfinished exercises — testing structure only."
  grep -q 'def factorial' "${starter}" && check "starter defines factorial" "yes" || check "starter defines factorial" "no"
  grep -q 'def fib_naive' "${starter}" && check "starter defines fib_naive" "yes" || check "starter defines fib_naive" "no"
else
  run_cli_checks "${starter}"
  grep -q '__name__ == "__main__"' "${starter}" && check "starter has the main guard" "yes" || check "starter has the main guard" "no"
fi

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

Troubleshooting

Troubleshooting — Day 062 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.

RecursionError: maximum recursion depth exceeded

This is the single most important error to understand in this lab. It means a function called itself so many times that it filled the call stack — the memory the interpreter uses to remember every unfinished call. There are two common causes:

  1. A missing or wrong base case. If factorial never hits n <= 1, or list_sum never hits the empty list, the calls never stop and the stack overflows. Check that every recursive call moves toward the base case (smaller n, a shorter list).
  2. Genuinely very deep input. Even a correct function can exceed Python's default limit (about 1000 frames) on input nested thousands of levels deep. The tool catches this and prints a clear message with exit code 1. You can raise the ceiling with sys.setrecursionlimit(10000), but for very deep data an iterative solution (a loop with an explicit stack) is usually the better fix — Python does not optimize deep recursion away.

fib --n 40 seems to hang

That is the lesson made visible: naive Fibonacci is exponential. fib_naive(40) makes over 300 million calls, which takes a long time. The memoized version returns instantly. If you want a large Fibonacci number, that gap is exactly why memoization exists — press Ctrl+C to stop, and try a smaller n (say 30) to see the call-count report.

find/flatten printed error: --data is not valid JSON

The --data you passed is not valid JSON. Common causes: an unclosed bracket ([1, 2), single quotes instead of double quotes inside the JSON, or the shell eating your quotes. Wrap the whole JSON value in single quotes so the shell passes it through unchanged:

python3 examples/recursion.py treesum --data '{"a": 1, "b": [2, 3]}'

sum --values rejects my input

--values must be a comma-separated list of integers, e.g. 1,2,3,4. Spaces inside the list, or non-numbers (1,x,3), are rejected with a clear message and exit code 1. Quote the value if it contains spaces.

error: the following arguments are required: --n (exit code 2)

That message comes from argparse itself, not from the recursive code: a required option was missing. Argparse prints the usage line and exits with code 2 for usage mistakes, which is different from the code 1 this program uses for its own validation errors (a negative factorial, bad JSON).

ModuleNotFoundError: No module named 'recursion' in the import check

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 recursion import factorial; print(factorial(5))"

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

  • What the tool does: reads command-line arguments, computes recursive functions in memory, and prints results. It makes no network connections, needs no privileges, reads no files, and writes no files. There is nothing to clean up.

  • Parse data with json, never eval(). The flatten and treesum subcommands take nested data as JSON on the command line and parse it with json.loads, which turns text into plain lists, dicts, numbers, and strings and can never run code. Do not be tempted to eval() a string to "turn it into a list" — eval executes its argument as Python, so a malicious value could delete files or open a network connection. Parsing with json is the safe pattern, and it is what this tool does.

  • Recursion depth is a resource limit, and that is a safety feature. Python caps the call stack (about 1000 frames by default) so a runaway or maliciously deep input cannot exhaust all memory and take the machine down; instead it raises RecursionError, which this tool catches and turns into a clear message and exit code 1. If you ever raise the limit with sys.setrecursionlimit, do so deliberately and modestly — setting it far too high can crash the interpreter itself, because the real operating-system stack has a hard size.

  • A missing base case is the classic recursion bug. A recursive function with no base case, or one whose calls do not move toward it, recurses forever and overflows the stack. That is a correctness and an availability problem: a service that can be pushed into unbounded recursion by crafted input can be knocked over. Always verify that every recursive call shrinks the problem.

  • Fail loudly, not silently. Every error path prints a message to standard error and returns a non-zero exit code, so a person or a script notices. Silent failure — returning a wrong number while reporting success — is worse than a crash.

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