Programming with PythonFunctions and Program Design › Day 59

Hands-on lab — Day 59: Modules, Imports, and Project Layout

Commands

Setup

cd labs/sections/programming-with-python/day-059-modules-imports-and-project-layout
python3 --version

Run

cd examples && python3 -m wordstats sample.txt
printf 'red red blue green red blue\n' | (cd examples && python3 -m wordstats)
python3 -c "import sys; sys.path.insert(0, 'examples'); from wordstats import tokenize, top_n; print(top_n(tokenize('a a b'), 2))"

Test

bash tests/run_tests.sh

File tree

examples/sample.txt
examples/wordstats/__init__.py
examples/wordstats/__main__.py
examples/wordstats/stats.py
examples/wordstats/tokens.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/module-layout-worksheet.md
starter/sample.txt
starter/single_file.py
starter/wordstats/__init__.py
starter/wordstats/__main__.py
starter/wordstats/stats.py
starter/wordstats/tokens.py
tests/run_tests.sh
troubleshooting.md

Lab README

Day 059 lab — Split into Modules

Lesson

Purpose

Day 59's lesson teaches how to grow a program beyond one file: modules, imports, packages and __init__.py, the if __name__ == "__main__": guard, how Python finds modules, and a sane small-project layout. This lab makes that concrete. You start from a single-file word-frequency tool (starter/single_file.py) where tokenizing, counting, reporting, and command-line handling are all tangled together — a working script that nothing can import or test. You refactor it into a package, wordstats/, with one responsibility per module: tokens.py (text to a list of words), stats.py (words to frequency numbers), an __init__.py that defines the public API, and a __main__.py entry point with the main guard so the package runs as python3 -m wordstats. Then you run a test suite that imports your modules and asserts their behaviour — the payoff of the split. This is the same layout, one size down, that AI projects use to separate data, model, training, and serving code so it can be reused, tested, and shipped.

Learning objectives

  • Turn a single-file script into a package: modules organized by responsibility, an __init__.py, and a __main__.py entry point.
  • Use import, from ... import, and import ... as, and choose between absolute imports (from tests) and relative imports (inside the package).
  • Add the if __name__ == "__main__": guard so a file can be both run and imported, and see why that is what makes the modules testable.
  • Explain how Python finds a module (sys.path) and that it imports each module only once (the sys.modules cache).
  • Keep module dependencies pointing one way to avoid circular imports.

Prerequisites

  • The Day 59 lesson (read it first — it explains every part this lab builds).
  • Days 57–58: functions with arguments and return values, and scope.
  • Day 56: a data-driven program with a main() and the main-guard idiom.
  • 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 package is pure standard-library Python and behaves identically everywhere.

Hardware requirements

Any computer that runs Python 3. The tool reads a small text file and does no heavy computation; it needs no special memory, disk, or GPU.

Required software

  • python3 (3.8 or newer; tested on 3.14.0).
  • bash for the test runner (preinstalled on macOS and Linux).
  • Standard library only — re, collections, 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 re and collections modules 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-059-modules-imports-and-project-layout
python3 --version   # confirm Python 3.8+ is available

File structure

day-059-modules-imports-and-project-layout/
├── README.md                       ← you are here
├── metadata.yml                    ← machine-readable lab metadata
├── starter/
│   ├── single_file.py              ← the "before": one tangled script (provided)
│   ├── sample.txt                  ← input text used by the tool
│   ├── module-layout-worksheet.md  ← plan the split before you code it
│   └── wordstats/                  ← YOUR package to complete (6 exercises)
│       ├── __init__.py             ← Exercise 5: the public API
│       ├── tokens.py               ← Exercise 1: tokenize
│       ├── stats.py                ← Exercises 2-3: count_words, top_n
│       └── __main__.py             ← Exercises 4, 6: report + the main guard
├── examples/
│   ├── sample.txt
│   └── wordstats/                  ← complete reference package
│       ├── __init__.py
│       ├── tokens.py
│       ├── stats.py
│       └── __main__.py
├── tests/
│   └── run_tests.sh                ← imports the modules and asserts behaviour
├── 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 on every platform
├── requirements/
│   └── README.md                   ← dependency statement (Python 3 only)
├── troubleshooting.md
└── security.md

How to run

The -m form must be run from the directory that contains the wordstats/ package, so cd into examples/ (or starter/) first.

## 1. See the single-file "before" — a working but untestable script.
cd starter
python3 single_file.py sample.txt
cd ..

## 2. Run the finished reference package as a program (file argument).
cd examples
python3 -m wordstats sample.txt

## 3. Feed it standard input instead of a file.
printf 'red red blue green red blue\n' | python3 -m wordstats
cd ..

## 4. Import the package as a library — the payoff of the split.
python3 -c "import sys; sys.path.insert(0, 'examples'); from wordstats import tokenize, top_n; print(top_n(tokenize('a a b'), 2))"

## 5. Your task: complete the six exercises in starter/wordstats, then run it.
cd starter
python3 -m wordstats sample.txt
cd ..

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

What the commands do

  • python3 single_file.py sample.txt — runs the monolith. It prints the same report as the package, but every line of real work is trapped inside its if __name__ == "__main__": block, so nothing in it can be imported or tested. This is the "before".
  • python3 -m wordstats sample.txt — runs the package. Python finds wordstats/ on the path (the current directory is first for -m), executes wordstats/__main__.py, which imports tokens and stats with relative imports, reads the file, and prints the report.
  • printf ... | python3 -m wordstats — the same, reading standard input because no file argument is given.
  • python3 -c "... from wordstats import tokenize, top_n ..." — imports the package's public API (defined by __init__.py) and calls the functions directly, with no file and no terminal. Only a package split into importable modules can be used this way.
  • bash tests/run_tests.sh — imports the reference modules and asserts what each function returns, runs the package as python3 -m wordstats, proves each responsibility module loads on its own (no circular imports), checks that a module is imported only once (the sys.modules cache), and then checks your starter. Exits 0 only if every check passes.

Expected output

See expected-output/sample-run.txt — a real captured session. Running the reference from examples/:

$ python3 -m wordstats sample.txt
19 words, 10 unique
1. the: 6
2. cat: 2
3. dog: 2
4. on: 2
5. sat: 2

top_n breaks ties alphabetically, so the tool is deterministic and your output will match. expected-output/FIELDS.md lists the required behaviour for the program and for the imported API on every platform.

Validation steps

  1. cd examples && python3 -m wordstats sample.txt prints 19 words, 10 unique followed by the five ranked lines above.
  2. python3 -c "import sys; sys.path.insert(0, 'examples'); from wordstats import tokenize; print(tokenize('Hello, HELLO world!'))" prints ['hello', 'hello', 'world'].
  3. Complete the six exercises in starter/wordstats, then run cd starter && python3 -m wordstats sample.txt and confirm it matches the reference.
  4. From starter, python3 -c "from wordstats import top_n" succeeds (proves Exercise 5, the public API, is done).
  5. 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 six starter exercises, the suite runs your package through the same strict import-and-run checks, giving 26 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 data files. Importing modules can create __pycache__ folders; remove them if you like:

find . -name __pycache__ -exec rm -rf {} +

To reset your work, restore the starter from git: git checkout -- starter/. The test runner sets PYTHONDONTWRITEBYTECODE=1 so it leaves nothing behind.

Troubleshooting

See troubleshooting.md for the full list: python vs python3, the No module named wordstats path error, why running a file inside the package directly breaks relative imports, why from wordstats import ... needs Exercise 5, circular imports, and __pycache__ folders.

Security notes

See security.md. Short version: the package makes no network calls and needs no privileges; it only reads the input you name. Its central habits are that importing a module runs its top-level code (so read before you import), that sys.path order is a trust decision (do not let a local file shadow a standard-library module — this is why the module is tokens.py, not tokenize.py), and that text is parsed as data with re, never executed.

Extension exercises

  1. Add a third responsibility module, wordstats/report.py, and move report into it; import it from __main__.py and re-export it from __init__.py. Notice how a new responsibility becomes one new file plus two import lines.
  2. Add a --top N option (using argparse, from Day 56) so the number of ranked words is configurable, defaulting to 5.
  3. Add a tests/test_wordstats.py that imports tokenize, count_words, and top_n and asserts their behaviour, printing all tests passed only if every assertion holds — a pure-Python test with no bash.
  4. Deliberately create a circular import (make tokens.py do from .stats import count_words and stats.py do from .tokens import tokenize), run python3 -m wordstats, read the error, then fix it by removing one of the two imports. Write two sentences on what you saw and why.
  • Previous day: Day 58 — Scope, Closures, and *args/**kwargs (labs/sections/programming-with-python/day-058-scope-closures-and-args-kwargs/).
  • Next day: Day 60 — A Tour of the Standard Library (labs/sections/programming-with-python/day-060-a-tour-of-the-standard-library/).
  • Week 9 project: the Flashcard Study App — a spaced-repetition flashcard tool organized into clean modules, the same package layout you build here scaled up to data, scheduling, and a command-line front end.

Expected output

FIELDS.md

# Expected output — Day 059 lab

These are real captured runs from the authoring machine (macOS, Apple
Silicon, Python 3.14.0, bash 3.2, 2026-07-13). The package is deterministic:
`top_n` breaks ties alphabetically, so given the same input it prints the same
report and the same numbers on every platform Python 3 runs on.

## Files

- `sample-run.txt` — the reference package driven end to end: run as a program
  with a file argument (`python3 -m wordstats sample.txt`) and with standard
  input, then imported as a library (`from wordstats import tokenize, top_n`)
  and a demonstration that importing the same module twice returns the same
  object (`a is b` is `True`).
- `test-run.txt` — a full run of `bash tests/run_tests.sh` with the starter
  still unfinished (18 checks, 0 failures). Any absolute path is shown as
  `<repo>`; on your machine it is your real repository path.

## Required behaviour on every platform

Running `python3 -m wordstats sample.txt` from the `examples/` directory (the
bundled `sample.txt` holds "The cat sat on the mat." and two more lines) must
print exactly:

```text
19 words, 10 unique
1. the: 6
2. cat: 2
3. dog: 2
4. on: 2
5. sat: 2
```

Imported as a library, the public API must behave as follows:

| Call | Result |
| --- | --- |
| `wordstats.__version__` | `'1.0.0'` |
| `tokenize('Hello, HELLO world!')` | `['hello', 'hello', 'world']` |
| `count_words(['x', 'y', 'x'])` | `{'x': 2, 'y': 1}` |
| `top_n(['b', 'a', 'b', 'a', 'c'], 2)` | `[('a', 2), ('b', 2)]` (ties alphabetical) |
| `import wordstats.tokens as a; import wordstats.tokens as b; a is b` | `True` (imported once) |
| `from wordstats.__main__ import report` | succeeds without running `main` (the guard) |

## Platform notes

- The only visible difference between platforms is the shell prompt (`$`)
  shown before each command; the program's own output is identical.
- `python3 -m wordstats` must be run from the directory that *contains* the
  `wordstats/` package (so the package is found on `sys.path`, whose first
  entry for `-m` is the current directory). The `examples/` and `starter/`
  directories each carry their own `sample.txt` for this reason.
- No `__pycache__` directories are left behind: the test runner sets
  `PYTHONDONTWRITEBYTECODE=1` before importing anything.
- Windows: run inside WSL, or substitute `python` for `python3` if that is how
  Python is exposed. The package is pure standard-library Python and behaves
  identically everywhere.

sample-run.txt

$ cd examples
$ python3 -m wordstats sample.txt
19 words, 10 unique
1. the: 6
2. cat: 2
3. dog: 2
4. on: 2
5. sat: 2

$ printf 'red red blue green red blue\n' | python3 -m wordstats
6 words, 3 unique
1. red: 3
2. blue: 2
3. green: 1

$ cd ..
$ python3 -c "import sys; sys.path.insert(0, 'examples'); from wordstats import tokenize, top_n, __version__; print(__version__); print(tokenize('Modules make code reusable. Reusable modules!')); print(top_n(tokenize('a a b b b c'), 2))"
1.0.0
['modules', 'make', 'code', 'reusable', 'reusable', 'modules']
[('b', 3), ('a', 2)]

$ python3 -c "import sys; sys.path.insert(0, 'examples'); import wordstats.tokens as a, wordstats.tokens as b; print(a is b)"
True

test-run.txt

Testing package in examples/ ...
  ok: import wordstats, __version__ == 1.0.0
  ok: from wordstats import tokenize -> ['hello','hello','world']
  ok: wordstats.stats.count_words counts correctly
  ok: top_n orders by count then alphabetically
  ok: import wordstats.stats as st (import-as)
  ok: module imported once (sys.modules cache)
  ok: tokens.py is self-contained (no sibling import)
  ok: stats.py is self-contained (no sibling import)
  ok: report() importable and pure (guard holds main back)
  ok: python3 -m wordstats sample.txt -> counts
  ok: python3 -m wordstats sample.txt -> top word
  ok: python3 -m wordstats (stdin)
Testing starter/ ...
  ok: starter files are valid Python
Note: starter/wordstats still has unfinished exercises — testing structure only.
  ok: starter defines tokenize
  ok: starter defines count_words
  ok: starter defines top_n
  ok: starter defines report
  ok: starter has wordstats/__init__.py

18 checks, 0 failure(s).

Source files

examples/sample.txt (81 bytes)
The cat sat on the mat.
The dog sat on the log.
The cat and the dog are friends.
examples/wordstats/__init__.py (795 bytes)
"""wordstats — a tiny, multi-module package that counts word frequencies.

This file, ``__init__.py``, is what turns the ``wordstats/`` directory into a
*package*: an importable name that groups several modules. It also defines the
package's public API by re-exporting the useful names from the submodules, so
a caller can write::

    from wordstats import tokenize, top_n

without needing to know that ``tokenize`` lives in ``tokens.py`` and ``top_n``
lives in ``stats.py``. The two imports below are *relative* imports (the
leading dot means "from this same package"), which is the normal way one part
of a package refers to another.
"""
from .tokens import tokenize
from .stats import count_words, top_n

__version__ = "1.0.0"
__all__ = ["tokenize", "count_words", "top_n", "__version__"]
examples/wordstats/__main__.py (1493 bytes)
"""__main__.py — the package's entry point: ``python3 -m wordstats FILE``.

Because this file is named ``__main__.py``, running ``python3 -m wordstats``
executes it. It reads a text file (or standard input when no file is given),
asks ``tokens`` and ``stats`` to do their jobs, and prints a small report.

The ``if __name__ == "__main__":`` guard at the very bottom is what lets this
same file be *imported* (to reuse ``report`` in a test) as well as *run* (as a
program): the guarded code runs only when the file is executed directly, not
when another module imports it.
"""
import sys

from .tokens import tokenize
from .stats import top_n


def report(text, n=5):
    """Return the printable top-n report for a block of text (no input/output).

    Keeping this pure — text in, string out, nothing read or printed — is what
    makes it importable and testable without a file or a terminal.
    """
    words = tokenize(text)
    lines = [f"{len(words)} words, {len(set(words))} unique"]
    for rank, (word, count) in enumerate(top_n(words, n), start=1):
        lines.append(f"{rank}. {word}: {count}")
    return "\n".join(lines)


def main(argv):
    """Read the file named on the command line (or stdin), print the report."""
    if len(argv) > 1:
        with open(argv[1], "r", encoding="utf-8") as handle:
            text = handle.read()
    else:
        text = sys.stdin.read()
    print(report(text))
    return 0


if __name__ == "__main__":
    sys.exit(main(sys.argv))
examples/wordstats/stats.py (847 bytes)
"""stats.py — one responsibility: turn a list of words into frequency numbers.

Like ``tokens``, this module depends only on the standard library and does not
import its siblings. It takes plain Python lists in and returns plain Python
values out, so it can be imported and tested on its own.
"""
from collections import Counter


def count_words(words):
    """Return a plain dict mapping each word to how many times it appears."""
    return dict(Counter(words))


def top_n(words, n=5):
    """Return the n most common (word, count) pairs, most frequent first.

    Ties are broken alphabetically so the result is deterministic on every
    machine. ``top_n(["a", "b", "a"], 1)`` returns ``[('a', 2)]``.
    """
    counts = count_words(words)
    ordered = sorted(counts.items(), key=lambda pair: (-pair[1], pair[0]))
    return ordered[:n]
examples/wordstats/tokens.py (928 bytes)
"""tokens.py — one responsibility: turn raw text into a clean list of words.

This module knows nothing about counting, reporting, or the command line. It
imports only the standard library and never imports its sibling ``stats`` — a
module that does one job and does not depend on the rest of the package is the
easiest to test and the least likely to cause a circular import.
"""
import re

# Matches runs of letters, digits, and apostrophes, so "don't" stays one word.
_WORD_RE = re.compile(r"[a-z0-9']+")


def normalize(text):
    """Return text lowercased — a tiny, separately testable step."""
    return text.lower()


def tokenize(text):
    """Split text into a list of lowercase word tokens.

    Punctuation and whitespace act as separators; internal apostrophes are
    kept. ``tokenize("Hello, HELLO world!")`` returns
    ``['hello', 'hello', 'world']``.
    """
    return _WORD_RE.findall(normalize(text))
metadata.yml (852 bytes)
lesson_id: D059
day: 59
kind: python-program
languages: [python]
setup_commands:
  - cd labs/sections/programming-with-python/day-059-modules-imports-and-project-layout
  - python3 --version
run_commands:
  - cd examples && python3 -m wordstats sample.txt
  - printf 'red red blue green red blue\n' | (cd examples && python3 -m wordstats)
  - python3 -c "import sys; sys.path.insert(0, 'examples'); from wordstats import tokenize, top_n; print(top_n(tokenize('a a b'), 2))"
test_commands:
  - bash tests/run_tests.sh
cleanup_commands:
  - find . -name __pycache__ -exec rm -rf {} +
  - 'git checkout -- starter/  # 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 (956 bytes)
# Dependencies — Day 059 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 `re`, `collections`
  (for `Counter`), `sys`, and `importlib` (used only inside the tests). All of
  these ship with Python. There is deliberately no `requirements.txt`: a
  package 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 package itself is pure standard-library
Python and behaves identically everywhere.
starter/module-layout-worksheet.md (2361 bytes)
# Module-layout worksheet

Fill this in *before* you refactor `single_file.py`. Deciding the split on
paper first — which responsibility goes in which module, and which module
imports which — is exactly the discipline the lesson teaches. A layout you can
draw is a layout you can test.

## The program

- **What the whole program does (one sentence):**
- **Where the input comes from (file argument / standard input):**
- **What it prints:**

## Responsibilities → modules

List each distinct responsibility in the single-file program and the module it
will live in. One responsibility per module; a module should do one job.

| Responsibility (what it does) | Module (`.py` file) | Public functions |
| ----------------------------- | ------------------- | ---------------- |
| Text → list of words          | `tokens.py`         | `tokenize`       |
| Words → frequency numbers      | `stats.py`          | `count_words`, `top_n` |
| Read input, print the report   | `__main__.py`       | `report`, `main` |
| Define the package's public API | `__init__.py`      | (re-exports)     |

## Import direction (avoid circular imports)

Draw the arrows: which module imports which? They must never form a loop.

- `__main__.py` imports from: __________ and __________
- `__init__.py` imports from: __________ and __________
- `tokens.py` imports from: __________ (should be standard library only)
- `stats.py` imports from: __________ (should be standard library only)
- Is there any cycle (A imports B and B imports A)?  yes / no  → if yes, fix it.

## Import styles used

Name where in the package each style appears:

- `import name` (plain): __________
- `from module import name` (from-import): __________
- `import module as alias` (import-as): __________
- Relative import (leading dot, e.g. `from .tokens import tokenize`): __________
- Absolute import (full path, e.g. `from wordstats.tokens import tokenize`): __________

## The entry point

- Which file is run by `python3 -m wordstats`?  __________
- What line makes it run only when executed, not when imported?  __________
- Which function stays pure (no printing, no file reading) so it can be tested?
  __________

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

- One good run (command and its output):
- One import (a `python3 -c "from wordstats import ..."` line and its output):
starter/sample.txt (81 bytes)
The cat sat on the mat.
The dog sat on the log.
The cat and the dog are friends.
starter/single_file.py (1138 bytes)
#!/usr/bin/env python3
"""single_file.py — the "before": one script that does everything at once.

This is a working word-frequency tool with tokenizing, counting, the report,
and the command-line handling all tangled together in one block. It runs, but
nothing in it can be imported or tested on its own, because the real work only
happens inside the ``if __name__ == "__main__":`` block. Your task in the lab
is to split this into the ``wordstats/`` package, one responsibility per
module. Run it first to see what it does:

    python3 single_file.py sample.txt
"""
import re
import sys
from collections import Counter

if __name__ == "__main__":
    if len(sys.argv) > 1:
        with open(sys.argv[1], "r", encoding="utf-8") as handle:
            text = handle.read()
    else:
        text = sys.stdin.read()
    words = re.findall(r"[a-z0-9']+", text.lower())
    counts = Counter(words)
    ordered = sorted(counts.items(), key=lambda pair: (-pair[1], pair[0]))
    print(f"{len(words)} words, {len(set(words))} unique")
    for rank, (word, count) in enumerate(ordered[:5], start=1):
        print(f"{rank}. {word}: {count}")
starter/wordstats/__init__.py (760 bytes)
"""wordstats — the package you are building.

The mere presence of this ``__init__.py`` is what makes ``wordstats/`` an
importable package. It should also define the package's public API by
re-exporting the useful names from the submodules, so callers can write
``from wordstats import tokenize, top_n`` without knowing which file each lives
in. The reference is in ``examples/wordstats/__init__.py``.
"""
# Exercise 5: DEFINE THE PUBLIC API.
# Uncomment the two RELATIVE imports below (the leading dot means "this same
# package") so that `from wordstats import tokenize, count_words, top_n` works:
#
# from .tokens import tokenize
# from .stats import count_words, top_n

__version__ = "1.0.0"
__all__ = ["tokenize", "count_words", "top_n", "__version__"]
starter/wordstats/__main__.py (1527 bytes)
"""__main__.py — the package's entry point: ``python3 -m wordstats FILE``.

Runs when you execute ``python3 -m wordstats``. It reads a file (or standard
input), asks the sibling modules to do their jobs, and prints a report. The
imports below are RELATIVE (the leading dot means "this same package").
"""
import sys

from .tokens import tokenize
from .stats import top_n


def report(text, n=5):
    """Return the printable top-n report for a block of text (no input/output)."""
    # Exercise 4: THE REPORT (pure: text in, string out — no printing here).
    # 1. words = tokenize(text)
    # 2. Start a list of lines with:
    #        f"{len(words)} words, {len(set(words))} unique"
    # 3. For each (word, count) in top_n(words, n), numbered from 1, append:
    #        f"{rank}. {word}: {count}"
    #    Hint: enumerate(top_n(words, n), start=1) gives (rank, (word, count)).
    # 4. Return "\n".join(lines).
    raise NotImplementedError("Exercise 4: implement report")


def main(argv):
    """Read the file named on the command line (or stdin), print the report."""
    if len(argv) > 1:
        with open(argv[1], "r", encoding="utf-8") as handle:
            text = handle.read()
    else:
        text = sys.stdin.read()
    print(report(text))
    return 0


# Exercise 6: ADD THE MAIN GUARD.
# Below this comment, add the guard so that running the package executes main
# but importing this module (for example to test report) does not:
#
#     if __name__ == "__main__":
#         sys.exit(main(sys.argv))
starter/wordstats/stats.py (1035 bytes)
"""stats.py — one responsibility: turn a list of words into frequency numbers.

Depends only on the standard library; does not import its siblings. Plain
lists in, plain values out. The reference is in ``examples/wordstats/stats.py``.
"""
from collections import Counter


def count_words(words):
    """Return a plain dict mapping each word to how many times it appears."""
    # Exercise 2: COUNT WORDS.
    # Return dict(Counter(words)) — a normal dict of word -> count.
    raise NotImplementedError("Exercise 2: implement count_words")


def top_n(words, n=5):
    """Return the n most common (word, count) pairs, most frequent first."""
    # Exercise 3: TOP N.
    # 1. counts = count_words(words)
    # 2. Sort counts.items() by count DESCENDING, then word ASCENDING, so ties
    #    break alphabetically and the result is deterministic:
    #        sorted(counts.items(), key=lambda pair: (-pair[1], pair[0]))
    # 3. Return the first n of that sorted list.
    raise NotImplementedError("Exercise 3: implement top_n")
starter/wordstats/tokens.py (1015 bytes)
"""tokens.py — one responsibility: turn raw text into a clean list of words.

This module should import only the standard library and must NOT import its
sibling ``stats`` — keeping the dependency one-directional is how you avoid
circular imports. The finished reference is in
``examples/wordstats/tokens.py`` — try each exercise before peeking.
"""
import re

# Matches runs of letters, digits, and apostrophes, so "don't" stays one word.
_WORD_RE = re.compile(r"[a-z0-9']+")


def normalize(text):
    """Return text lowercased — a tiny, separately testable step. (Provided.)"""
    return text.lower()


def tokenize(text):
    """Split text into a list of lowercase word tokens."""
    # Exercise 1: TOKENIZE.
    # Return a list of lowercase word tokens found in text. Lowercase first
    # with normalize(text), then use _WORD_RE.findall(...) on the result.
    # tokenize("Hello, HELLO world!") must return ['hello', 'hello', 'world'].
    raise NotImplementedError("Exercise 1: implement tokenize")
tests/run_tests.sh (6764 bytes)
#!/usr/bin/env bash
# Tests for the Day 059 lab. Run from the lab directory:
#   bash tests/run_tests.sh
#
# Verifies the reference package (examples/wordstats) by IMPORTING its modules
# and asserting behaviour, by running it as `python3 -m wordstats`, by proving
# each responsibility module loads on its own (no circular imports), and by
# checking the module cache imports a module only once. It then checks the
# learner's starter package — structurally while the exercises are unfinished,
# and to the same strict standard once they are complete. No network, no
# prompts. Exits 0 only if every check passes.
set -u

# Keep the working tree clean: no __pycache__ directories from importing.
export PYTHONDONTWRITEBYTECODE=1

lab_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
examples="${lab_dir}/examples"
starter="${lab_dir}/starter"
failures=0
checks=0

check() {
  local label="$1" ok="$2"
  checks=$((checks + 1))
  if [ "${ok}" = "yes" ]; then
    echo "  ok: ${label}"
  else
    echo "  FAIL: ${label}"
    failures=$((failures + 1))
  fi
}

# assert_py <label> <dir-with-package> <python code>
# Runs the code with <dir> prepended to sys.path; passes when it exits 0.
assert_py() {
  local label="$1" dir="$2" code="$3"
  if python3 -c "import sys; sys.path.insert(0, '${dir}')
${code}" >/dev/null 2>&1; then
    check "${label}" "yes"
  else
    check "${label}" "no"
  fi
}

# assert_run <label> <dir-with-package> <expect_exit> <needle> [stdin]
# Runs `python3 -m wordstats` from <dir> (so the package is found on the path);
# if a 5th argument is given it is piped in as stdin, otherwise sample.txt is
# passed as a file argument. Checks the exit code and that output contains the
# needle.
assert_run() {
  local label="$1" dir="$2" expect_exit="$3" needle="$4" stdin="${5:-}"
  local out code
  if [ -n "${stdin}" ]; then
    out="$(printf '%s\n' "${stdin}" | (cd "${dir}" && python3 -m wordstats) 2>&1)"
  else
    out="$( (cd "${dir}" && python3 -m wordstats sample.txt) 2>&1)"
  fi
  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
}

# All the behaviour a finished package must have, run against a package dir.
run_pkg_checks() {
  local dir="$1" label="$2"
  echo "Testing package in ${label} ..."
  # The public API is importable from the package root (via __init__.py).
  assert_py "import wordstats, __version__ == 1.0.0" "${dir}" \
    "import wordstats; assert wordstats.__version__ == '1.0.0'"
  # from-import of a name re-exported by __init__.py.
  assert_py "from wordstats import tokenize -> ['hello','hello','world']" "${dir}" \
    "from wordstats import tokenize; assert tokenize('Hello, HELLO world!') == ['hello', 'hello', 'world'], tokenize('Hello, HELLO world!')"
  # Submodule function, imported directly.
  assert_py "wordstats.stats.count_words counts correctly" "${dir}" \
    "from wordstats.stats import count_words; assert count_words(['x','y','x']) == {'x': 2, 'y': 1}"
  # Ties break alphabetically -> deterministic.
  assert_py "top_n orders by count then alphabetically" "${dir}" \
    "from wordstats.stats import top_n; assert top_n(['b','a','b','a','c'], 2) == [('a', 2), ('b', 2)], top_n(['b','a','b','a','c'], 2)"
  # import ... as ... binds the module under a new name.
  assert_py "import wordstats.stats as st (import-as)" "${dir}" \
    "import wordstats.stats as st; assert st.count_words(['q']) == {'q': 1}"
  # The module cache: importing twice yields the very same module object.
  assert_py "module imported once (sys.modules cache)" "${dir}" \
    "import wordstats.tokens as a; import wordstats.tokens as b; import sys; assert a is b and 'wordstats.tokens' in sys.modules"
  # tokens.py stands alone: loaded outside the package it still works, proving
  # it does not import its siblings (no circular import).
  assert_py "tokens.py is self-contained (no sibling import)" "${dir}" \
    "import importlib.util as u; s=u.spec_from_file_location('t', '${dir}/wordstats/tokens.py'); m=u.module_from_spec(s); s.loader.exec_module(m); assert m.tokenize('A a') == ['a', 'a']"
  assert_py "stats.py is self-contained (no sibling import)" "${dir}" \
    "import importlib.util as u; s=u.spec_from_file_location('s', '${dir}/wordstats/stats.py'); m=u.module_from_spec(s); s.loader.exec_module(m); assert m.count_words(['a','a']) == {'a': 2}"
  # report() is importable and pure — the payoff of the __main__ guard.
  assert_py "report() importable and pure (guard holds main back)" "${dir}" \
    "from wordstats.__main__ import report; r = report('a a b'); assert r.startswith('3 words, 2 unique'), r"
  # Runnable as a program: file argument and standard input.
  assert_run "python3 -m wordstats sample.txt -> counts" "${dir}" 0 "19 words, 10 unique"
  assert_run "python3 -m wordstats sample.txt -> top word" "${dir}" 0 "1. the: 6"
  assert_run "python3 -m wordstats (stdin)" "${dir}" 0 "1. red: 3" "red red blue green red blue"
}

# --- Reference package: always tested strictly ---
run_pkg_checks "${examples}" "examples/"

# --- Learner starter ---
echo "Testing starter/ ..."
starter_valid="yes"
for f in "${starter}/single_file.py" "${starter}/wordstats/__init__.py" \
         "${starter}/wordstats/tokens.py" "${starter}/wordstats/stats.py" \
         "${starter}/wordstats/__main__.py"; do
  python3 -c "compile(open('${f}').read(), '${f}', 'exec')" 2>/dev/null || starter_valid="no"
done
check "starter files are valid Python" "${starter_valid}"

if grep -rq 'NotImplementedError' "${starter}/wordstats"; then
  echo "Note: starter/wordstats still has unfinished exercises — testing structure only."
  grep -q 'def tokenize' "${starter}/wordstats/tokens.py" && check "starter defines tokenize" "yes" || check "starter defines tokenize" "no"
  grep -q 'def count_words' "${starter}/wordstats/stats.py" && check "starter defines count_words" "yes" || check "starter defines count_words" "no"
  grep -q 'def top_n' "${starter}/wordstats/stats.py" && check "starter defines top_n" "yes" || check "starter defines top_n" "no"
  grep -q 'def report' "${starter}/wordstats/__main__.py" && check "starter defines report" "yes" || check "starter defines report" "no"
  test -f "${starter}/wordstats/__init__.py" && check "starter has wordstats/__init__.py" "yes" || check "starter has wordstats/__init__.py" "no"
else
  run_pkg_checks "${starter}" "starter/"
  grep -q '__name__ == "__main__"' "${starter}/wordstats/__main__.py" && 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 059 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.

No module named wordstats when I run python3 -m wordstats

Python looks for the package on its search path (sys.path). When you run python3 -m wordstats, the first entry on that path is the current directory, so you must run the command from the directory that contains the wordstats/ folder — examples/ for the reference, starter/ for your own version:

cd examples
python3 -m wordstats sample.txt   # correct: wordstats/ is right here

Running it from one level up (where wordstats/ is not directly present) gives this error. This is exactly the sys.path search the lesson describes.

The starter raises NotImplementedError

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, and uncomment the two imports in starter/wordstats/__init__.py (Exercise 5). Once all six exercises are done, the package behaves like the reference.

ImportError: attempted relative import with no known parent package

You tried to run a module inside the package directly, for example python3 wordstats/__main__.py. A relative import like from .tokens import tokenize only works when the file is imported as part of the package. Run the package instead:

python3 -m wordstats sample.txt   # right: -m runs it as a package

The -m form gives the module its parent-package context, so the leading-dot imports resolve.

from wordstats import tokenize fails with ImportError

Until Exercise 5 is done, starter/wordstats/__init__.py does not re-export the submodule names, so the package root exposes nothing to import. Uncomment the two relative imports in that file. Importing straight from the submodule (from wordstats.tokens import tokenize) works even before Exercise 5, because that reaches the function in its own module directly.

A circular-import error mentioning wordstats

If you make tokens.py import stats.py and stats.py import tokens.py, Python cannot finish loading either one — a circular import. In this design neither responsibility module imports the other; only __main__.py and __init__.py import them. Keep the dependencies pointing one way (entry point -> responsibility modules), never in a loop.

__pycache__ folders appeared

Importing a module makes Python cache a compiled .pyc inside __pycache__. The test runner sets PYTHONDONTWRITEBYTECODE=1 to avoid this; if you imported by hand and want them gone, delete them: find . -name __pycache__ -exec rm -rf {} +. They are harmless and are normally left out of version control.

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

  • What the package does: reads one text file you name (or standard input), splits it into words, counts them, and prints a small report. It makes no network connections, needs no privileges, and writes no files — it only reads the input you point it at. The test runner reads only the files in this lab and leaves nothing behind.

  • Imports run code — know what you import. An import statement does not just "load data": Python executes the top level of a module the first time it is imported. That is why the responsibility modules here keep their top level to a compiled regular expression and function definitions, with no work or side effects at import time. Never import a module you have not read, and never add a module to sys.path from an untrusted location — anything on the path can be imported and run.

  • sys.path order is a trust decision. Python imports the first matching module it finds while scanning sys.path, and for python3 -m the current directory comes first. A file named, say, re.py sitting in your working directory could shadow the standard-library re module and be imported instead. Name your modules so they do not collide with the standard library (this package uses tokens.py, not tokenize.py, for exactly that reason), and be deliberate about what directories are on the path.

  • Parse text as data, never as code. This tool treats its input purely as text to be tokenized and counted. Do not reach for eval() or exec() to "process" a line of a file — those execute the string as Python, so a malicious input could delete files or open a connection. Reading text and matching it with re cannot execute anything; that is the safe pattern.

  • The __main__ guard is a safety feature too. Because the real work is behind if __name__ == "__main__":, importing the package (for a test, or to reuse report) never launches the program or reads a file on its own. A module that did work at import time could surprise anyone who imported it — reading files, printing, or worse — so keep runnable behaviour behind the guard.

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