Programming with PythonControl Flow and Collections › Day 53

Hands-on lab — Day 53: Dictionaries in Depth

Commands

Setup

cd labs/sections/programming-with-python/day-053-dictionaries-in-depth
python3 --version

Run

python3 examples/wordstats.py "the cat sat on the mat the cat"
python3 examples/wordstats.py --records
python3 starter/wordstats.py "one two two three three three"

Test

bash tests/run_tests.sh

File tree

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

Lab README

Day 053 lab — Word Frequency & Records

Lesson

  • Lesson title: Dictionaries in Depth
  • Day number: 53 of 365
  • Lesson article: https://ai-roadmap-365.github.io/day-053-dictionaries-in-depth
  • 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-053-dictionaries-in-depth when the site is running.

Purpose

Day 53's lesson teaches dictionaries in depth. This lab makes the idioms concrete: you build Word Frequency & Records, a small program that (a) counts word frequencies from text using the d.get(key, 0) + 1 idiom and finds the most common word, and (b) stores small records as a list of dictionaries, then groups them with setdefault and filters them with a dict comprehension. You build it from a starter, one exercise at a time, then run an automated test suite that checks real behaviour — including safe access, the insertion-order guarantee, and tie-breaking. The word counter is the exact machinery underneath text search and the term-frequency features of classical language models.

Learning objectives

  • Count word frequencies with the .get()-with-default idiom, never a KeyError.
  • Find the most common item, breaking ties toward the first-seen key.
  • Store structured data as a list of dictionaries and group it with setdefault.
  • Build a filtered view with a dict comprehension.
  • Validate input at the boundary and fail with a clear message and a non-zero exit code.
  • Prove a function is importable and testable — the payoff of the main guard from Day 49.

Prerequisites

  • The Day 53 lesson (read it first — it explains every pattern this lab uses).
  • Days 43-52: a working Python 3 install plus variables, strings, numbers, input/output, reading errors, assembling a small program, and lists.
  • A text editor and a terminal. No programming experience beyond this course is assumed.

Supported operating systems

  • macOS — fully supported (tested on macOS with Apple Silicon, Python 3.14).
  • Linux — fully supported (any distribution with Python 3.7+ and bash).
  • Windows — use WSL and follow the Linux path, or substitute python for python3 if that is how Python is exposed. The program is pure standard-library Python and behaves identically everywhere Python 3.7+ runs.

Hardware requirements

Any computer that runs Python 3. The program builds a few small dictionaries in memory; it needs no special memory, disk, or GPU.

Required software

  • python3 (3.7 or newer; tested on 3.14). Version 3.7+ matters: the lab relies on the dictionary insertion-order guarantee introduced then.
  • bash for the test runner (preinstalled on macOS and Linux).
  • Standard library only — the sys module ships 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.

Installation

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

cd labs/sections/programming-with-python/day-053-dictionaries-in-depth
python3 --version   # confirm Python 3.7+ is available

File structure

day-053-dictionaries-in-depth/
├── README.md                       ← you are here
├── metadata.yml                    ← machine-readable lab metadata
├── starter/
│   └── wordstats.py                ← YOUR working file (4 numbered exercises)
├── examples/
│   └── wordstats.py                ← complete reference implementation
├── tests/
│   └── run_tests.sh                ← automated checks (counting, records, imports)
├── expected-output/
│   ├── sample-run.txt              ← real captured run of the reference
│   ├── test-run.txt                ← real captured run of the test suite
│   └── FIELDS.md                   ← required behaviour on every platform
├── requirements/
│   └── README.md                   ← dependency statement (Python 3.7+ only)
├── troubleshooting.md
└── security.md

How to run

From this directory:

## 1. See the finished program first: counting, then records
python3 examples/wordstats.py "the cat sat on the mat the cat"
python3 examples/wordstats.py --records
python3 examples/wordstats.py                 # no argument: prints an error, exits non-zero

## 2. Your task: complete the four exercises in the starter, then run it
python3 starter/wordstats.py "one two two three three three"

## 3. Prove the module is importable (the payoff of the main guard)
python3 -c "import sys; sys.path.insert(0, 'examples'); from wordstats import count_words; print(count_words('a b a'))"

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

What the commands do

  • python3 examples/wordstats.py "the cat sat on the mat the cat" — runs the complete reference: it counts the words with count_words (the get()-with-default idiom), prints each word with its count in insertion order, and prints the most common word (most_common, ties going to the first seen).
  • python3 examples/wordstats.py --records — runs the records half: it groups the built-in PEOPLE records by role with group_by_role (using setdefault) and builds a filtered, sorted view of names with a dict comprehension (names_up_to_m).
  • python3 examples/wordstats.py with no argument — prints a clear usage error to standard error and exits with code 1.
  • python3 starter/wordstats.py ... — runs your version. The starter ships with four exercises stubbed out (each raising NotImplementedError until you finish it): count with .get(), find the top word, group with setdefault, and build a dict comprehension.
  • python3 -c "...count_words..." — imports one function from the module and calls it, without running the whole program. This works only because the main guard holds main back on import.
  • bash tests/run_tests.sh — runs the reference on counting and records inputs (checking output and exit code), imports two functions to check their return values, and checks your starter (structurally until you finish, strictly afterwards). Exits 0 only if every check passes.

Expected output

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

$ python3 examples/wordstats.py "a a b"
a: 2
b: 1
most common: a (2)

$ python3 examples/wordstats.py --records
engineers: ['Ada', 'Alan']
admirals: ['Grace']
names A-M: ['Ada', 'Alan', 'Grace']

The counts print in insertion order (the order each word first appeared), not alphabetically — the Python 3.7+ guarantee. The program is deterministic, so your output will match exactly. expected-output/FIELDS.md lists the required behaviour for every input on every platform.

Validation steps

  1. Run python3 examples/wordstats.py "a a b" — it must print a: 2, b: 1, and most common: a (2).
  2. Run python3 examples/wordstats.py --records — it must print the two role groups and the names A-M line.
  3. Run python3 examples/wordstats.py; echo $? — it must print a usage error and then 1.
  4. Complete the four exercises in starter/wordstats.py, then run it on the same inputs and confirm it matches the reference.
  5. Run the tests (next section) — every check must pass.

Tests

bash tests/run_tests.sh

Expected final line while the starter is unfinished: 12 checks, 0 failure(s). Once you complete all four starter exercises, six more checks run your version through the same counting and records inputs plus the main-guard check, giving 18 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

Nothing to clean up: the program and tests read only their command-line arguments and write nothing outside their own console output (no files, no network, no settings). To reset your work, restore the starter from git: git checkout -- starter/wordstats.py.

Troubleshooting

See troubleshooting.md for the full list: python vs python3, the deliberate NotImplementedError stubs, KeyError while counting, unhashable type on a bad key, insertion order versus sorting, tie-breaking, and importing vs running.

Security notes

See security.md. Short version: the program makes no network calls, writes no files, and needs no privileges. It validates input at the boundary, never eval()s it, and relies on Python's default hash randomisation (leave it on) to resist hash-flooding attacks.

Extension exercises

  1. Rewrite the counting and grouping with collections.Counter and collections.defaultdict and confirm the output is identical; note in a comment what each helper removed (Counter(...).most_common(1) should replace your top-word logic).
  2. Add a timing experiment that counts a large repeated string with a dictionary versus scanning a list of [word, count] pairs, and record the two timings — you should see the list version grow far slower as the vocabulary grows (the O(1)-versus-O(n) difference made visible).
  3. Build a toy vocabulary: a dict comprehension mapping each distinct word to a unique integer id, then use it to turn a sentence into a list of ids — the token-to-id step that turns text into the numbers a language model consumes.
  • Previous day: Day 52 — Lists in Depth (labs/sections/programming-with-python/day-052-lists-in-depth/).
  • Next day: Day 54 — continues Week 8, Data Structures (labs/sections/programming-with-python/day-054-.../, to be written).
  • Week 8 project: builds on the collection idioms of this week — counting, grouping, and querying structured data with dictionaries.

Expected output

FIELDS.md

# Expected output — Day 053 lab

This directory holds real captured runs from the authoring machine
(macOS, Apple Silicon, Python 3.14, 2026-07-13). Your numbers will match
exactly, because the program is deterministic — the same input always
produces the same output on every platform.

## Files

- `sample-run.txt` — the reference program run on several inputs (counting,
  records, a bad-input error) plus the `python3 -c` import check.
- `test-run.txt` — a full run of `bash tests/run_tests.sh` with the starter
  still unfinished (12 checks, 0 failures).

## Required behaviour on every platform

A correct program must, for these inputs, produce exactly:

| Command | Standard output | Exit code |
| ------- | --------------- | --------- |
| `wordstats.py "a a b"` | `a: 2` / `b: 1` / `most common: a (2)` | 0 |
| `wordstats.py "the cat sat on the mat the cat"` | `the: 3` first (insertion order), then `cat: 2`, `sat: 1`, `on: 1`, `mat: 1`, `most common: the (3)` | 0 |
| `wordstats.py --records` | `engineers: ['Ada', 'Alan']` / `admirals: ['Grace']` / `names A-M: ['Ada', 'Alan', 'Grace']` | 0 |
| `wordstats.py` (no argument) | (stderr) `error: expected text to count, or --records` | 1 |

Two details are load-bearing and identical on every platform:

- **Insertion order.** Counts print in the order each word first appeared,
  not alphabetically — this is the Python 3.7+ guarantee. `the` prints before
  `cat` because it was seen first.
- **Tie-breaking.** `most common` breaks ties in favour of the word seen
  first, because the ranking uses a strictly-greater comparison.

The only platform difference is the shell prompt shown before each command
(`$` here); the program's own output is identical everywhere Python 3.7+ runs.

## Test-suite counts

- With the starter unfinished: `12 checks, 0 failure(s).`
- Once you complete all four starter exercises: `18 checks, 0 failure(s).`
  (the six extra checks run your starter through the same counting and records
  inputs as the reference, plus a check that it has the main guard).

sample-run.txt

$ python3 examples/wordstats.py "the cat sat on the mat the cat"
the: 3
cat: 2
sat: 1
on: 1
mat: 1
most common: the (3)

$ python3 examples/wordstats.py "a a b"
a: 2
b: 1
most common: a (2)

$ python3 examples/wordstats.py --records
engineers: ['Ada', 'Alan']
admirals: ['Grace']
names A-M: ['Ada', 'Alan', 'Grace']

$ python3 examples/wordstats.py   ; echo "exit: $?"
error: expected text to count, or --records
usage: python3 wordstats.py "some words"   |   --records
exit: 1

$ python3 -c "import sys; sys.path.insert(0, 'examples'); from wordstats import count_words; print(count_words('a b a'))"
{'a': 2, 'b': 1}

test-run.txt

Testing <repo>/labs/sections/programming-with-python/day-053-dictionaries-in-depth/examples/wordstats.py ...
  ok: a a b -> a: 2
  ok: a a b -> most common a
  ok: counts in insertion order
  ok: records: engineers group
  ok: records: admirals group
  ok: records: A-M comprehension
  ok: no argument rejected
Testing importability of examples/wordstats.py ...
  ok: import count_words('a b a') == {'a': 2, 'b': 1}
  ok: import most_common ties to first seen
Testing starter/wordstats.py ...
  ok: starter is valid Python
Note: starter/wordstats.py still has unfinished exercises — testing structure only.
  ok: starter defines count_words
  ok: starter defines group_by_role

12 checks, 0 failure(s).

Source files

examples/wordstats.py (4041 bytes)
#!/usr/bin/env python3
"""Word Frequency & Records — dictionaries in depth.

A complete, small program that shows the core dictionary idioms:

  * counting word frequencies with the d.get(key, 0) + 1 pattern
  * finding the most common word (ties broken by first appearance)
  * storing small records as a list of dictionaries
  * grouping records with setdefault
  * building a filtered view with a dict comprehension

It reads input from the command line (never input()), validates it, prints
clear output, and fails gracefully with a non-zero exit code on bad input.

Usage:
    python3 wordstats.py "some words to count"
    python3 wordstats.py --records

Examples:
    python3 wordstats.py "a a b"   ->  a: 2 / b: 1 / most common: a (2)
    python3 wordstats.py --records ->  grouped and filtered records
"""
import sys

# A tiny table of records, modelled as a list of dictionaries. Each record is
# a dict with named fields — exactly the shape of a JSON payload or a config.
PEOPLE = [
    {"name": "Ada", "role": "engineer"},
    {"name": "Grace", "role": "admiral"},
    {"name": "Alan", "role": "engineer"},
]


def count_words(text):
    """Return a dict mapping each word in text to how many times it appears.

    Uses the get()-with-default idiom so a first-seen word starts at 0 and no
    KeyError is ever raised. Insertion order (Python 3.7+) means the returned
    dict lists words in the order they first appeared.
    """
    counts = {}
    for word in text.split():
        counts[word] = counts.get(word, 0) + 1
    return counts


def most_common(counts):
    """Return (word, count) for the highest count; ties go to the first seen.

    Raises ValueError if counts is empty, so callers get a clear message
    rather than a confusing result.
    """
    if not counts:
        raise ValueError("no words to rank")
    best_word = None
    best_count = -1
    for word, n in counts.items():
        if n > best_count:  # strictly greater, so the earliest word wins ties
            best_word, best_count = word, n
    return best_word, best_count


def group_by_role(people):
    """Group record names by their role using setdefault.

    Returns a dict mapping each role to the list of names with that role, with
    roles in the order they were first encountered.
    """
    groups = {}
    for person in people:
        groups.setdefault(person["role"], []).append(person["name"])
    return groups


def names_up_to_m(people):
    """Return the sorted names whose first letter is A-M, via a dict comprehension.

    Builds a {name: role} mapping with a comprehension, then filters and sorts
    the names — showing a dict comprehension feeding a downstream query.
    """
    by_name = {person["name"]: person["role"] for person in people}
    return sorted(name for name in by_name if name[0].upper() <= "M")


def report_counts(text):
    """Print the word counts and the most common word for text."""
    counts = count_words(text)
    if not counts:
        raise ValueError("no words found in the input text")
    for word, n in counts.items():
        print(f"{word}: {n}")
    word, n = most_common(counts)
    print(f"most common: {word} ({n})")


def report_records():
    """Print the grouped and filtered records."""
    groups = group_by_role(PEOPLE)
    for role, names in groups.items():
        print(f"{role}s: {names}")
    print(f"names A-M: {names_up_to_m(PEOPLE)}")


def main(argv):
    """Entry point. Returns an exit code: 0 on success, 1 on bad input."""
    args = argv[1:]
    if not args:
        print("error: expected text to count, or --records", file=sys.stderr)
        print('usage: python3 wordstats.py "some words"   |   --records',
              file=sys.stderr)
        return 1
    try:
        if args[0] == "--records":
            report_records()
        else:
            report_counts(args[0])
    except ValueError as err:
        print(f"error: {err}", file=sys.stderr)
        return 1
    return 0


if __name__ == "__main__":
    sys.exit(main(sys.argv))
metadata.yml (712 bytes)
lesson_id: D053
day: 53
kind: python-program
languages: [python]
setup_commands:
  - cd labs/sections/programming-with-python/day-053-dictionaries-in-depth
  - python3 --version
run_commands:
  - python3 examples/wordstats.py "the cat sat on the mat the cat"
  - python3 examples/wordstats.py --records
  - python3 starter/wordstats.py "one two two three three three"
test_commands:
  - bash tests/run_tests.sh
cleanup_commands:
  - 'git checkout -- starter/wordstats.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 → 12 checks, 0 failure(s), exit 0'
requirements/README.md (947 bytes)
# Dependencies — Day 053 lab

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

- `python3` (3.7 or newer; tested on 3.14). The insertion-order guarantee this
  lab relies on is a language feature since 3.7. 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 the `sys` module,
  which ships with Python. The extension exercises optionally use
  `collections` (also standard library). There is deliberately no
  `requirements.txt`: a dictionary lab should run on a plain Python install
  with nothing to install first.

Check your Python is present and new enough:

```bash
python3 --version
```

If that prints `Python 3.7` 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.
starter/wordstats.py (3727 bytes)
#!/usr/bin/env python3
"""Word Frequency & Records — YOUR working file.

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

When all exercises are done, this file should behave just like the reference:

    python3 starter/wordstats.py "a a b"   ->  a: 2 / b: 1 / most common: a (2)
    python3 starter/wordstats.py --records ->  grouped and filtered records

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

PEOPLE = [
    {"name": "Ada", "role": "engineer"},
    {"name": "Grace", "role": "admiral"},
    {"name": "Alan", "role": "engineer"},
]


def count_words(text):
    """Return a dict mapping each word in text to how many times it appears."""
    # Exercise 1: COUNT WITH .get().
    # Split text into words, then for each word do:
    #     counts[word] = counts.get(word, 0) + 1
    # so a first-seen word starts at 0 (no KeyError). Return the counts dict.
    # Verify by hand: count_words("a b a") must be {"a": 2, "b": 1}.
    raise NotImplementedError("Exercise 1: implement count_words with .get()")


def most_common(counts):
    """Return (word, count) for the highest count; ties go to the first seen."""
    # Exercise 2: FIND THE TOP WORD.
    # If counts is empty, raise ValueError("no words to rank").
    # Otherwise walk counts.items() and keep the word with the largest count.
    # Use a STRICTLY-greater test (n > best_count) so the EARLIEST word wins
    # on ties. Return (best_word, best_count).
    raise NotImplementedError("Exercise 2: implement most_common")


def group_by_role(people):
    """Group record names by their role using setdefault."""
    # Exercise 3: GROUP WITH setdefault.
    # For each person dict, append person["name"] to the list stored under
    # person["role"], creating that list once with:
    #     groups.setdefault(person["role"], []).append(person["name"])
    # Return the groups dict.
    raise NotImplementedError("Exercise 3: implement group_by_role with setdefault")


def names_up_to_m(people):
    """Return the sorted names whose first letter is A-M, via a dict comprehension."""
    # Exercise 4: DICT COMPREHENSION.
    # Build a {name: role} mapping with a dict comprehension over people, then
    # return the sorted names whose first letter (name[0].upper()) is <= "M".
    raise NotImplementedError("Exercise 4: implement names_up_to_m with a dict comprehension")


def report_counts(text):
    """Print the word counts and the most common word for text. (Provided.)"""
    counts = count_words(text)
    if not counts:
        raise ValueError("no words found in the input text")
    for word, n in counts.items():
        print(f"{word}: {n}")
    word, n = most_common(counts)
    print(f"most common: {word} ({n})")


def report_records():
    """Print the grouped and filtered records. (Provided.)"""
    groups = group_by_role(PEOPLE)
    for role, names in groups.items():
        print(f"{role}s: {names}")
    print(f"names A-M: {names_up_to_m(PEOPLE)}")


def main(argv):
    """Entry point. Returns an exit code: 0 on success, 1 on bad input. (Provided.)"""
    args = argv[1:]
    if not args:
        print("error: expected text to count, or --records", file=sys.stderr)
        print('usage: python3 wordstats.py "some words"   |   --records',
              file=sys.stderr)
        return 1
    try:
        if args[0] == "--records":
            report_records()
        else:
            report_counts(args[0])
    except ValueError as err:
        print(f"error: {err}", file=sys.stderr)
        return 1
    return 0


if __name__ == "__main__":
    sys.exit(main(sys.argv))
tests/run_tests.sh (4245 bytes)
#!/usr/bin/env bash
# Tests for the Day 053 lab. Run from the lab directory:
#   bash tests/run_tests.sh
#
# Exercises the complete reference program (examples/wordstats.py) on known
# inputs, checking both the printed output and the process exit code, then
# imports functions from the module and checks their return values. 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/wordstats.py"
starter="${lab_dir}/starter/wordstats.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 program, checks its exit code equals expect_exit and that its
# combined output contains 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_program_checks() {
  local script="$1"
  echo "Testing ${script} ..."
  # Counting: correct counts, insertion order, most-common line, exit 0.
  check_run "a a b -> a: 2"          "${script}" 0 "a: 2"                "a a b"
  check_run "a a b -> most common a" "${script}" 0 "most common: a (2)"  "a a b"
  check_run "counts in insertion order" "${script}" 0 "the: 3"           "the cat sat on the mat the cat"
  # Records: grouping with setdefault + dict comprehension, exit 0.
  check_run "records: engineers group" "${script}" 0 "engineers: ['Ada', 'Alan']" --records
  check_run "records: admirals group"  "${script}" 0 "admirals: ['Grace']"        --records
  check_run "records: A-M comprehension" "${script}" 0 "names A-M: ['Ada', 'Alan', 'Grace']" --records
  # Bad input: no argument -> clear error, non-zero exit.
  check_run "no argument rejected"   "${script}" 1 "expected text to count"
}

# --- Reference program: always tested strictly ---
run_program_checks "${ref}"

# --- Import functions and check return values (main-guard payoff) ---
echo "Testing importability of examples/wordstats.py ..."
if python3 -c "import sys; sys.path.insert(0, '${lab_dir}/examples'); \
from wordstats import count_words; \
assert count_words('a b a') == {'a': 2, 'b': 1}"; then
  check "import count_words('a b a') == {'a': 2, 'b': 1}" "yes"
else
  check "import count_words('a b a') == {'a': 2, 'b': 1}" "no"
fi
if python3 -c "import sys; sys.path.insert(0, '${lab_dir}/examples'); \
from wordstats import most_common; \
assert most_common({'x': 2, 'y': 2}) == ('x', 2)"; then
  check "import most_common ties to first seen" "yes"
else
  check "import most_common ties to first seen" "no"
fi

# --- Learner starter ---
echo "Testing starter/wordstats.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/wordstats.py still has unfinished exercises — testing structure only."
  grep -q 'def count_words' "${starter}" && check "starter defines count_words" "yes" || check "starter defines count_words" "no"
  grep -q 'def group_by_role' "${starter}" && check "starter defines group_by_role" "yes" || check "starter defines group_by_role" "no"
else
  # Learner finished: hold the starter to the same strict standard.
  run_program_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 053 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 — you need 3.7 or newer for the insertion-order behaviour this lab relies on.

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 accidentally think an empty function "works." Replace each raise NotImplementedError(...) line with the real body described in the comment above it. Once all four exercises are done, the file runs like the reference.

KeyError while counting

You read counts[word] before the key exists. The very first time a word is seen, its key is not in the dictionary yet, so the square-bracket read raises KeyError. Use the idiom instead:

counts[word] = counts.get(word, 0) + 1

counts.get(word, 0) returns the running count, or 0 for a first-seen word, so no key ever has to be created by hand and no KeyError is raised.

TypeError: unhashable type: 'list'

You tried to use a list (or a set, or another dict) as a dictionary key. Keys must be hashable, meaning immutable — a string, a number, or a tuple of those. Use one of those as the key. In this lab the keys are words and roles (strings), so this error usually means a variable holds the wrong thing.

Counts come out in the wrong order

A plain dictionary preserves insertion order (the order words first appeared), not alphabetical order. In this lab that is exactly what you want: the prints before cat because it was seen first. If you sorted the output, remove the sort; if you want alphabetical order for some other purpose, add sorted(...) explicitly — the two are different on purpose.

most common is wrong when two words tie

The rule is that the word seen first wins a tie. That falls out of using a strictly-greater test (n > best_count) as you scan: an equal count never displaces the earlier word. If you used >=, a later word would wrongly win.

ModuleNotFoundError: No module named 'wordstats'

Python imports a module by looking on its search path (sys.path), which does not include the examples/ subfolder by default. The import one-liner in this lab adds it first:

python3 -c "import sys; sys.path.insert(0, 'examples'); from wordstats import count_words; print(count_words('a b a'))"

Run this from the lab directory (the folder that contains examples/), not from inside examples/ itself.

Importing the file runs the whole program

This is what the main guard prevents. If importing your module prints output or exits, you either removed if __name__ == "__main__": or wrote program-running code at the top level instead of inside main. Only the guarded sys.exit(main(sys.argv)) should trigger execution — that is what lets a test import count_words without launching the program.

bash: tests/run_tests.sh: Permission denied

Run it through bash explicitly (as the README shows) rather than executing it directly: bash tests/run_tests.sh. You do not need to chmod +x anything.

Security notes

Security notes — Day 053 lab

  • What the program does: reads one command-line argument (text to count, or the --records flag), builds dictionaries in memory, and prints the result. It makes no network connections, reads and writes no files, and changes no settings. The test runner is equally self-contained and non-interactive.

  • Validate input at the boundary. The program checks that an argument was supplied and that the text contains at least one word, printing a clear error to standard error and exiting non-zero otherwise — never a raw KeyError or traceback. When you build dictionaries from untrusted input (a parsed JSON request, say), do the same: never assume an incoming payload has the keys you expect. Use d.get(key, default) or check key in d first, exactly as this lab teaches.

  • Never eval() input to "parse" it. As on Day 49, the rule is absolute: turn text into data with safe operations (str.split(), int(), float()), never by executing it. eval() and exec() run their argument as Python code and must never touch input.

  • Hash randomisation is a feature — leave it on. Python randomises string hashing on each run by default to resist "hash-flooding" attacks, where an attacker sends keys engineered to collide into one bucket and slow your dictionary from O(1) toward O(n). You get this protection for free; do not disable it (PYTHONHASHSEED) and do not rely on the exact numeric value of hash() staying the same across runs.

  • Fail loudly, not silently. On bad input the program prints a clear message to standard error and exits with a non-zero code. Silently computing a wrong answer from bad input is worse than a crash, because no one notices.

  • Privileges: everything runs as your normal user. Nothing here needs sudo. Read examples/wordstats.py and tests/run_tests.sh before running them — every file in this lab is short enough to read first.