Programming with Python › Functions and Program Design › Day 59
Hands-on lab — Day 59: Modules, Imports, and Project Layout
- ← Back to the Day 59 lesson
- Open the hands-on files on GitHub — clone or download them from the public labs repository
- Local path in your clone:
labs/sections/programming-with-python/day-059-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
- Lesson title: Modules, Imports, and Project Layout
- Day number: 59 of 365
- Lesson article: https://ai-roadmap-365.github.io/day-059-modules-imports-and-project-layout
- 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-059-modules-imports-and-project-layoutwhen the site is running.
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__.pyentry point. - Use
import,from ... import, andimport ... 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 (thesys.modulescache). - 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
pythonforpython3if 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).bashfor the test runner (preinstalled on macOS and Linux).- Standard library only —
re,collections, andsysall ship with Python. No packages to install. Seerequirements/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 itsif __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 findswordstats/on the path (the current directory is first for-m), executeswordstats/__main__.py, which importstokensandstatswith 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 aspython3 -m wordstats, proves each responsibility module loads on its own (no circular imports), checks that a module is imported only once (thesys.modulescache), 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
cd examples && python3 -m wordstats sample.txtprints19 words, 10 uniquefollowed by the five ranked lines above.python3 -c "import sys; sys.path.insert(0, 'examples'); from wordstats import tokenize; print(tokenize('Hello, HELLO world!'))"prints['hello', 'hello', 'world'].- Complete the six exercises in
starter/wordstats, then runcd starter && python3 -m wordstats sample.txtand confirm it matches the reference. - From
starter,python3 -c "from wordstats import top_n"succeeds (proves Exercise 5, the public API, is done). - 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
- Add a third responsibility module,
wordstats/report.py, and movereportinto it; import it from__main__.pyand re-export it from__init__.py. Notice how a new responsibility becomes one new file plus two import lines. - Add a
--top Noption (usingargparse, from Day 56) so the number of ranked words is configurable, defaulting to 5. - Add a
tests/test_wordstats.pythat importstokenize,count_words, andtop_nand asserts their behaviour, printingall tests passedonly if every assertion holds — a pure-Python test with no bash. - Deliberately create a circular import (make
tokens.pydofrom .stats import count_wordsandstats.pydofrom .tokens import tokenize), runpython3 -m wordstats, read the error, then fix it by removing one of the two imports. Write two sentences on what you saw and why.
Navigation
- 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
importstatement 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. Neverimporta module you have not read, and never add a module tosys.pathfrom an untrusted location — anything on the path can be imported and run. -
sys.pathorder is a trust decision. Python imports the first matching module it finds while scanningsys.path, and forpython3 -mthe current directory comes first. A file named, say,re.pysitting in your working directory could shadow the standard-libraryremodule and be imported instead. Name your modules so they do not collide with the standard library (this package usestokens.py, nottokenize.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()orexec()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 withrecannot execute anything; that is the safe pattern. -
The
__main__guard is a safety feature too. Because the real work is behindif __name__ == "__main__":, importing the package (for a test, or to reusereport) 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/*.pyandtests/run_tests.shbefore running them. Running unread scripts is one of the most common ways developers get compromised; the course's rule is that every lab script is small enough to read and understand first.