Programming with PythonPython for Automation and the Web › Day 83

Hands-on lab — Day 83: Packaging and Distributing Python Code

Commands

Setup

cd labs/sections/programming-with-python/day-083-packaging-and-distributing-python-code
python3 -m venv .venv
.venv/bin/pip install -r requirements/requirements.txt
.venv/bin/python -m build --version

Run

bash examples/build_and_inspect.sh
cd examples/wordtally-tools && python3 -m build --no-isolation
cd examples/wordtally-tools && tar -tzf dist/wordtally_tools-0.3.1.tar.gz
cd examples/wordtally-tools && unzip -l dist/wordtally_tools-0.3.1-py3-none-any.whl
cd examples/wordtally-tools && unzip -p dist/wordtally_tools-0.3.1-py3-none-any.whl wordtally_tools-0.3.1.dist-info/METADATA
cd examples/wordtally-tools && unzip -p dist/wordtally_tools-0.3.1-py3-none-any.whl wordtally_tools-0.3.1.dist-info/entry_points.txt
python3 -m venv workspace/tryout
workspace/tryout/bin/pip install --no-index examples/wordtally-tools/dist/wordtally_tools-0.3.1-py3-none-any.whl
workspace/tryout/bin/wordtally --version
cd examples/wordtally-tools && ../../workspace/tryout/bin/python -c 'import wordtally; print(wordtally.__file__)'
cd starter/wordtally-tools && python3 -m build --no-isolation

Test

bash tests/run_tests.sh

File tree

examples/build_and_inspect.sh
examples/sample.txt
examples/wordtally-tools/LICENSE
examples/wordtally-tools/MANIFEST.in
examples/wordtally-tools/pyproject.toml
examples/wordtally-tools/README.md
examples/wordtally-tools/src/wordtally/__init__.py
examples/wordtally-tools/src/wordtally/cli.py
examples/wordtally-tools/src/wordtally/core.py
examples/wordtally-tools/src/wordtally/data/stopwords.txt
examples/wordtally-tools/tests/test_cli.py
examples/wordtally-tools/tests/test_core.py
expected-output/artifact-contents.txt
expected-output/build-and-inspect.txt
expected-output/build-failures.txt
expected-output/FIELDS.md
expected-output/import-resolution.txt
expected-output/starter-build.txt
expected-output/test-run.txt
metadata.yml
README.md
requirements/README.md
requirements/requirements.txt
security.md
starter/wordtally-tools/LICENSE
starter/wordtally-tools/pyproject.toml
starter/wordtally-tools/src/wordtally/__init__.py
starter/wordtally-tools/src/wordtally/cli.py
starter/wordtally-tools/src/wordtally/core.py
starter/wordtally-tools/src/wordtally/data/stopwords.txt
starter/wordtally-tools/tests/test_cli.py
starter/wordtally-tools/tests/test_core.py
tests/run_tests.sh
troubleshooting.md

Lab README

Day 083 lab — Build a Real Package and Install It

Lesson

Purpose

Day 83 of the course, and the day your code stops being something you run and starts being something someone else installs.

You take a small library with a command-line front end, give it a complete pyproject.toml, build it into the two artifacts Python distributes — an sdist and a wheel — open both of them and read what is inside, then install the wheel into a brand-new throwaway environment and run the command it puts on your path.

By the end you will have seen, with your own eyes rather than on trust:

  • that a wheel is a zip file with a metadata directory, and what every entry in it is for;
  • that the sdist carries files the wheel deliberately does not;
  • that one line of TOML is the entire difference between shipping a module and shipping a command;
  • that with a src layout, the installed copy of your package is the copy Python imports even while you are standing inside the project — and that in a flat layout it silently is not.

Nothing in this lab is uploaded anywhere. No package index is contacted, not once. Publishing is described accurately in the lesson and its commands are shown, and then the lab stops before running them. Every pip install here passes --no-index, which forbids pip from talking to any index at all, and tests/run_tests.sh checks that claim mechanically rather than just asserting it. The reason is not squeamishness: a version number published to a public index can never be reused, so an accidental upload is a mistake with no undo.

Learning objectives

  • Read a complete pyproject.toml and say what each table and each field is for, and which of them are standardised and which belong to one tool.
  • Build both artifacts with one command and inspect them with tar and unzip.
  • Explain the difference between a distribution name and an import name, and find both in a built wheel.
  • Turn a Python function into an installed console command with [project.scripts].
  • Demonstrate that a src layout makes the installed copy win the import, and that a flat layout does not.
  • Predict and then verify what happens when required metadata is missing.

Prerequisites

  • Day 43 — virtual environments and pip from the consumer side. Today you are on the other side of the same transaction.
  • Day 59 — modules, imports, and project layout. The src layout here is that lesson's question answered properly.
  • Day 71 to Day 77 — pytest, and the pyproject.toml you wrote on Day 77 to configure quality tools. Today the same file also carries package metadata.
  • Day 80 — argparse and a main(argv) -> int. The console script installed here calls exactly that function.
  • Comfort running bash commands and editing a TOML file.

Supported operating systems

  • macOS (verified: macOS 26.5.1 on Apple Silicon).
  • Linux (any distribution with Python 3.10 or newer, bash, tar and unzip).
  • Windows via WSL. Native Windows differs in two visible ways: environment binaries live in Scripts\ rather than bin/, and an installed console script is wordtally.exe rather than a shebang script. Both scripts here are bash, so WSL is the supported route.

Hardware requirements

Any machine that runs Python. The whole lab builds two archives of a few kilobytes each and creates one virtual environment; disk use stays under about 50 MB and the full test run finishes in a few seconds.

Required software

  • Python 3.10 or newer (verified on 3.14.0).
  • bash, tar, unzip — all present by default on macOS and Linux.
  • The three pinned Python packages in requirements/requirements.txt: build==1.5.0, setuptools==83.0.0, pytest==9.1.1.

Free and open-source options

Everything used here is free and open source, and there is no paid tier of anything in this lab. build, setuptools and pytest are all permissively licensed and install with pip.

The alternatives discussed in the lesson — hatch/hatchling, flit, poetry, uv, pipx and conda — are also all free and open source. None of them is installed on the authoring machine, so this lab quotes no output and no timing figures for them; where the lesson describes them it says so plainly.

Publishing to the public index is free for open-source projects. Private indexes, whether self-hosted or commercial, are the paid case, and the lesson covers when that is the right answer.

Installation

cd labs/sections/programming-with-python/day-083-packaging-and-distributing-python-code
python3 -m venv .venv
.venv/bin/pip install -r requirements/requirements.txt
.venv/bin/python -m build --version
.venv/bin/pytest --version

That install is the only step that needs a network. Everything afterwards — every build, every inspection, every install into the throwaway environment — runs offline.

requirements/README.md explains all three packages, and explains why setuptools is pinned here even though a real project would let the build frontend fetch it.

File structure

day-083-packaging-and-distributing-python-code/
├── README.md                       this file
├── metadata.yml                    every command this lab runs
├── troubleshooting.md
├── security.md
├── requirements/
│   ├── requirements.txt            build, setuptools, pytest — pinned
│   └── README.md                   what each one is for
├── examples/
│   ├── build_and_inspect.sh        the whole flow in one script
│   ├── sample.txt                  input for the installed command
│   └── wordtally-tools/            the FINISHED reference project
│       ├── pyproject.toml          a complete, commented package definition
│       ├── MANIFEST.in             sdist-only inclusions
│       ├── LICENSE
│       ├── README.md               becomes the long description in METADATA
│       ├── src/wordtally/          src layout: the importable package
│       │   ├── __init__.py         single-sourced __version__
│       │   ├── core.py             the library
│       │   ├── cli.py              the argparse front end
│       │   └── data/stopwords.txt  packaged data, shipped in the wheel
│       └── tests/                  in the sdist, not in the wheel
├── starter/
│   └── wordtally-tools/            same code, ten numbered exercises in
│                                   pyproject.toml
├── tests/
│   └── run_tests.sh                87 checks
└── expected-output/                real captures from the authoring machine

workspace/ is created by the scripts, holds every build product and the throwaway environment, and is deleted again. It is ignored by version control, so nothing you build here can be committed by accident.

How to run

The whole thing, in one script:

bash examples/build_and_inspect.sh

Or step by step, which is the better way to learn it. Build:

cd examples/wordtally-tools
python3 -m build --no-isolation
ls dist/

Look inside both artifacts:

tar -tzf dist/wordtally_tools-0.3.1.tar.gz
unzip -l dist/wordtally_tools-0.3.1-py3-none-any.whl
unzip -p dist/wordtally_tools-0.3.1-py3-none-any.whl \
  wordtally_tools-0.3.1.dist-info/METADATA
unzip -p dist/wordtally_tools-0.3.1-py3-none-any.whl \
  wordtally_tools-0.3.1.dist-info/entry_points.txt

Install into a fresh environment and run the installed command:

cd ../..                                     # back to the lab directory
mkdir -p workspace
python3 -m venv workspace/tryout
workspace/tryout/bin/pip install --no-index \
  examples/wordtally-tools/dist/wordtally_tools-0.3.1-py3-none-any.whl
workspace/tryout/bin/wordtally --version
cp examples/sample.txt examples/wordtally-tools/sample.txt
cd examples/wordtally-tools
../../workspace/tryout/bin/wordtally count sample.txt
../../workspace/tryout/bin/wordtally top sample.txt -n 3

And the check the whole lab exists for — where did the import come from?

../../workspace/tryout/bin/python -c "import wordtally; print(wordtally.__file__)"

Then work through the ten numbered exercises in starter/wordtally-tools/pyproject.toml, which walk the same path from a two-field metadata block to a complete one.

What the commands do

Command What it does
python3 -m build --no-isolation Runs the build frontend. It reads [build-system], calls the backend named there (setuptools.build_meta), and writes an sdist and a wheel into dist/. --no-isolation reuses the installed setuptools instead of fetching one, which is what keeps this lab offline; a real release omits the flag.
tar -tzf …tar.gz Lists the sdist. It is a gzipped tar of the project as a maintainer would want it: source, tests, licence, build instructions.
unzip -l …whl Lists the wheel. It is an ordinary zip archive — this command is the whole proof — holding the package as the installer wants it plus a .dist-info metadata directory.
unzip -p …whl …/METADATA Prints the metadata an index would display: name, version, summary, licence, classifiers, required Python, dependencies. Every line of it came from [project] in pyproject.toml.
unzip -p …whl …/entry_points.txt Prints the console-script declaration. This is the file an installer reads to decide to create an executable called wordtally.
python3 -m venv workspace/tryout Creates an empty environment. It has no packages beyond pip, which is the point: it cannot accidentally have your project on its path already.
pip install --no-index …whl Installs the wheel by unpacking it. --no-index forbids contacting any package index; the install is local and offline.
workspace/tryout/bin/wordtally --version Runs the command that installing created. Nothing put this on your path but [project.scripts].
python -c "import wordtally; print(wordtally.__file__)" Asks Python where it found the package. Run from inside the project directory, the answer must still be site-packages.
bash tests/run_tests.sh The 87-check harness.

Expected output

Captured on the authoring machine (macOS 26.5.1, Apple Silicon, Python 3.14.0, bash 3.2.57, build 1.5.0, setuptools 83.0.0, pip 25.2, pytest 9.1.1) on 2026-07-19. Absolute paths appear as <repo>.

The build:

* Building sdist...
* Building wheel from sdist...
Successfully built wordtally_tools-0.3.1.tar.gz and wordtally_tools-0.3.1-py3-none-any.whl

The wheel, opened:

Archive:  dist/wordtally_tools-0.3.1-py3-none-any.whl
  Length      Date    Time    Name
---------  ---------- -----   ----
      912  07-19-2026 13:24   wordtally/__init__.py
     2775  07-19-2026 13:24   wordtally/cli.py
     2427  07-19-2026 13:24   wordtally/core.py
      132  07-19-2026 13:24   wordtally/data/stopwords.txt
     1079  07-19-2026 13:24   wordtally_tools-0.3.1.dist-info/licenses/LICENSE
     1656  07-19-2026 13:24   wordtally_tools-0.3.1.dist-info/METADATA
       91  07-19-2026 13:24   wordtally_tools-0.3.1.dist-info/WHEEL
       49  07-19-2026 13:24   wordtally_tools-0.3.1.dist-info/entry_points.txt
       10  07-19-2026 13:24   wordtally_tools-0.3.1.dist-info/top_level.txt
      846  07-19-2026 13:24   wordtally_tools-0.3.1.dist-info/RECORD
---------                     -------
     9977                     10 files

No pyproject.toml, no MANIFEST.in, no tests/, and no src/ prefix. The sdist has all four.

The installed command, and where it imported from:

wordtally 0.3.1
12
     3  mat
     1  cat
<repo>/labs/sections/programming-with-python/day-083-packaging-and-distributing-python-code/workspace/tryout/lib/python3.14/site-packages/wordtally/__init__.py

Full captures, including the sdist listing, the complete METADATA, the editable-install comparison and the deliberate build failures, are in expected-output/. expected-output/FIELDS.md explains each file and lists the behaviour the harness requires.

Validation steps

  1. dist/ holds exactly two files, and both filenames contain 0.3.1.
  2. unzip -t on the wheel reports no errors — it really is a valid zip.
  3. The wheel listing contains wordtally/core.py and wordtally/data/stopwords.txt, and does not contain pyproject.toml or tests/test_core.py.
  4. The sdist listing contains all four of those.
  5. workspace/tryout/bin/wordtally exists, is executable, and wordtally --version prints wordtally 0.3.1 and exits 0.
  6. Run from inside examples/wordtally-tools, import wordtally; print(wordtally.__file__) prints a path inside workspace/tryout/lib/, not a path inside the project.
  7. Deleting the name line from pyproject.toml makes python3 -m build exit non-zero and print a message naming the missing field, and leaves no dist/ behind.
  8. bash tests/run_tests.sh prints 87 checks, 0 failure(s). and exits 0.

Tests

bash tests/run_tests.sh

87 checks in eleven sections. The harness is deterministic and offline: it copies the reference project into workspace/, builds it, opens both artifacts, creates a fresh environment, installs the wheel with --no-index, runs the installed command, and compares the resolved __file__ against a flat-layout copy of the same code. It also removes a required metadata field and demands that the build fail, and it checks that metadata.yml declares no publishing command anywhere.

The three checks worth reading the source for are the src-versus-flat comparison in section 6, the wheel contents in section 3, and the failing build in section 9.

Every check that runs a tool resolves it first: PYTHON=… or PYTEST=… if you set them, then this lab's .venv/bin/, then your PATH. A missing tool stops the run with install instructions rather than skipping quietly.

Cleanup

rm -rf workspace
rm -rf examples/wordtally-tools/dist examples/wordtally-tools/build
find examples/wordtally-tools/src -type d -name '*.egg-info' -prune -exec rm -rf -- {} +
rm -rf starter/wordtally-tools/dist starter/wordtally-tools/build
find starter/wordtally-tools/src -type d -name '*.egg-info' -prune -exec rm -rf -- {} +
rm -f examples/wordtally-tools/sample.txt
git checkout -- starter/     # optional: reset your work on the exercises

tests/run_tests.sh already removes workspace/ on the way out, including when a check fails, so a test run leaves nothing behind. The commands above matter if you built by hand in examples/ or starter/. Nothing here is installed system-wide and nothing was installed outside the environments you created, so there is nothing else to undo.

Troubleshooting

See troubleshooting.md for the full list. The three most common:

  • No module named build — the requirements are not installed for the Python you are running. Use .venv/bin/python -m build, or set PYTHON=.venv/bin/python.
  • Backend 'setuptools.build_meta' is not available with --no-isolation — setuptools is not installed in that environment. Install the requirements, or drop --no-isolation and let the frontend fetch a backend (that needs a network).
  • import wordtally prints a path inside the project rather than inside the environment — you are almost certainly in a flat-layout directory, or you have a stray wordtally/ folder in your working directory. That is the failure the src layout exists to prevent, and section 6 of the harness reproduces it on purpose.

Security notes

See security.md. In short: this lab contacts no index and uploads nothing; it never asks for a token and there is nowhere to put one; installing a package runs the publisher's code, which is why the throwaway environment is throwaway; and a version number, once published, cannot be reused or quietly replaced.

Extension exercises

  1. Add a second console script — wordtally-count mapped to a new wordtally.cli:count_main — rebuild, and confirm two entries appear in entry_points.txt and two executables appear after installing.
  2. Give the package a real runtime dependency, rebuild, and read the Requires-Dist line that appears in METADATA. Then try installing the wheel with --no-index and watch it fail, which is exactly what a dependency means.
  3. Delete the [tool.setuptools.package-data] table, rebuild, install, and run wordtally top. Read the traceback carefully: this is the single most common packaging bug, and now you know its shape.
  4. Change version to 0.3.2, rebuild without deleting dist/, and note that you now have four artifacts. Which one would pip install dist/*.whl pick, and why is that a good argument for building into a clean directory?
  5. Build the same project with a different backend. Replace [build-system] with hatchling's, adjust the tool-specific tables, and compare the two wheels file by file. They should be nearly identical — which is the whole point of a standardised interface.
  6. Write a MANIFEST.in that excludes the tests from the sdist, rebuild, and argue for or against shipping tests in an sdist. There are real projects on both sides.
  • Previous lab: ../day-082-a-first-web-api-with-fastapi/
  • Next lab: ../day-084-shipping-an-automation-toolkit/
  • Section index: ../README.md

Expected output

FIELDS.md

# Expected output — Day 083 lab

Every file in this directory is a real capture from the authoring machine
(macOS 26.5.1, Apple Silicon, Python 3.14.0, bash 3.2.57, 2026-07-19) with
`build` 1.5.0, `setuptools` 83.0.0, `pip` 25.2 and `pytest` 9.1.1. Absolute
paths appear as `<repo>`; the interpreter that ran the build appears as
`<env>`. On your machine both are real paths.

**Nothing in these captures uploaded anything to any index.** Every install
shown passes `--no-index`, except the one editable install in
`import-resolution.txt`, which is labelled in the capture itself and needs a
network only because `pip install -e .` builds with isolation.

Nothing in this lab reads the clock, the network, or a random number, so the
listings and the counts below are the same on any machine running the pinned
tool versions. Only the timestamps inside the archive listings differ.

## Files

- `build-and-inspect.txt` — a full run of `bash examples/build_and_inspect.sh`:
  build, sdist listing, wheel listing, metadata, entry points, a fresh
  environment, the installed command, and the resolved `__file__`. If you read
  one file here, read this one.
- `artifact-contents.txt` — the two artifacts opened side by side, plus the
  complete `METADATA`, `WHEEL`, `RECORD`, `entry_points.txt` and
  `top_level.txt` files from inside the wheel.
- `import-resolution.txt` — the three-way comparison the lab exists for: the
  same import, in a src layout with the wheel installed, in a flat layout with
  the same wheel installed, and under an editable install.
- `build-failures.txt` — `name` and then `version` removed from `[project]`.
  Both builds fail, both name the missing field, and neither leaves a `dist/`
  behind.
- `starter-build.txt` — the starter built exactly as shipped, showing the three
  gaps exercises 1 to 5 close.
- `test-run.txt` — a full run of `bash tests/run_tests.sh`: 87 checks, 0
  failures, exit 0.

## Required behaviour of the reference project

| Command, run in the project directory | Result |
| --- | --- |
| `python3 -m build --no-isolation` | exit 0, two files in `dist/` |
| `ls dist/` | `wordtally_tools-0.3.1-py3-none-any.whl` and `wordtally_tools-0.3.1.tar.gz` |
| `unzip -t dist/…whl` | no errors: the wheel is a valid zip |
| `unzip -Z1 dist/…whl \| wc -l` | 10 entries |
| `unzip -p dist/…whl …/entry_points.txt` | `[console_scripts]` then `wordtally = wordtally.cli:main` |
| `tar -tzf dist/…tar.gz \| wc -l` | 24 entries |

## What is in which artifact

| Path | In the sdist | In the wheel | Why |
| --- | --- | --- | --- |
| `src/wordtally/core.py` | yes, with the `src/` prefix | yes, flattened to `wordtally/core.py` | a wheel is unpacked straight into `site-packages` |
| `wordtally/data/stopwords.txt` | yes | yes | declared in `[tool.setuptools.package-data]`; the installed code reads it at runtime |
| `pyproject.toml` | yes | no | build instructions; the wheel is already built |
| `MANIFEST.in` | yes | no | it only ever controlled the sdist |
| `tests/test_core.py` | yes | no | users install a package, not a test suite |
| `PKG-INFO` | yes | no | the sdist's metadata file; the wheel uses `METADATA` instead |
| `…dist-info/METADATA` | no | yes | the installed metadata, and what an index displays |
| `…dist-info/RECORD` | no | yes | the manifest an uninstall reads |

## Required behaviour after installing

| Command | Result |
| --- | --- |
| `pip install --no-index …whl` | exit 0, no index contacted |
| `workspace/tryout/bin/wordtally --version` | `wordtally 0.3.1`, exit 0 |
| `wordtally count sample.txt` | `12`, exit 0 |
| `wordtally top sample.txt -n 2` | `     3  mat` then `     1  cat`, exit 0 |
| `wordtally count no-such-file.txt` | a message on standard error, exit 2 |
| `python -c "import wordtally_tools"` | `ModuleNotFoundError` — the distribution name is not an import name |
| `python -c "import wordtally; print(wordtally.__file__)"` from the project | a path inside `workspace/tryout/lib/python3.14/site-packages/` |

## The src-layout proof

| Layout | `wordtally.__file__`, evaluated inside the project |
| --- | --- |
| `src` layout, wheel installed | `…/workspace/tryout/lib/python3.14/site-packages/wordtally/__init__.py` |
| flat layout, same wheel installed | `…/workspace/flat/wordtally/__init__.py` |
| `src` layout, editable install | `…/workspace/demo/src/wordtally/__init__.py` |

Row one is what you want when you test. Row two is the accident the src layout
prevents: the installed copy is present and loses anyway, so the suite passes
against code that is not what anyone receives. Row three is deliberate and
correct — an editable install is *supposed* to point at your working tree.

## Required behaviour of the starter

| Check on the starter's wheel, as shipped | Result |
| --- | --- |
| artifacts exist | `wordtally_tools-0.1.0-*` — its own declared version |
| `METADATA` size | 103 bytes, five lines |
| `entry_points.txt` present | no — exercise 4 is real work |
| `wordtally/data/stopwords.txt` present | no — exercise 5 is real work |

## Platform notes

- **Linux** produces identical listings. The wheel tag stays `py3-none-any`
  because the project is pure Python; only the intermediate
  `build/bdist.macosx-26.0-arm64/` directory name in the verbose build log is
  platform-specific, and it does not appear in any artifact.
- **Windows**: run everything inside WSL. On native Windows the environment
  layout is `Scripts\` rather than `bin/`, and the installed console script is
  `wordtally.exe` rather than a shebang script, so the shebang check in
  section 5 of the harness does not apply there.
- **Different tool versions** will change wording and byte sizes. setuptools'
  error text for missing metadata, pip's install summary and the exact file
  sizes all move between releases. The exit codes, the artifact names, the set
  of files inside each archive and the resolved `__file__` will not — which is
  why `tests/run_tests.sh` asserts on those and not on prose.
- The harness builds everything under `workspace/` and removes it in an `EXIT`
  trap, so a completed run — passing or failing — leaves nothing behind.

artifact-contents.txt

$ tar -tzf dist/wordtally_tools-0.3.1.tar.gz
wordtally_tools-0.3.1/
wordtally_tools-0.3.1/LICENSE
wordtally_tools-0.3.1/MANIFEST.in
wordtally_tools-0.3.1/PKG-INFO
wordtally_tools-0.3.1/README.md
wordtally_tools-0.3.1/pyproject.toml
wordtally_tools-0.3.1/setup.cfg
wordtally_tools-0.3.1/src/
wordtally_tools-0.3.1/src/wordtally/
wordtally_tools-0.3.1/src/wordtally/__init__.py
wordtally_tools-0.3.1/src/wordtally/cli.py
wordtally_tools-0.3.1/src/wordtally/core.py
wordtally_tools-0.3.1/src/wordtally/data/
wordtally_tools-0.3.1/src/wordtally/data/stopwords.txt
wordtally_tools-0.3.1/src/wordtally_tools.egg-info/
wordtally_tools-0.3.1/src/wordtally_tools.egg-info/PKG-INFO
wordtally_tools-0.3.1/src/wordtally_tools.egg-info/SOURCES.txt
wordtally_tools-0.3.1/src/wordtally_tools.egg-info/dependency_links.txt
wordtally_tools-0.3.1/src/wordtally_tools.egg-info/entry_points.txt
wordtally_tools-0.3.1/src/wordtally_tools.egg-info/requires.txt
wordtally_tools-0.3.1/src/wordtally_tools.egg-info/top_level.txt
wordtally_tools-0.3.1/tests/
wordtally_tools-0.3.1/tests/test_cli.py
wordtally_tools-0.3.1/tests/test_core.py

$ unzip -l dist/wordtally_tools-0.3.1-py3-none-any.whl
Archive:  dist/wordtally_tools-0.3.1-py3-none-any.whl
  Length      Date    Time    Name
---------  ---------- -----   ----
      912  07-19-2026 13:33   wordtally/__init__.py
     2775  07-19-2026 13:33   wordtally/cli.py
     2427  07-19-2026 13:33   wordtally/core.py
      132  07-19-2026 13:33   wordtally/data/stopwords.txt
     1079  07-19-2026 13:33   wordtally_tools-0.3.1.dist-info/licenses/LICENSE
     1656  07-19-2026 13:33   wordtally_tools-0.3.1.dist-info/METADATA
       91  07-19-2026 13:33   wordtally_tools-0.3.1.dist-info/WHEEL
       49  07-19-2026 13:33   wordtally_tools-0.3.1.dist-info/entry_points.txt
       10  07-19-2026 13:33   wordtally_tools-0.3.1.dist-info/top_level.txt
      846  07-19-2026 13:33   wordtally_tools-0.3.1.dist-info/RECORD
---------                     -------
     9977                     10 files

$ unzip -p dist/wordtally_tools-0.3.1-py3-none-any.whl wordtally_tools-0.3.1.dist-info/METADATA
Metadata-Version: 2.4
Name: wordtally-tools
Version: 0.3.1
Summary: Count and rank the words in a text file, from Python or from the command line.
Author: 365 Days of AI Mastery
License-Expression: MIT
Project-URL: Documentation, https://packaging.python.org/en/latest/
Keywords: text,words,counting,cli
Classifier: Development Status :: 4 - Beta
Classifier: Environment :: Console
Classifier: Intended Audience :: Developers
Classifier: Operating System :: OS Independent
Classifier: Programming Language :: Python :: 3
Classifier: Topic :: Text Processing :: Linguistic
Requires-Python: >=3.10
Description-Content-Type: text/markdown
License-File: LICENSE
Provides-Extra: dev
Requires-Dist: pytest>=8; extra == "dev"
Requires-Dist: build>=1.2; extra == "dev"
Dynamic: license-file

# wordtally-tools

Count and rank the words in a text file, from Python or from the command line.

This project exists to be built, inspected and installed. It is a teaching
package and is deliberately not published to any index.

## Install

```bash
pip install wordtally-tools
```

The distribution name is `wordtally-tools`; the import name is `wordtally`.

## Use it as a library

```python
from wordtally import count_words, top_words

count_words("the cat sat on the mat")          # 6
top_words("the cat sat on the cat mat", n=2)   # [('cat', 2), ('mat', 1)]
```

## Use it as a command

```bash
wordtally count sample.txt
wordtally top sample.txt -n 5
wordtally --version
```

`wordtally top` filters common words such as `the` and `and` using a stop-word
list shipped inside the package; pass `--keep-stopwords` to turn that off.

## Licence

MIT. See `LICENSE`.

$ unzip -p dist/wordtally_tools-0.3.1-py3-none-any.whl wordtally_tools-0.3.1.dist-info/WHEEL
Wheel-Version: 1.0
Generator: setuptools (83.0.0)
Root-Is-Purelib: true
Tag: py3-none-any


$ unzip -p dist/wordtally_tools-0.3.1-py3-none-any.whl wordtally_tools-0.3.1.dist-info/RECORD
wordtally/__init__.py,sha256=jX23Hy25H4_UoZD1G_6fXAJm0qUOqswDdiKhjpOvGhQ,912
wordtally/cli.py,sha256=VrOByDtD3FmYHeoJNVZ32Y0mw0uuqr0hsR_Ovm8KBVo,2775
wordtally/core.py,sha256=FcDKpL12bx0yNVNpICHLhhjlPGdtMDfaRIq2xWrfLcs,2427
wordtally/data/stopwords.txt,sha256=ZP6yrlV4Shf14dwl6LBoHsEYAMCjQTOqMi9i2-75yKw,132
wordtally_tools-0.3.1.dist-info/licenses/LICENSE,sha256=uKRc-_YNNEAQhoKywdG4BmY24K4P-IPnIdbAsUALBb8,1079
wordtally_tools-0.3.1.dist-info/METADATA,sha256=nGcmbfyrDuTzJ9SGkqNeDcx_hGSPZyOHL92F8Ptnfv4,1656
wordtally_tools-0.3.1.dist-info/WHEEL,sha256=K260EYznzXsJYBQGqmI8VTxEdiZYNvDZwW9cBh9-_MA,91
wordtally_tools-0.3.1.dist-info/entry_points.txt,sha256=-YyfVAHU3YRBXxBnmdFFSpufLM6u2kq6422i3vJl7S0,49
wordtally_tools-0.3.1.dist-info/top_level.txt,sha256=HfaJxiNl_D_eDAZy0joOgja787RjFKtXiwkd__p4EY4,10
wordtally_tools-0.3.1.dist-info/RECORD,,

$ unzip -p dist/wordtally_tools-0.3.1-py3-none-any.whl wordtally_tools-0.3.1.dist-info/entry_points.txt
[console_scripts]
wordtally = wordtally.cli:main

$ unzip -p dist/wordtally_tools-0.3.1-py3-none-any.whl wordtally_tools-0.3.1.dist-info/top_level.txt
wordtally

build-and-inspect.txt

=== 1. Build both artifacts ================================================
* Getting build dependencies for sdist...
running egg_info
creating src/wordtally_tools.egg-info
writing src/wordtally_tools.egg-info/PKG-INFO
writing dependency_links to src/wordtally_tools.egg-info/dependency_links.txt
writing entry points to src/wordtally_tools.egg-info/entry_points.txt
writing requirements to src/wordtally_tools.egg-info/requires.txt
writing top-level names to src/wordtally_tools.egg-info/top_level.txt
writing manifest file 'src/wordtally_tools.egg-info/SOURCES.txt'
reading manifest file 'src/wordtally_tools.egg-info/SOURCES.txt'
reading manifest template 'MANIFEST.in'
warning: no previously-included files matching '__pycache__' found anywhere in distribution
adding license file 'LICENSE'
writing manifest file 'src/wordtally_tools.egg-info/SOURCES.txt'
* Building sdist...
running sdist
running egg_info
writing src/wordtally_tools.egg-info/PKG-INFO
writing dependency_links to src/wordtally_tools.egg-info/dependency_links.txt
writing entry points to src/wordtally_tools.egg-info/entry_points.txt
writing requirements to src/wordtally_tools.egg-info/requires.txt
writing top-level names to src/wordtally_tools.egg-info/top_level.txt
reading manifest file 'src/wordtally_tools.egg-info/SOURCES.txt'
reading manifest template 'MANIFEST.in'
warning: no previously-included files matching '__pycache__' found anywhere in distribution
adding license file 'LICENSE'
writing manifest file 'src/wordtally_tools.egg-info/SOURCES.txt'
running check
creating wordtally_tools-0.3.1
creating wordtally_tools-0.3.1/src/wordtally
creating wordtally_tools-0.3.1/src/wordtally/data
creating wordtally_tools-0.3.1/src/wordtally_tools.egg-info
creating wordtally_tools-0.3.1/tests
copying files to wordtally_tools-0.3.1...
copying LICENSE -> wordtally_tools-0.3.1
copying MANIFEST.in -> wordtally_tools-0.3.1
copying README.md -> wordtally_tools-0.3.1
copying pyproject.toml -> wordtally_tools-0.3.1
copying src/wordtally/__init__.py -> wordtally_tools-0.3.1/src/wordtally
copying src/wordtally/cli.py -> wordtally_tools-0.3.1/src/wordtally
copying src/wordtally/core.py -> wordtally_tools-0.3.1/src/wordtally
copying src/wordtally/data/stopwords.txt -> wordtally_tools-0.3.1/src/wordtally/data
copying src/wordtally_tools.egg-info/PKG-INFO -> wordtally_tools-0.3.1/src/wordtally_tools.egg-info
copying src/wordtally_tools.egg-info/SOURCES.txt -> wordtally_tools-0.3.1/src/wordtally_tools.egg-info
copying src/wordtally_tools.egg-info/dependency_links.txt -> wordtally_tools-0.3.1/src/wordtally_tools.egg-info
copying src/wordtally_tools.egg-info/entry_points.txt -> wordtally_tools-0.3.1/src/wordtally_tools.egg-info
copying src/wordtally_tools.egg-info/requires.txt -> wordtally_tools-0.3.1/src/wordtally_tools.egg-info
copying src/wordtally_tools.egg-info/top_level.txt -> wordtally_tools-0.3.1/src/wordtally_tools.egg-info
copying tests/test_cli.py -> wordtally_tools-0.3.1/tests
copying tests/test_core.py -> wordtally_tools-0.3.1/tests
copying src/wordtally_tools.egg-info/SOURCES.txt -> wordtally_tools-0.3.1/src/wordtally_tools.egg-info
Writing wordtally_tools-0.3.1/setup.cfg
Creating tar archive
removing 'wordtally_tools-0.3.1' (and everything under it)
* Building wheel from sdist
* Getting build dependencies for wheel...
running egg_info
writing src/wordtally_tools.egg-info/PKG-INFO
writing dependency_links to src/wordtally_tools.egg-info/dependency_links.txt
writing entry points to src/wordtally_tools.egg-info/entry_points.txt
writing requirements to src/wordtally_tools.egg-info/requires.txt
writing top-level names to src/wordtally_tools.egg-info/top_level.txt
reading manifest file 'src/wordtally_tools.egg-info/SOURCES.txt'
reading manifest template 'MANIFEST.in'
warning: no previously-included files matching '__pycache__' found anywhere in distribution
warning: no previously-included files matching '*.py[cod]' found anywhere in distribution
adding license file 'LICENSE'
writing manifest file 'src/wordtally_tools.egg-info/SOURCES.txt'
* Building wheel...
running bdist_wheel
running build
running build_py
creating build/lib/wordtally
copying src/wordtally/__init__.py -> build/lib/wordtally
copying src/wordtally/core.py -> build/lib/wordtally
copying src/wordtally/cli.py -> build/lib/wordtally
running egg_info
writing src/wordtally_tools.egg-info/PKG-INFO
writing dependency_links to src/wordtally_tools.egg-info/dependency_links.txt
writing entry points to src/wordtally_tools.egg-info/entry_points.txt
writing requirements to src/wordtally_tools.egg-info/requires.txt
writing top-level names to src/wordtally_tools.egg-info/top_level.txt
reading manifest file 'src/wordtally_tools.egg-info/SOURCES.txt'
reading manifest template 'MANIFEST.in'
warning: no previously-included files matching '__pycache__' found anywhere in distribution
warning: no previously-included files matching '*.py[cod]' found anywhere in distribution
adding license file 'LICENSE'
writing manifest file 'src/wordtally_tools.egg-info/SOURCES.txt'
creating build/lib/wordtally/data
copying src/wordtally/data/stopwords.txt -> build/lib/wordtally/data
installing to build/bdist.macosx-26.0-arm64/wheel
running install
running install_lib
creating build/bdist.macosx-26.0-arm64/wheel
creating build/bdist.macosx-26.0-arm64/wheel/wordtally
copying build/lib/wordtally/__init__.py -> build/bdist.macosx-26.0-arm64/wheel/./wordtally
copying build/lib/wordtally/core.py -> build/bdist.macosx-26.0-arm64/wheel/./wordtally
copying build/lib/wordtally/cli.py -> build/bdist.macosx-26.0-arm64/wheel/./wordtally
creating build/bdist.macosx-26.0-arm64/wheel/wordtally/data
copying build/lib/wordtally/data/stopwords.txt -> build/bdist.macosx-26.0-arm64/wheel/./wordtally/data
running install_egg_info
Copying src/wordtally_tools.egg-info to build/bdist.macosx-26.0-arm64/wheel/./wordtally_tools-0.3.1-py3.14.egg-info
running install_scripts
creating build/bdist.macosx-26.0-arm64/wheel/wordtally_tools-0.3.1.dist-info/WHEEL
creating '<repo>/labs/sections/programming-with-python/day-083-packaging-and-distributing-python-code/workspace/demo/dist/.tmp-8qyi9596/wordtally_tools-0.3.1-py3-none-any.whl' and adding 'build/bdist.macosx-26.0-arm64/wheel' to it
adding 'wordtally/__init__.py'
adding 'wordtally/cli.py'
adding 'wordtally/core.py'
adding 'wordtally/data/stopwords.txt'
adding 'wordtally_tools-0.3.1.dist-info/licenses/LICENSE'
adding 'wordtally_tools-0.3.1.dist-info/METADATA'
adding 'wordtally_tools-0.3.1.dist-info/WHEEL'
adding 'wordtally_tools-0.3.1.dist-info/entry_points.txt'
adding 'wordtally_tools-0.3.1.dist-info/top_level.txt'
adding 'wordtally_tools-0.3.1.dist-info/RECORD'
removing build/bdist.macosx-26.0-arm64/wheel
Successfully built wordtally_tools-0.3.1.tar.gz and wordtally_tools-0.3.1-py3-none-any.whl

wordtally_tools-0.3.1-py3-none-any.whl
wordtally_tools-0.3.1.tar.gz

=== 2. The sdist: a source snapshot ========================================
wordtally_tools-0.3.1/
wordtally_tools-0.3.1/LICENSE
wordtally_tools-0.3.1/MANIFEST.in
wordtally_tools-0.3.1/PKG-INFO
wordtally_tools-0.3.1/README.md
wordtally_tools-0.3.1/pyproject.toml
wordtally_tools-0.3.1/setup.cfg
wordtally_tools-0.3.1/src/
wordtally_tools-0.3.1/src/wordtally/
wordtally_tools-0.3.1/src/wordtally/__init__.py
wordtally_tools-0.3.1/src/wordtally/cli.py
wordtally_tools-0.3.1/src/wordtally/core.py
wordtally_tools-0.3.1/src/wordtally/data/
wordtally_tools-0.3.1/src/wordtally/data/stopwords.txt
wordtally_tools-0.3.1/src/wordtally_tools.egg-info/
wordtally_tools-0.3.1/src/wordtally_tools.egg-info/PKG-INFO
wordtally_tools-0.3.1/src/wordtally_tools.egg-info/SOURCES.txt
wordtally_tools-0.3.1/src/wordtally_tools.egg-info/dependency_links.txt
wordtally_tools-0.3.1/src/wordtally_tools.egg-info/entry_points.txt
wordtally_tools-0.3.1/src/wordtally_tools.egg-info/requires.txt
wordtally_tools-0.3.1/src/wordtally_tools.egg-info/top_level.txt
wordtally_tools-0.3.1/tests/
wordtally_tools-0.3.1/tests/test_cli.py
wordtally_tools-0.3.1/tests/test_core.py

=== 3. The wheel: a built artifact, and just a zip ==========================
Archive:  dist/wordtally_tools-0.3.1-py3-none-any.whl
  Length      Date    Time    Name
---------  ---------- -----   ----
      912  07-19-2026 13:33   wordtally/__init__.py
     2775  07-19-2026 13:33   wordtally/cli.py
     2427  07-19-2026 13:33   wordtally/core.py
      132  07-19-2026 13:33   wordtally/data/stopwords.txt
     1079  07-19-2026 13:33   wordtally_tools-0.3.1.dist-info/licenses/LICENSE
     1656  07-19-2026 13:33   wordtally_tools-0.3.1.dist-info/METADATA
       91  07-19-2026 13:33   wordtally_tools-0.3.1.dist-info/WHEEL
       49  07-19-2026 13:33   wordtally_tools-0.3.1.dist-info/entry_points.txt
       10  07-19-2026 13:33   wordtally_tools-0.3.1.dist-info/top_level.txt
      846  07-19-2026 13:33   wordtally_tools-0.3.1.dist-info/RECORD
---------                     -------
     9977                     10 files

=== 4. The metadata the index would display ================================
Metadata-Version: 2.4
Name: wordtally-tools
Version: 0.3.1
Summary: Count and rank the words in a text file, from Python or from the command line.
Author: 365 Days of AI Mastery
License-Expression: MIT
Project-URL: Documentation, https://packaging.python.org/en/latest/
Keywords: text,words,counting,cli
Classifier: Development Status :: 4 - Beta
Classifier: Environment :: Console
Classifier: Intended Audience :: Developers
Classifier: Operating System :: OS Independent
Classifier: Programming Language :: Python :: 3
Classifier: Topic :: Text Processing :: Linguistic
Requires-Python: >=3.10
Description-Content-Type: text/markdown
License-File: LICENSE
Provides-Extra: dev
Requires-Dist: pytest>=8; extra == "dev"
Requires-Dist: build>=1.2; extra == "dev"
Dynamic: license-file


=== 5. The console script declaration ======================================
[console_scripts]
wordtally = wordtally.cli:main

=== 6. Install the wheel into a fresh, throwaway environment ===============
installed:
wordtally-tools==0.3.1

=== 7. Run the installed COMMAND ===========================================
wordtally 0.3.1
12
     3  mat
     1  cat
     1  cat's

=== 8. Where did the import come from? =====================================
standing in: <repo>/labs/sections/programming-with-python/day-083-packaging-and-distributing-python-code/workspace/demo
<repo>/labs/sections/programming-with-python/day-083-packaging-and-distributing-python-code/workspace/tryout/lib/python3.14/site-packages/wordtally/__init__.py
That path is inside the environment, not inside this directory.
src layout is what guarantees it.

Done. Remove everything with:  rm -rf <repo>/labs/sections/programming-with-python/day-083-packaging-and-distributing-python-code/workspace

build-failures.txt

# Two deliberate failures, and what a well-behaved build says about them.

--- removing 'name' from [project] ---------------------------------
$ python3 -m build --no-isolation
    validate(subset, filepath)
    ~~~~~~~~^^^^^^^^^^^^^^^^^^
  File "<env>/lib/python3.14/site-packages/setuptools/config/pyprojecttoml.py", line 61, in validate
    raise ValueError(f"{error}\n{summary}") from None
ValueError: invalid pyproject.toml config: `project`.
configuration error: `project` must contain ['name'] properties

ERROR Backend subprocess exited when trying to invoke get_requires_for_build_sdist
exit code: 1
$ ls dist/ 2>&1
ls: dist/: No such file or directory

--- removing 'version' from [project] ---------------------------------
$ python3 -m build --no-isolation
    validate(subset, filepath)
    ~~~~~~~~^^^^^^^^^^^^^^^^^^
  File "<env>/lib/python3.14/site-packages/setuptools/config/pyprojecttoml.py", line 61, in validate
    raise ValueError(f"{error}\n{summary}") from None
ValueError: invalid pyproject.toml config: `project`.
configuration error: `project` must contain ['version'] properties

ERROR Backend subprocess exited when trying to invoke get_requires_for_build_sdist
exit code: 1
$ ls dist/ 2>&1
ls: dist/: No such file or directory

Nothing was produced. That is the desired behaviour: an artifact without
a name or a version is an artifact nobody can install, depend on, or
supersede, so the build refuses rather than guessing.

import-resolution.txt

# Where does 'import wordtally' come from? Three environments, three answers.

--- 1. src layout, wheel installed, standing inside the project ---------
$ pwd
<repo>/labs/sections/programming-with-python/day-083-packaging-and-distributing-python-code/workspace/demo
$ ls
LICENSE
MANIFEST.in
README.md
dist
pyproject.toml
sample.txt
src
tests
$ ../tryout/bin/python -c 'import wordtally; print(wordtally.__file__)'
<repo>/labs/sections/programming-with-python/day-083-packaging-and-distributing-python-code/workspace/tryout/lib/python3.14/site-packages/wordtally/__init__.py

--- 2. flat layout, same wheel installed, standing inside the project ---
$ pwd
<repo>/labs/sections/programming-with-python/day-083-packaging-and-distributing-python-code/workspace/flat
$ ls
LICENSE
MANIFEST.in
README.md
pyproject.toml
tests
wordtally
$ ../tryout/bin/python -c 'import wordtally; print(wordtally.__file__)'
<repo>/labs/sections/programming-with-python/day-083-packaging-and-distributing-python-code/workspace/flat/wordtally/__init__.py
The installed copy lost. Your tests would now be testing the working
directory, not the thing your users install.

--- 3. src layout, EDITABLE install ------------------------------------
$ pip install -e .   (run in a separate environment; this step needs a
                       network, because it builds with isolation)
  Building editable for wordtally-tools (pyproject.toml): finished with status 'done'
  Created wheel for wordtally-tools: filename=wordtally_tools-0.3.1-0.editable-py3-none-any.whl size=3253 sha256=8225b6232025b030a80544fe8ef4c5e10e970f2b2ab0bf2d62d7eed9fd064276
Successfully built wordtally-tools
Installing collected packages: wordtally-tools
Successfully installed wordtally-tools-0.3.1
$ ls ../editable/lib/python3.14/site-packages/
__editable__.wordtally_tools-0.3.1.pth
pip
pip-25.2.dist-info
wordtally_tools-0.3.1.dist-info
$ cd <repo>/labs/sections/programming-with-python/day-083-packaging-and-distributing-python-code/workspace/demo && ../editable/bin/python -c 'import wordtally; print(wordtally.__file__)'
<repo>/labs/sections/programming-with-python/day-083-packaging-and-distributing-python-code/workspace/demo/src/wordtally/__init__.py
$ ../editable/bin/wordtally count sample.txt
12

An editable install writes a .pth file that points back at src/. The
package is importable everywhere, and your edits take effect with no
reinstall — which is exactly what you want while developing, and exactly
what you must not confuse with what a user receives.

starter-build.txt

# The starter, built exactly as shipped — before any exercise is done.

$ python3 -m build --no-isolation   (last lines)
creating build/bdist.macosx-26.0-arm64/wheel/wordtally_tools-0.1.0.dist-info/WHEEL
creating '<repo>/labs/sections/programming-with-python/day-083-packaging-and-distributing-python-code/workspace/starter/dist/.tmp-hpcl39vk/wordtally_tools-0.1.0-py3-none-any.whl' and adding 'build/bdist.macosx-26.0-arm64/wheel' to it
adding 'wordtally/__init__.py'
adding 'wordtally/cli.py'
adding 'wordtally/core.py'
adding 'wordtally_tools-0.1.0.dist-info/licenses/LICENSE'
adding 'wordtally_tools-0.1.0.dist-info/METADATA'
adding 'wordtally_tools-0.1.0.dist-info/WHEEL'
adding 'wordtally_tools-0.1.0.dist-info/top_level.txt'
adding 'wordtally_tools-0.1.0.dist-info/RECORD'
removing build/bdist.macosx-26.0-arm64/wheel
Successfully built wordtally_tools-0.1.0.tar.gz and wordtally_tools-0.1.0-py3-none-any.whl

$ ls dist/
wordtally_tools-0.1.0-py3-none-any.whl
wordtally_tools-0.1.0.tar.gz

$ unzip -l dist/wordtally_tools-0.1.0-py3-none-any.whl
Archive:  dist/wordtally_tools-0.1.0-py3-none-any.whl
  Length      Date    Time    Name
---------  ---------- -----   ----
      912  07-19-2026 13:34   wordtally/__init__.py
     2775  07-19-2026 13:34   wordtally/cli.py
     2427  07-19-2026 13:34   wordtally/core.py
     1079  07-19-2026 13:34   wordtally_tools-0.1.0.dist-info/licenses/LICENSE
      103  07-19-2026 13:34   wordtally_tools-0.1.0.dist-info/METADATA
       91  07-19-2026 13:34   wordtally_tools-0.1.0.dist-info/WHEEL
       10  07-19-2026 13:34   wordtally_tools-0.1.0.dist-info/top_level.txt
      658  07-19-2026 13:34   wordtally_tools-0.1.0.dist-info/RECORD
---------                     -------
     8055                     8 files

$ unzip -p dist/wordtally_tools-0.1.0-py3-none-any.whl wordtally_tools-0.1.0.dist-info/METADATA
Metadata-Version: 2.4
Name: wordtally-tools
Version: 0.1.0
License-File: LICENSE
Dynamic: license-file

Compare that with the finished package. The starter's wheel has:
  * a METADATA file of five lines instead of twenty-two;
  * no entry_points.txt, so installing it creates no command;
  * no wordtally/data/stopwords.txt, so 'wordtally top' would raise
    FileNotFoundError the moment somebody installed it elsewhere.
Exercises 1 to 5 close each of those gaps.

test-run.txt

Day 083 — Build a Real Package and Install It

1. The tools
  ok: python -m build --version reports a build ( build 1.5.0 )
  ok: python is 3.10 or newer ( Python 3.14.0 )

2. Building the reference project
  ok: python -m build exits 0
  ok: the build produces exactly two artifacts
  ok: an sdist is produced, and the declared version 0.3.1 is in its name
  ok: a wheel is produced, and the declared version 0.3.1 is in its name
  ok: the wheel carries the pure-Python tag py3-none-any

3. A wheel is a zip — open it and look
  ok: unzip -t accepts the wheel: it is a valid zip archive
  ok: the wheel contains wordtally/__init__.py
  ok: the wheel contains wordtally/core.py
  ok: the wheel contains wordtally/cli.py
  ok: the wheel contains wordtally/data/stopwords.txt
  ok: the wheel contains wordtally_tools-0.3.1.dist-info/METADATA
  ok: the wheel contains wordtally_tools-0.3.1.dist-info/WHEEL
  ok: the wheel contains wordtally_tools-0.3.1.dist-info/RECORD
  ok: the wheel contains wordtally_tools-0.3.1.dist-info/entry_points.txt
  ok: the wheel contains wordtally_tools-0.3.1.dist-info/licenses/LICENSE
  ok: the wheel deliberately omits pyproject.toml
  ok: the wheel deliberately omits MANIFEST.in
  ok: the wheel deliberately omits tests/test_core.py
  ok: the wheel deliberately omits src/wordtally/core.py
  ok: METADATA declares Name: wordtally-tools
  ok: METADATA declares Version: 0.3.1
  ok: METADATA declares Requires-Python: >=3.10
  ok: METADATA declares License-Expression: MIT
  ok: METADATA declares Provides-Extra: dev
  ok: METADATA declares Summary: Count and rank the words in a text file
  ok: the dev extra's dependency is recorded as conditional on the extra
  ok: entry_points.txt declares a console_scripts group
  ok: the console script maps the name wordtally to wordtally.cli:main

4. The sdist carries what the wheel does not
  ok: the sdist contains pyproject.toml
  ok: the sdist contains MANIFEST.in
  ok: the sdist contains README.md
  ok: the sdist contains LICENSE
  ok: the sdist contains PKG-INFO
  ok: the sdist contains tests/test_core.py
  ok: the sdist contains tests/test_cli.py
  ok: the sdist contains src/wordtally/core.py
  ok: the sdist contains src/wordtally/data/stopwords.txt
  ok: pyproject.toml is in the sdist and not in the wheel
  ok: MANIFEST.in is in the sdist and not in the wheel
  ok: tests/test_core.py is in the sdist and not in the wheel
  ok: the sdist preserves the src/ prefix; the wheel has flattened it

5. Install the wheel into a fresh environment
  ok: a fresh virtual environment is created
  ok: the fresh environment cannot import wordtally before installation
  ok: pip install --no-index of the wheel exits 0 (no index was contacted)
  ok: installing the wheel creates an executable named wordtally
  ok: the console script's shebang points at the environment's own python
  ok: the installed command runs: wordtally --version prints 'wordtally 0.3.1', exit 0
  ok: wordtally count sample.txt prints 12 and exits 0
  ok: wordtally top sample.txt -n 2 ranks mat then cat, exit 0
  ok: the packaged data file travelled inside the wheel and loads after install
  ok: the installed command exits 2 on an unreadable file

6. src layout — the import came from the environment, not this directory
  ok: standing in the project, import wordtally resolves to the environment's site-packages
  ok: the resolved path is NOT inside the working directory
  ok: in a FLAT layout the same import silently picks up the working-directory copy
  ok: the two layouts resolve the same import to different files

7. Distribution name versus import name
  ok: pip lists the DISTRIBUTION name wordtally-tools
  ok: the IMPORT name wordtally works after installation
  ok: there is no module called wordtally_tools — the names genuinely differ
  ok: importlib.metadata.version('wordtally-tools') returns 0.3.1 — the version is single-sourced
  ok: wordtally.__version__ agrees, because it reads the same metadata

8. The package's own test suite
  ok: the package's own pytest suite exits 0
  ok: the package's own suite reports 17 passed

9. Missing required metadata makes the build fail
  ok: the edit really removed the name field
  ok: building without a name fails (exit 1, not 0)
  ok: the failure names the missing field rather than failing vaguely
  ok: a failed build produces no artifact for name
  ok: the edit really removed the version field
  ok: building without a version fails (exit 1, not 0)
  ok: the failure names the missing field rather than failing vaguely
  ok: a failed build produces no artifact for version

10. The starter still has work in it
  ok: the starter builds as shipped, so you can begin from a green state
  ok: the starter's artifacts carry its own declared version, 0.1.0
  ok: the starter wheel has no console script yet — exercise 4 is real work
  ok: the starter wheel omits the data file — exercise 5 is real work
  ok: the starter's exercises mention description
  ok: the starter's exercises mention readme
  ok: the starter's exercises mention requires-python
  ok: the starter's exercises mention license
  ok: the starter's exercises mention classifiers
  ok: the starter's exercises mention project.scripts
  ok: the starter's exercises mention package-data

11. Nothing is uploaded, and nothing is fetched
  ok: metadata.yml declares no publishing command anywhere in this lab
  ok: every pip install in this lab passes --no-index, so no index is ever contacted
  ok: the reference project declares an empty runtime dependency list
  ok: no network, clock or randomness in the packaged source

87 checks, 0 failure(s).

Source files

examples/build_and_inspect.sh (3433 bytes)
#!/usr/bin/env bash
# Build wordtally-tools, look inside both artifacts, install the wheel into a
# throwaway environment, and run the installed command.
#
# Run from the LAB directory:
#   bash examples/build_and_inspect.sh
#
# Everything happens under workspace/, which is disposable and is not tracked
# by version control. Nothing is uploaded anywhere: the only `pip install` here
# passes --no-index, which forbids pip from contacting any index at all.
set -eu

lab_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
work="${lab_dir}/workspace"
project="${work}/demo"

# Resolve python and build the same way tests/run_tests.sh does.
python_bin=""
for candidate in "${PYTHON:-}" "${lab_dir}/.venv/bin/python" "$(command -v python3 || true)"; do
  if [ -n "${candidate}" ] && [ -x "${candidate}" ]; then python_bin="${candidate}"; break; fi
done
if [ -z "${python_bin}" ]; then
  echo "python3 not found. Install Python 3.10 or newer and try again." >&2
  exit 1
fi
if ! "${python_bin}" -c "import build" >/dev/null 2>&1; then
  echo "The 'build' package is not installed for ${python_bin}." >&2
  echo "  python3 -m venv .venv" >&2
  echo "  .venv/bin/pip install -r requirements/requirements.txt" >&2
  exit 1
fi

rm -rf "${project}"
mkdir -p "${work}"
cp -R "${lab_dir}/examples/wordtally-tools" "${project}"
cd "${project}"

echo "=== 1. Build both artifacts ================================================"
# --no-isolation reuses the setuptools already installed here instead of
# creating a fresh environment and downloading one. That keeps this script
# offline and repeatable. A real release uses plain `python -m build`.
"${python_bin}" -m build --no-isolation
echo
ls -1 dist/

echo
echo "=== 2. The sdist: a source snapshot ========================================"
tar -tzf dist/wordtally_tools-0.3.1.tar.gz

echo
echo "=== 3. The wheel: a built artifact, and just a zip =========================="
unzip -l dist/wordtally_tools-0.3.1-py3-none-any.whl

echo
echo "=== 4. The metadata the index would display ================================"
unzip -p dist/wordtally_tools-0.3.1-py3-none-any.whl \
  wordtally_tools-0.3.1.dist-info/METADATA | sed -n '1,22p'

echo
echo "=== 5. The console script declaration ======================================"
unzip -p dist/wordtally_tools-0.3.1-py3-none-any.whl \
  wordtally_tools-0.3.1.dist-info/entry_points.txt

echo
echo "=== 6. Install the wheel into a fresh, throwaway environment ==============="
rm -rf "${work}/tryout"
"${python_bin}" -m venv "${work}/tryout"
"${work}/tryout/bin/pip" install --no-index --disable-pip-version-check -q \
  "${project}/dist/wordtally_tools-0.3.1-py3-none-any.whl"
echo "installed:"
"${work}/tryout/bin/pip" list --disable-pip-version-check --format=freeze | grep -i wordtally

echo
echo "=== 7. Run the installed COMMAND ==========================================="
cp "${lab_dir}/examples/sample.txt" "${project}/sample.txt"
"${work}/tryout/bin/wordtally" --version
"${work}/tryout/bin/wordtally" count sample.txt
"${work}/tryout/bin/wordtally" top sample.txt -n 3

echo
echo "=== 8. Where did the import come from? ====================================="
echo "standing in: $(pwd)"
"${work}/tryout/bin/python" -c "import wordtally; print(wordtally.__file__)"
echo "That path is inside the environment, not inside this directory."
echo "src layout is what guarantees it."

echo
echo "Done. Remove everything with:  rm -rf ${work}"
examples/sample.txt (49 bytes)
The cat sat on the mat. The mat was a cat's mat.
examples/wordtally-tools/LICENSE (1079 bytes)
MIT License

Copyright (c) 2026 365 Days of AI Mastery

Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:

The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.

THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
examples/wordtally-tools/MANIFEST.in (810 bytes)
# MANIFEST.in controls the SDIST only.
#
# The sdist is a snapshot of the project as a maintainer would want it: source,
# tests, licence, build instructions. The wheel is a snapshot of the project as
# an INSTALLER wants it: the importable package and its metadata, nothing else.
# That is why `graft tests` below puts the test suite in the sdist and leaves
# it out of the wheel — the wheel never sees this file.
#
# The data file is a different case. It must be in the wheel, because the
# installed package reads it at runtime, so it is declared in pyproject.toml
# under [tool.setuptools.package-data]. Listing it here as well keeps it in the
# sdist even if package-data handling changes.

include MANIFEST.in
graft tests
recursive-include src/wordtally/data *.txt
global-exclude __pycache__ *.py[cod]
examples/wordtally-tools/pyproject.toml (3129 bytes)
# The complete reference pyproject.toml for wordtally-tools.
#
# Three kinds of table live in this one file, and it is worth knowing which is
# which before you read it:
#
#   [build-system]        who builds the package, and what they need. Read by
#                         the build FRONTEND (python -m build, or pip).
#   [project]             the package's metadata. Standardised, backend-neutral.
#   [tool.<name>]         settings for one specific tool. Day 77 used these for
#                         pytest, mypy, Ruff and coverage; here setuptools uses
#                         them too, because src layout and package data are
#                         setuptools' business, not the standard's.

[build-system]
requires = ["setuptools>=68"]
build-backend = "setuptools.build_meta"

[project]
# The DISTRIBUTION name — what you type after `pip install`. It is not the
# import name, which is `wordtally`. See src/wordtally/__init__.py.
name = "wordtally-tools"
version = "0.3.1"
description = "Count and rank the words in a text file, from Python or from the command line."
readme = "README.md"
requires-python = ">=3.10"
license = "MIT"
license-files = ["LICENSE"]
authors = [{ name = "365 Days of AI Mastery" }]
keywords = ["text", "words", "counting", "cli"]
classifiers = [
  "Development Status :: 4 - Beta",
  "Environment :: Console",
  "Intended Audience :: Developers",
  "Operating System :: OS Independent",
  "Programming Language :: Python :: 3",
  "Topic :: Text Processing :: Linguistic",
]
# No runtime dependencies at all: everything this package imports is standard
# library. An empty list is a real, meaningful declaration — it says "installing
# me pulls in nothing else" — so it is written out rather than omitted.
dependencies = []

[project.optional-dependencies]
# Extras. `pip install wordtally-tools[dev]` installs these as well; a plain
# install does not. Test and lint tooling is the classic case: your users do
# not need it, your contributors do.
dev = ["pytest>=8", "build>=1.2"]

[project.scripts]
# The payoff of Day 80. This one line is why `pip install` gives the user a
# COMMAND called `wordtally` and not merely a module they have to remember to
# run with `python -m`. The value is "import path : callable".
wordtally = "wordtally.cli:main"

[project.urls]
# Free-form. The keys are conventional, not standardised; an index renders them
# as links on the project page. Relative documentation paths are used here
# because this package is deliberately never published anywhere.
Documentation = "https://packaging.python.org/en/latest/"

[tool.setuptools.packages.find]
# src layout: the importable code lives under src/, and this tells setuptools
# where to look. Without it, setuptools would search the project root and find
# nothing (or, in a flat layout, would happily package `tests` as well).
where = ["src"]

[tool.setuptools.package-data]
# Data files are NOT included just because they sit inside the package
# directory. Declare them, or the wheel ships code that cannot find its data.
wordtally = ["data/*.txt"]

[tool.pytest.ini_options]
testpaths = ["tests"]
examples/wordtally-tools/README.md (872 bytes)
# wordtally-tools

Count and rank the words in a text file, from Python or from the command line.

This project exists to be built, inspected and installed. It is a teaching
package and is deliberately not published to any index.

## Install

```bash
pip install wordtally-tools
```

The distribution name is `wordtally-tools`; the import name is `wordtally`.

## Use it as a library

```python
from wordtally import count_words, top_words

count_words("the cat sat on the mat")          # 6
top_words("the cat sat on the cat mat", n=2)   # [('cat', 2), ('mat', 1)]
```

## Use it as a command

```bash
wordtally count sample.txt
wordtally top sample.txt -n 5
wordtally --version
```

`wordtally top` filters common words such as `the` and `and` using a stop-word
list shipped inside the package; pass `--keep-stopwords` to turn that off.

## Licence

MIT. See `LICENSE`.
examples/wordtally-tools/src/wordtally/__init__.py (912 bytes)
"""wordtally — count and rank the words in a text file.

The import name is ``wordtally``. The distribution name — what you type after
``pip install`` — is ``wordtally-tools``. They differ deliberately, because in
the real world they differ constantly: ``pip install beautifulsoup4`` gives
you ``import bs4``, which you met on Day 79.

The version is single-sourced. It is written once, in ``pyproject.toml``, and
read back here from the installed metadata. There is no second copy to forget
to update.
"""

from importlib.metadata import PackageNotFoundError, version

from wordtally.core import count_words, load_stopwords, top_words, words

try:
    __version__ = version("wordtally-tools")
except PackageNotFoundError:  # running from a source tree, never installed
    __version__ = "0.0.0.dev0"

__all__ = [
    "__version__",
    "count_words",
    "load_stopwords",
    "top_words",
    "words",
]
examples/wordtally-tools/src/wordtally/cli.py (2775 bytes)
"""The command-line half of ``wordtally-tools``.

``[project.scripts]`` in ``pyproject.toml`` points at :func:`main` here. When
the package is installed, the installer writes a small executable named
``wordtally`` into the environment's ``bin/`` directory whose entire body is
"import this function and ``sys.exit()`` its return value". That is why
:func:`main` returns an ``int`` rather than calling ``sys.exit`` itself: a
function that returns is testable, and Day 80 made that the habit.
"""

from __future__ import annotations

import argparse
import sys
from collections.abc import Sequence

from wordtally import __version__
from wordtally.core import count_words, top_words

__all__ = ["build_parser", "main"]


def build_parser() -> argparse.ArgumentParser:
    """Build the argument parser. Separate from :func:`main` so tests can
    inspect the parser without running anything."""
    parser = argparse.ArgumentParser(
        prog="wordtally",
        description="Count and rank the words in a text file.",
    )
    parser.add_argument(
        "--version",
        action="version",
        version=f"wordtally {__version__}",
    )
    subcommands = parser.add_subparsers(dest="command", required=True)

    count = subcommands.add_parser("count", help="print the number of words")
    count.add_argument("path", help="file to read, or - for standard input")

    top = subcommands.add_parser("top", help="print the most frequent words")
    top.add_argument("path", help="file to read, or - for standard input")
    top.add_argument(
        "-n",
        type=int,
        default=3,
        help="how many words to print (default: 3)",
    )
    top.add_argument(
        "--keep-stopwords",
        action="store_true",
        help="do not filter out common words such as the and and",
    )
    return parser


def _read(path: str) -> str:
    if path == "-":
        return sys.stdin.read()
    with open(path, encoding="utf-8") as handle:
        return handle.read()


def main(argv: Sequence[str] | None = None) -> int:
    """Run the command line. Returns the process exit code."""
    parser = build_parser()
    args = parser.parse_args(argv)

    try:
        text = _read(args.path)
    except OSError as error:
        print(f"wordtally: cannot read {args.path}: {error}", file=sys.stderr)
        return 2

    if args.command == "count":
        print(count_words(text))
        return 0

    try:
        ranked = top_words(text, args.n, skip_stopwords=not args.keep_stopwords)
    except ValueError as error:
        print(f"wordtally: {error}", file=sys.stderr)
        return 2
    for word, count in ranked:
        print(f"{count:>6}  {word}")
    return 0


if __name__ == "__main__":  # pragma: no cover
    raise SystemExit(main())
examples/wordtally-tools/src/wordtally/core.py (2427 bytes)
"""The library half of ``wordtally-tools``.

Three honest utilities over plain text. Nothing here touches the network, the
clock, or a random number, so every result below is reproducible.

Note the packaged data file: :func:`load_stopwords` reads
``wordtally/data/stopwords.txt`` through :mod:`importlib.resources` rather
than by building a path relative to ``__file__``. That matters for packaging.
A file only exists next to the module if the build actually put it in the
wheel, and ``importlib.resources`` is the supported way to ask for it whether
the package was installed from a wheel, installed editable, or is sitting in a
source tree.
"""

from __future__ import annotations

import re
from collections import Counter
from importlib import resources

__all__ = ["words", "count_words", "load_stopwords", "top_words"]

_WORD_RE = re.compile(r"[A-Za-z']+")


def words(text: str) -> list[str]:
    """Split ``text`` into lower-cased words.

    Apostrophes stay inside a word so ``don't`` counts once, not twice.
    """
    return [match.group(0).lower() for match in _WORD_RE.finditer(text)]


def count_words(text: str) -> int:
    """Return how many words ``text`` contains."""
    return len(words(text))


def load_stopwords() -> frozenset[str]:
    """Return the packaged stop-word list.

    Raises ``FileNotFoundError`` if the data file was not shipped with the
    package — which is exactly the failure a wheel built without
    ``package-data`` produces, and exactly why the lab checks for the file
    inside the built wheel.
    """
    data = resources.files("wordtally").joinpath("data/stopwords.txt")
    lines = data.read_text(encoding="utf-8").splitlines()
    return frozenset(line.strip().lower() for line in lines if line.strip())


def top_words(
    text: str,
    n: int = 3,
    *,
    skip_stopwords: bool = True,
) -> list[tuple[str, int]]:
    """Return the ``n`` most frequent words as ``(word, count)`` pairs.

    Ties are broken alphabetically so the result is deterministic. Raises
    ``ValueError`` when ``n`` is not positive.
    """
    if n <= 0:
        raise ValueError(f"n must be positive, got {n}")
    found = words(text)
    if skip_stopwords:
        stopwords = load_stopwords()
        found = [word for word in found if word not in stopwords]
    counts = Counter(found)
    ranked = sorted(counts.items(), key=lambda pair: (-pair[1], pair[0]))
    return ranked[:n]
examples/wordtally-tools/src/wordtally/data/stopwords.txt (132 bytes)
a
an
and
are
as
at
be
but
by
for
from
if
in
into
is
it
its
of
on
or
that
the
their
then
there
these
they
this
to
was
were
will
with
examples/wordtally-tools/tests/test_cli.py (2187 bytes)
"""Tests for the command-line half.

`main` is called directly with an argument list, exactly as Day 80 argued:
a `main(argv) -> int` is a plain function, so testing it needs no subprocess,
no shell, and no installed console script. The lab's harness separately checks
that the INSTALLED console script runs, which is the other half of the claim.
"""

from __future__ import annotations

from pathlib import Path

import pytest

from wordtally.cli import build_parser, main

SAMPLE = "The cat sat on the mat. The mat was a cat's mat."


@pytest.fixture()
def sample_file(tmp_path: Path) -> Path:
    path = tmp_path / "sample.txt"
    path.write_text(SAMPLE, encoding="utf-8")
    return path


def test_parser_knows_both_subcommands() -> None:
    parser = build_parser()
    assert parser.parse_args(["count", "x.txt"]).command == "count"
    assert parser.parse_args(["top", "x.txt"]).n == 3


def test_count_prints_the_number_and_exits_zero(
    sample_file: Path,
    capsys: pytest.CaptureFixture[str],
) -> None:
    assert main(["count", str(sample_file)]) == 0
    assert capsys.readouterr().out.strip() == "12"


def test_top_prints_ranked_words(
    sample_file: Path,
    capsys: pytest.CaptureFixture[str],
) -> None:
    assert main(["top", str(sample_file), "-n", "2"]) == 0
    lines = capsys.readouterr().out.strip().splitlines()
    assert [line.split() for line in lines] == [["3", "mat"], ["1", "cat"]]


def test_a_missing_file_exits_two(
    tmp_path: Path,
    capsys: pytest.CaptureFixture[str],
) -> None:
    assert main(["count", str(tmp_path / "nope.txt")]) == 2
    assert "cannot read" in capsys.readouterr().err


def test_a_non_positive_n_exits_two(
    sample_file: Path,
    capsys: pytest.CaptureFixture[str],
) -> None:
    assert main(["top", str(sample_file), "-n", "0"]) == 2
    assert "must be positive" in capsys.readouterr().err


def test_stdin_is_read_when_the_path_is_a_dash(
    monkeypatch: pytest.MonkeyPatch,
    capsys: pytest.CaptureFixture[str],
) -> None:
    import io
    import sys

    monkeypatch.setattr(sys, "stdin", io.StringIO(SAMPLE))
    assert main(["count", "-"]) == 0
    assert capsys.readouterr().out.strip() == "12"
examples/wordtally-tools/tests/test_core.py (1728 bytes)
"""Tests for the library half.

These tests import `wordtally` by name. They never add `src/` to `sys.path`
and they never reach for a relative file path. That is the src layout doing
its job: if `import wordtally` works while these tests run, it is because the
package was INSTALLED — editable or not — and the tests are therefore testing
the same thing a user gets.
"""

from __future__ import annotations

import pytest

from wordtally.core import count_words, load_stopwords, top_words, words

SAMPLE = "The cat sat on the mat. The mat was a cat's mat."


def test_words_lower_cases_and_keeps_apostrophes() -> None:
    assert words("The Cat's MAT") == ["the", "cat's", "mat"]


def test_words_of_empty_text_is_empty() -> None:
    assert words("") == []


def test_count_words_counts_the_sample() -> None:
    assert count_words(SAMPLE) == 12


def test_stopwords_are_shipped_with_the_package() -> None:
    stopwords = load_stopwords()
    assert "the" in stopwords
    assert "cat" not in stopwords


def test_top_words_skips_stopwords_by_default() -> None:
    assert top_words(SAMPLE, 2) == [("mat", 3), ("cat", 1)]


def test_top_words_can_keep_stopwords() -> None:
    assert top_words(SAMPLE, 2, skip_stopwords=False) == [
        ("mat", 3),
        ("the", 3),
    ]


def test_top_words_breaks_ties_alphabetically() -> None:
    assert top_words("pear apple pear apple fig", 2) == [
        ("apple", 2),
        ("pear", 2),
    ]


def test_top_words_returns_at_most_n_items() -> None:
    assert len(top_words(SAMPLE, 1)) == 1


@pytest.mark.parametrize("bad", [0, -1, -100])
def test_top_words_rejects_a_non_positive_n(bad: int) -> None:
    with pytest.raises(ValueError):
        top_words(SAMPLE, bad)
metadata.yml (2048 bytes)
lesson_id: D083
day: 83
kind: python-program
languages: [python, bash, toml]
setup_commands:
  - cd labs/sections/programming-with-python/day-083-packaging-and-distributing-python-code
  - python3 -m venv .venv
  - .venv/bin/pip install -r requirements/requirements.txt
  - .venv/bin/python -m build --version
run_commands:
  - bash examples/build_and_inspect.sh
  - cd examples/wordtally-tools && python3 -m build --no-isolation
  - cd examples/wordtally-tools && tar -tzf dist/wordtally_tools-0.3.1.tar.gz
  - cd examples/wordtally-tools && unzip -l dist/wordtally_tools-0.3.1-py3-none-any.whl
  - cd examples/wordtally-tools && unzip -p dist/wordtally_tools-0.3.1-py3-none-any.whl wordtally_tools-0.3.1.dist-info/METADATA
  - cd examples/wordtally-tools && unzip -p dist/wordtally_tools-0.3.1-py3-none-any.whl wordtally_tools-0.3.1.dist-info/entry_points.txt
  - python3 -m venv workspace/tryout
  - workspace/tryout/bin/pip install --no-index examples/wordtally-tools/dist/wordtally_tools-0.3.1-py3-none-any.whl
  - workspace/tryout/bin/wordtally --version
  - cd examples/wordtally-tools && ../../workspace/tryout/bin/python -c 'import wordtally; print(wordtally.__file__)'
  - cd starter/wordtally-tools && python3 -m build --no-isolation
test_commands:
  - bash tests/run_tests.sh
cleanup_commands:
  - rm -rf workspace
  - rm -rf examples/wordtally-tools/dist examples/wordtally-tools/build
  - find examples/wordtally-tools/src -type d -name '*.egg-info' -prune -exec rm -rf -- {} +
  - rm -rf starter/wordtally-tools/dist starter/wordtally-tools/build
  - find starter/wordtally-tools/src -type d -name '*.egg-info' -prune -exec rm -rf -- {} +
  - rm -f examples/wordtally-tools/sample.txt
  - 'git checkout -- starter/  # optional: reset your work'
requires_network: true
requires_api_key: false
estimated_minutes: 30
last_executed: '2026-07-19'
executed_on: 'macOS 26.5.1 (Apple Silicon), Python 3.14.0, bash 3.2.57, build 1.5.0, setuptools 83.0.0, pip 25.2, pytest 9.1.1 — bash tests/run_tests.sh -> 87 checks, 0 failure(s), exit 0'
requirements/README.md (4081 bytes)
# Dependencies — Day 083 lab

Three packages. All free, all open source, none needs an account, an API key,
or a paid plan. After the one-time install, everything in this lab runs
completely offline — including the builds and the installs.

```
build==1.5.0
setuptools==83.0.0
pytest==9.1.1
```

| Package | Role | What it does | Licence |
| --- | --- | --- | --- |
| `build` | build **frontend** | `python -m build` reads `[build-system]` from `pyproject.toml`, calls the backend it names, and writes an sdist and a wheel into `dist/`. It builds nothing itself — it is the thing that knows how to ask. | MIT, per the package metadata |
| `setuptools` | build **backend** | The thing that actually turns a source tree into artifacts. Named in this project's `[build-system] requires` and `build-backend`. | MIT, per the package metadata |
| `pytest` | test runner | Runs the packaged project's own suite, and is the runner the lab harness resolves. Introduced on Day 71. | MIT, per the pytest documentation |

The versions above were installed and verified on the authoring machine on
2026-07-19. `python -m build --version` reported `build 1.5.0`, `pip --version`
reported `pip 25.2`, and `pytest --version` reported `pytest 9.1.1`, on
Python 3.14.0.

## Why setuptools is pinned here, when a real project would not pin it

Normally you never install a build backend yourself. `python -m build` creates
a fresh, isolated environment for each build and installs whatever
`[build-system] requires` asks for — usually from an index, over the network.
That isolation is a genuinely good default: it means the build cannot
accidentally depend on something you happen to have installed.

This lab builds with `python3 -m build --no-isolation`, which reuses the
`setuptools` already installed instead of fetching one. Two reasons, both
honest:

- **The lab must run offline.** A test suite that downloads a build backend on
  every run is slow, is flaky on a train, and fails the day an index has a bad
  afternoon.
- **The result is then deterministic.** Every learner builds with the same
  backend version, so the captured output in `expected-output/` is something
  you can actually compare against.

A real release uses plain `python -m build`. The lesson says so, and the
lab README shows both commands side by side.

## Deliberately not installed

- **twine** — the standard upload client. It is described in the lesson,
  its commands are shown, and it is never run here, because **this lab
  uploads nothing to any index**. Installing an upload tool into a teaching
  environment invites an accident that cannot be undone: a version number
  published to an index can never be reused.
- **hatch**, **flit**, **poetry** and **uv** — all free and open source, all
  covered in the lesson's Alternatives section. None is installed on the
  authoring machine, so the lesson describes them from their documented
  behaviour and quotes no output for them and no benchmark numbers. Where a
  claim could not be checked here, the lesson says so.

## One-time install

```bash
cd labs/sections/programming-with-python/day-083-packaging-and-distributing-python-code
python3 -m venv .venv
.venv/bin/pip install -r requirements/requirements.txt
.venv/bin/python -m build --version
.venv/bin/pytest --version
```

You created a virtual environment for the first time on Day 43; this is the
same procedure. `.venv/` is ignored by version control and never committed.

`tests/run_tests.sh` finds these tools automatically: an explicit override
first (`PYTHON=/path/to/python3 bash tests/run_tests.sh`), then this lab's
`.venv/bin/`, then whatever is on your `PATH`. If a tool is missing the script
stops with install instructions rather than skipping the check.

## Windows

Run everything inside WSL and follow the Linux path. `run_tests.sh` and
`build_and_inspect.sh` are bash scripts, the environment layout differs on
native Windows (`.venv\Scripts\` rather than `.venv/bin/`), and the console
script an installer creates there is `wordtally.exe` rather than a shebang
script.
requirements/requirements.txt (46 bytes)
build==1.5.0
setuptools==83.0.0
pytest==9.1.1
starter/wordtally-tools/LICENSE (1079 bytes)
MIT License

Copyright (c) 2026 365 Days of AI Mastery

Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:

The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.

THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
starter/wordtally-tools/pyproject.toml (5267 bytes)
# STARTER pyproject.toml — your work happens in this file.
#
# As shipped this is the smallest thing that builds: a build system, a name and
# a version. It produces a wheel. That wheel is also nearly useless — it has no
# summary, no licence, no console script, and it silently leaves the stop-word
# list behind. The exercises below fix that, one field at a time.
#
# Run `bash tests/run_tests.sh` from the lab directory at any point; the
# harness tells you which exercises are still outstanding.
#
# Compare with examples/wordtally-tools/pyproject.toml only after you have
# tried each exercise — that file is the finished version.

[build-system]
requires = ["setuptools>=68"]
build-backend = "setuptools.build_meta"

[project]
name = "wordtally-tools"
version = "0.1.0"

# --- Exercise 1: describe the package -------------------------------------
# Add these five keys inside the [project] table above, then rebuild and read
# the METADATA file inside the wheel to see each one appear:
#
#   description = "Count and rank the words in a text file, from Python or from the command line."
#   readme = "README.md"          # you will need to write starter/wordtally-tools/README.md
#   requires-python = ">=3.10"
#   license = "MIT"
#   license-files = ["LICENSE"]
#
# Inspect with:
#   unzip -p dist/wordtally_tools-0.1.0-py3-none-any.whl \
#     wordtally_tools-0.1.0.dist-info/METADATA | head -20

# --- Exercise 2: identify yourself and classify the project ---------------
# Add an authors list and at least three classifiers. Classifiers come from a
# fixed, published list; inventing one makes an index reject the upload.
#
#   authors = [{ name = "Your Name" }]
#   classifiers = [
#     "Environment :: Console",
#     "Operating System :: OS Independent",
#     "Programming Language :: Python :: 3",
#   ]

# --- Exercise 3: declare dependencies -------------------------------------
# This package imports nothing outside the standard library, so its runtime
# dependency list is genuinely empty — write it out anyway, because an empty
# list is a claim and a missing key is a shrug:
#
#   dependencies = []
#
# Then add an optional group for the tools a contributor needs but a user does
# not, installed with `pip install wordtally-tools[dev]`:
#
#   [project.optional-dependencies]
#   dev = ["pytest>=8", "build>=1.2"]

# --- Exercise 4: turn the package into a COMMAND --------------------------
# Add this table. It is the single line that makes `pip install` put an
# executable called `wordtally` on the user's PATH:
#
#   [project.scripts]
#   wordtally = "wordtally.cli:main"
#
# Verify it landed in the wheel:
#   unzip -p dist/wordtally_tools-0.1.0-py3-none-any.whl \
#     wordtally_tools-0.1.0.dist-info/entry_points.txt

# --- Exercise 5: find the source, and ship the data -----------------------
# The importable code lives under src/. Tell setuptools where to look, and
# tell it to include the stop-word list — without the second table the wheel
# contains code that raises FileNotFoundError the first time it needs its own
# data file.
#
#   [tool.setuptools.packages.find]
#   where = ["src"]
#
#   [tool.setuptools.package-data]
#   wordtally = ["data/*.txt"]
#
# Check with:
#   unzip -l dist/wordtally_tools-0.1.0-py3-none-any.whl | grep stopwords

# --- Exercise 6: build, and look inside both artifacts --------------------
#   cd starter/wordtally-tools
#   python3 -m build --no-isolation
#   ls dist/
#   tar -tzf dist/wordtally_tools-0.1.0.tar.gz
#   unzip -l dist/wordtally_tools-0.1.0-py3-none-any.whl
#
# Write down one file that is in the sdist and not in the wheel, and say why.

# --- Exercise 7: install it somewhere else, and run it --------------------
#   cd ../..                       # back to the lab directory
#   mkdir -p workspace
#   python3 -m venv workspace/my-tryout
#   workspace/my-tryout/bin/pip install --no-index \
#     starter/wordtally-tools/dist/wordtally_tools-0.1.0-py3-none-any.whl
#   workspace/my-tryout/bin/wordtally --version
#
# The --no-index flag proves the point: nothing was fetched from anywhere.

# --- Exercise 8: prove the src layout is doing its job -------------------
#   cd starter/wordtally-tools
#   ../../workspace/my-tryout/bin/python -c "import wordtally; print(wordtally.__file__)"
#
# The path printed must be inside workspace/my-tryout/lib/.../site-packages/.
# You are standing in the project directory and Python still imported the
# INSTALLED copy, because there is no importable `wordtally` in the working
# directory — only `src/wordtally`. That is the whole argument for src layout.

# --- Exercise 9: break it on purpose --------------------------------------
# Delete the `version` line, run `python3 -m build --no-isolation`, and read
# the error. Then put it back. A build that fails loudly on missing metadata
# is a feature: the alternative is a nameless artifact nobody can install.

# --- Exercise 10: release a new version -----------------------------------
# Change version to "0.2.0", rebuild, and note that the FILENAMES change.
# The version is part of the artifact's identity, which is why a published
# version can never be reused — you fix a bad release by publishing a new
# version, never by quietly replacing the old one.
starter/wordtally-tools/src/wordtally/__init__.py (912 bytes)
"""wordtally — count and rank the words in a text file.

The import name is ``wordtally``. The distribution name — what you type after
``pip install`` — is ``wordtally-tools``. They differ deliberately, because in
the real world they differ constantly: ``pip install beautifulsoup4`` gives
you ``import bs4``, which you met on Day 79.

The version is single-sourced. It is written once, in ``pyproject.toml``, and
read back here from the installed metadata. There is no second copy to forget
to update.
"""

from importlib.metadata import PackageNotFoundError, version

from wordtally.core import count_words, load_stopwords, top_words, words

try:
    __version__ = version("wordtally-tools")
except PackageNotFoundError:  # running from a source tree, never installed
    __version__ = "0.0.0.dev0"

__all__ = [
    "__version__",
    "count_words",
    "load_stopwords",
    "top_words",
    "words",
]
starter/wordtally-tools/src/wordtally/cli.py (2775 bytes)
"""The command-line half of ``wordtally-tools``.

``[project.scripts]`` in ``pyproject.toml`` points at :func:`main` here. When
the package is installed, the installer writes a small executable named
``wordtally`` into the environment's ``bin/`` directory whose entire body is
"import this function and ``sys.exit()`` its return value". That is why
:func:`main` returns an ``int`` rather than calling ``sys.exit`` itself: a
function that returns is testable, and Day 80 made that the habit.
"""

from __future__ import annotations

import argparse
import sys
from collections.abc import Sequence

from wordtally import __version__
from wordtally.core import count_words, top_words

__all__ = ["build_parser", "main"]


def build_parser() -> argparse.ArgumentParser:
    """Build the argument parser. Separate from :func:`main` so tests can
    inspect the parser without running anything."""
    parser = argparse.ArgumentParser(
        prog="wordtally",
        description="Count and rank the words in a text file.",
    )
    parser.add_argument(
        "--version",
        action="version",
        version=f"wordtally {__version__}",
    )
    subcommands = parser.add_subparsers(dest="command", required=True)

    count = subcommands.add_parser("count", help="print the number of words")
    count.add_argument("path", help="file to read, or - for standard input")

    top = subcommands.add_parser("top", help="print the most frequent words")
    top.add_argument("path", help="file to read, or - for standard input")
    top.add_argument(
        "-n",
        type=int,
        default=3,
        help="how many words to print (default: 3)",
    )
    top.add_argument(
        "--keep-stopwords",
        action="store_true",
        help="do not filter out common words such as the and and",
    )
    return parser


def _read(path: str) -> str:
    if path == "-":
        return sys.stdin.read()
    with open(path, encoding="utf-8") as handle:
        return handle.read()


def main(argv: Sequence[str] | None = None) -> int:
    """Run the command line. Returns the process exit code."""
    parser = build_parser()
    args = parser.parse_args(argv)

    try:
        text = _read(args.path)
    except OSError as error:
        print(f"wordtally: cannot read {args.path}: {error}", file=sys.stderr)
        return 2

    if args.command == "count":
        print(count_words(text))
        return 0

    try:
        ranked = top_words(text, args.n, skip_stopwords=not args.keep_stopwords)
    except ValueError as error:
        print(f"wordtally: {error}", file=sys.stderr)
        return 2
    for word, count in ranked:
        print(f"{count:>6}  {word}")
    return 0


if __name__ == "__main__":  # pragma: no cover
    raise SystemExit(main())
starter/wordtally-tools/src/wordtally/core.py (2427 bytes)
"""The library half of ``wordtally-tools``.

Three honest utilities over plain text. Nothing here touches the network, the
clock, or a random number, so every result below is reproducible.

Note the packaged data file: :func:`load_stopwords` reads
``wordtally/data/stopwords.txt`` through :mod:`importlib.resources` rather
than by building a path relative to ``__file__``. That matters for packaging.
A file only exists next to the module if the build actually put it in the
wheel, and ``importlib.resources`` is the supported way to ask for it whether
the package was installed from a wheel, installed editable, or is sitting in a
source tree.
"""

from __future__ import annotations

import re
from collections import Counter
from importlib import resources

__all__ = ["words", "count_words", "load_stopwords", "top_words"]

_WORD_RE = re.compile(r"[A-Za-z']+")


def words(text: str) -> list[str]:
    """Split ``text`` into lower-cased words.

    Apostrophes stay inside a word so ``don't`` counts once, not twice.
    """
    return [match.group(0).lower() for match in _WORD_RE.finditer(text)]


def count_words(text: str) -> int:
    """Return how many words ``text`` contains."""
    return len(words(text))


def load_stopwords() -> frozenset[str]:
    """Return the packaged stop-word list.

    Raises ``FileNotFoundError`` if the data file was not shipped with the
    package — which is exactly the failure a wheel built without
    ``package-data`` produces, and exactly why the lab checks for the file
    inside the built wheel.
    """
    data = resources.files("wordtally").joinpath("data/stopwords.txt")
    lines = data.read_text(encoding="utf-8").splitlines()
    return frozenset(line.strip().lower() for line in lines if line.strip())


def top_words(
    text: str,
    n: int = 3,
    *,
    skip_stopwords: bool = True,
) -> list[tuple[str, int]]:
    """Return the ``n`` most frequent words as ``(word, count)`` pairs.

    Ties are broken alphabetically so the result is deterministic. Raises
    ``ValueError`` when ``n`` is not positive.
    """
    if n <= 0:
        raise ValueError(f"n must be positive, got {n}")
    found = words(text)
    if skip_stopwords:
        stopwords = load_stopwords()
        found = [word for word in found if word not in stopwords]
    counts = Counter(found)
    ranked = sorted(counts.items(), key=lambda pair: (-pair[1], pair[0]))
    return ranked[:n]
starter/wordtally-tools/src/wordtally/data/stopwords.txt (132 bytes)
a
an
and
are
as
at
be
but
by
for
from
if
in
into
is
it
its
of
on
or
that
the
their
then
there
these
they
this
to
was
were
will
with
starter/wordtally-tools/tests/test_cli.py (2187 bytes)
"""Tests for the command-line half.

`main` is called directly with an argument list, exactly as Day 80 argued:
a `main(argv) -> int` is a plain function, so testing it needs no subprocess,
no shell, and no installed console script. The lab's harness separately checks
that the INSTALLED console script runs, which is the other half of the claim.
"""

from __future__ import annotations

from pathlib import Path

import pytest

from wordtally.cli import build_parser, main

SAMPLE = "The cat sat on the mat. The mat was a cat's mat."


@pytest.fixture()
def sample_file(tmp_path: Path) -> Path:
    path = tmp_path / "sample.txt"
    path.write_text(SAMPLE, encoding="utf-8")
    return path


def test_parser_knows_both_subcommands() -> None:
    parser = build_parser()
    assert parser.parse_args(["count", "x.txt"]).command == "count"
    assert parser.parse_args(["top", "x.txt"]).n == 3


def test_count_prints_the_number_and_exits_zero(
    sample_file: Path,
    capsys: pytest.CaptureFixture[str],
) -> None:
    assert main(["count", str(sample_file)]) == 0
    assert capsys.readouterr().out.strip() == "12"


def test_top_prints_ranked_words(
    sample_file: Path,
    capsys: pytest.CaptureFixture[str],
) -> None:
    assert main(["top", str(sample_file), "-n", "2"]) == 0
    lines = capsys.readouterr().out.strip().splitlines()
    assert [line.split() for line in lines] == [["3", "mat"], ["1", "cat"]]


def test_a_missing_file_exits_two(
    tmp_path: Path,
    capsys: pytest.CaptureFixture[str],
) -> None:
    assert main(["count", str(tmp_path / "nope.txt")]) == 2
    assert "cannot read" in capsys.readouterr().err


def test_a_non_positive_n_exits_two(
    sample_file: Path,
    capsys: pytest.CaptureFixture[str],
) -> None:
    assert main(["top", str(sample_file), "-n", "0"]) == 2
    assert "must be positive" in capsys.readouterr().err


def test_stdin_is_read_when_the_path_is_a_dash(
    monkeypatch: pytest.MonkeyPatch,
    capsys: pytest.CaptureFixture[str],
) -> None:
    import io
    import sys

    monkeypatch.setattr(sys, "stdin", io.StringIO(SAMPLE))
    assert main(["count", "-"]) == 0
    assert capsys.readouterr().out.strip() == "12"
starter/wordtally-tools/tests/test_core.py (1728 bytes)
"""Tests for the library half.

These tests import `wordtally` by name. They never add `src/` to `sys.path`
and they never reach for a relative file path. That is the src layout doing
its job: if `import wordtally` works while these tests run, it is because the
package was INSTALLED — editable or not — and the tests are therefore testing
the same thing a user gets.
"""

from __future__ import annotations

import pytest

from wordtally.core import count_words, load_stopwords, top_words, words

SAMPLE = "The cat sat on the mat. The mat was a cat's mat."


def test_words_lower_cases_and_keeps_apostrophes() -> None:
    assert words("The Cat's MAT") == ["the", "cat's", "mat"]


def test_words_of_empty_text_is_empty() -> None:
    assert words("") == []


def test_count_words_counts_the_sample() -> None:
    assert count_words(SAMPLE) == 12


def test_stopwords_are_shipped_with_the_package() -> None:
    stopwords = load_stopwords()
    assert "the" in stopwords
    assert "cat" not in stopwords


def test_top_words_skips_stopwords_by_default() -> None:
    assert top_words(SAMPLE, 2) == [("mat", 3), ("cat", 1)]


def test_top_words_can_keep_stopwords() -> None:
    assert top_words(SAMPLE, 2, skip_stopwords=False) == [
        ("mat", 3),
        ("the", 3),
    ]


def test_top_words_breaks_ties_alphabetically() -> None:
    assert top_words("pear apple pear apple fig", 2) == [
        ("apple", 2),
        ("pear", 2),
    ]


def test_top_words_returns_at_most_n_items() -> None:
    assert len(top_words(SAMPLE, 1)) == 1


@pytest.mark.parametrize("bad", [0, -1, -100])
def test_top_words_rejects_a_non_positive_n(bad: int) -> None:
    with pytest.raises(ValueError):
        top_words(SAMPLE, bad)
tests/run_tests.sh (25373 bytes)
#!/usr/bin/env bash
# Tests for the Day 083 lab. Run from the lab directory:
#   bash tests/run_tests.sh
#
# What this suite proves, in order of how much it is worth:
#
#   * the INSTALLED package imports from the environment's site-packages and
#     not from the working directory, even while you are standing inside the
#     project. A flat-layout copy of the same project is built alongside it and
#     shown to do the opposite. That contrast is the entire argument for the
#     src layout, and it is the most valuable check here;
#   * a wheel is a zip: it is opened with unzip and its contents are asserted
#     file by file, including the metadata directory and the console-script
#     declaration;
#   * the sdist carries files the wheel deliberately omits;
#   * installing the wheel into a fresh environment produces a COMMAND that
#     runs and exits 0;
#   * the declared version appears in both artifact filenames;
#   * removing one required metadata field makes the build fail loudly.
#
# NOTHING IS UPLOADED ANYWHERE. Every install below passes --no-index, which
# forbids pip from contacting any package index, and the last section of this
# file checks that claim mechanically.
#
# Non-interactive and deterministic. Exits 0 only if every check passes.
set -u

export PYTHONDONTWRITEBYTECODE=1

lab_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
work="${lab_dir}/workspace"
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
}

# Resolve python: an explicit override, then this lab's .venv, then PATH.
# Fails loudly with instructions rather than silently skipping.
resolve_python() {
  if [ -n "${PYTHON:-}" ] && [ -x "${PYTHON}" ]; then echo "${PYTHON}"; return 0; fi
  if [ -x "${lab_dir}/.venv/bin/python" ]; then echo "${lab_dir}/.venv/bin/python"; return 0; fi
  if command -v python3 >/dev/null 2>&1; then command -v python3; return 0; fi
  return 1
}

resolve_tool() {
  local tool="$1" override="$2"
  if [ -n "${override}" ] && [ -x "${override}" ]; then echo "${override}"; return 0; fi
  if [ -x "${lab_dir}/.venv/bin/${tool}" ]; then echo "${lab_dir}/.venv/bin/${tool}"; return 0; fi
  if command -v "${tool}" >/dev/null 2>&1; then command -v "${tool}"; return 0; fi
  return 1
}

python_bin="$(resolve_python)" || {
  echo "FAIL: python3 not found." >&2
  echo "  Install Python 3.10 or newer, or point this suite at one:" >&2
  echo "    PYTHON=/path/to/python3 bash tests/run_tests.sh" >&2
  exit 1
}

pytest_bin="$(resolve_tool pytest "${PYTEST:-}")" || {
  echo "FAIL: pytest not found." >&2
  echo "  Install it with:" >&2
  echo "    python3 -m venv .venv" >&2
  echo "    .venv/bin/pip install -r requirements/requirements.txt" >&2
  echo "  Or point this suite at an existing pytest: PYTEST=/path/to/pytest bash tests/run_tests.sh" >&2
  exit 1
}

if ! "${python_bin}" -c "import build" >/dev/null 2>&1; then
  echo "FAIL: the 'build' package is not installed for ${python_bin}." >&2
  echo "    python3 -m venv .venv" >&2
  echo "    .venv/bin/pip install -r requirements/requirements.txt" >&2
  exit 1
fi

if ! "${python_bin}" -c "import setuptools" >/dev/null 2>&1; then
  echo "FAIL: setuptools is not installed for ${python_bin}." >&2
  echo "  This lab builds with --no-isolation so that it never needs a network." >&2
  echo "    .venv/bin/pip install -r requirements/requirements.txt" >&2
  exit 1
fi

for tool in unzip tar; do
  if ! command -v "${tool}" >/dev/null 2>&1; then
    echo "FAIL: ${tool} not found on PATH; this suite opens the built artifacts with it." >&2
    exit 1
  fi
done

echo "Day 083 — Build a Real Package and Install It"
echo

rm -rf "${work}"
mkdir -p "${work}"
trap 'rm -rf "${work}"' EXIT

# --------------------------------------------------------------------------
echo "1. The tools"
# --------------------------------------------------------------------------

build_version="$("${python_bin}" -m build --version 2>&1 | head -1)"
case "${build_version}" in
  build\ *) check "python -m build --version reports a build ( ${build_version%% (*} )" "yes" ;;
  *) check "python -m build --version reports a build ( ${build_version} )" "no" ;;
esac

py_version="$("${python_bin}" --version 2>&1)"
case "${py_version}" in
  Python\ 3.1[0-9]*) check "python is 3.10 or newer ( ${py_version} )" "yes" ;;
  *) check "python is 3.10 or newer ( ${py_version} )" "no" ;;
esac

# --------------------------------------------------------------------------
echo
echo "2. Building the reference project"
# --------------------------------------------------------------------------

project="${work}/demo"
cp -R "${lab_dir}/examples/wordtally-tools" "${project}"
build_log="${work}/build.log"
(cd "${project}" && "${python_bin}" -m build --no-isolation) >"${build_log}" 2>&1
build_exit=$?
if [ "${build_exit}" -eq 0 ]; then
  check "python -m build exits 0" "yes"
else
  check "python -m build exits 0 (got ${build_exit})" "no"
  tail -20 "${build_log}"
fi

sdist="${project}/dist/wordtally_tools-0.3.1.tar.gz"
wheel="${project}/dist/wordtally_tools-0.3.1-py3-none-any.whl"

artifact_count="$(ls -1 "${project}/dist" 2>/dev/null | wc -l | tr -d ' ')"
if [ "${artifact_count}" = "2" ]; then
  check "the build produces exactly two artifacts" "yes"
else
  check "the build produces exactly two artifacts (got ${artifact_count})" "no"
fi

[ -f "${sdist}" ] && check "an sdist is produced, and the declared version 0.3.1 is in its name" "yes" \
                 || check "an sdist is produced, and the declared version 0.3.1 is in its name" "no"
[ -f "${wheel}" ] && check "a wheel is produced, and the declared version 0.3.1 is in its name" "yes" \
                 || check "a wheel is produced, and the declared version 0.3.1 is in its name" "no"

# py3-none-any is the compatibility tag of a pure-Python wheel: any Python 3,
# no ABI requirement, any platform. A compiled project would say something far
# more specific here.
case "${wheel}" in
  *-py3-none-any.whl) check "the wheel carries the pure-Python tag py3-none-any" "yes" ;;
  *) check "the wheel carries the pure-Python tag py3-none-any" "no" ;;
esac

# --------------------------------------------------------------------------
echo
echo "3. A wheel is a zip — open it and look"
# --------------------------------------------------------------------------

if unzip -t "${wheel}" >/dev/null 2>&1; then
  check "unzip -t accepts the wheel: it is a valid zip archive" "yes"
else
  check "unzip -t accepts the wheel: it is a valid zip archive" "no"
fi

wheel_list="$(unzip -Z1 "${wheel}" 2>/dev/null)"

for entry in \
  "wordtally/__init__.py" \
  "wordtally/core.py" \
  "wordtally/cli.py" \
  "wordtally/data/stopwords.txt" \
  "wordtally_tools-0.3.1.dist-info/METADATA" \
  "wordtally_tools-0.3.1.dist-info/WHEEL" \
  "wordtally_tools-0.3.1.dist-info/RECORD" \
  "wordtally_tools-0.3.1.dist-info/entry_points.txt" \
  "wordtally_tools-0.3.1.dist-info/licenses/LICENSE"
do
  if printf '%s\n' "${wheel_list}" | grep -qx "${entry}"; then
    check "the wheel contains ${entry}" "yes"
  else
    check "the wheel contains ${entry}" "no"
  fi
done

# The wheel is an INSTALL image. It carries no build instructions and no tests.
for absent in "pyproject.toml" "MANIFEST.in" "tests/test_core.py" "src/wordtally/core.py"
do
  if printf '%s\n' "${wheel_list}" | grep -qx "${absent}"; then
    check "the wheel deliberately omits ${absent}" "no"
  else
    check "the wheel deliberately omits ${absent}" "yes"
  fi
done

metadata="$(unzip -p "${wheel}" wordtally_tools-0.3.1.dist-info/METADATA 2>/dev/null)"
for line in \
  "Name: wordtally-tools" \
  "Version: 0.3.1" \
  "Requires-Python: >=3.10" \
  "License-Expression: MIT" \
  "Provides-Extra: dev" \
  "Summary: Count and rank the words in a text file"
do
  if printf '%s\n' "${metadata}" | grep -q "^${line}"; then
    check "METADATA declares ${line}" "yes"
  else
    check "METADATA declares ${line}" "no"
  fi
done

# The extra's dependency is recorded conditionally: a plain install skips it.
if printf '%s\n' "${metadata}" | grep -q 'Requires-Dist: pytest>=8; extra == "dev"'; then
  check "the dev extra's dependency is recorded as conditional on the extra" "yes"
else
  check "the dev extra's dependency is recorded as conditional on the extra" "no"
fi

entry_points="$(unzip -p "${wheel}" wordtally_tools-0.3.1.dist-info/entry_points.txt 2>/dev/null)"
if printf '%s\n' "${entry_points}" | grep -q '^\[console_scripts\]'; then
  check "entry_points.txt declares a console_scripts group" "yes"
else
  check "entry_points.txt declares a console_scripts group" "no"
fi
if printf '%s\n' "${entry_points}" | grep -q '^wordtally = wordtally.cli:main$'; then
  check "the console script maps the name wordtally to wordtally.cli:main" "yes"
else
  check "the console script maps the name wordtally to wordtally.cli:main" "no"
fi

# --------------------------------------------------------------------------
echo
echo "4. The sdist carries what the wheel does not"
# --------------------------------------------------------------------------

sdist_list="$(tar -tzf "${sdist}" 2>/dev/null)"

for entry in \
  "wordtally_tools-0.3.1/pyproject.toml" \
  "wordtally_tools-0.3.1/MANIFEST.in" \
  "wordtally_tools-0.3.1/README.md" \
  "wordtally_tools-0.3.1/LICENSE" \
  "wordtally_tools-0.3.1/PKG-INFO" \
  "wordtally_tools-0.3.1/tests/test_core.py" \
  "wordtally_tools-0.3.1/tests/test_cli.py" \
  "wordtally_tools-0.3.1/src/wordtally/core.py" \
  "wordtally_tools-0.3.1/src/wordtally/data/stopwords.txt"
do
  if printf '%s\n' "${sdist_list}" | grep -qx "${entry}"; then
    check "the sdist contains ${entry#wordtally_tools-0.3.1/}" "yes"
  else
    check "the sdist contains ${entry#wordtally_tools-0.3.1/}" "no"
  fi
done

# The three files the wheel does not have, named explicitly, because this is
# the difference the lesson is about.
for entry in "pyproject.toml" "MANIFEST.in" "tests/test_core.py"; do
  in_sdist="no"; in_wheel="no"
  printf '%s\n' "${sdist_list}" | grep -qx "wordtally_tools-0.3.1/${entry}" && in_sdist="yes"
  printf '%s\n' "${wheel_list}" | grep -qx "${entry}" && in_wheel="yes"
  if [ "${in_sdist}" = "yes" ] && [ "${in_wheel}" = "no" ]; then
    check "${entry} is in the sdist and not in the wheel" "yes"
  else
    check "${entry} is in the sdist and not in the wheel" "no"
  fi
done

# The sdist keeps the src/ prefix; the wheel has already flattened it away,
# because a wheel is unpacked straight into site-packages.
if printf '%s\n' "${sdist_list}" | grep -q '^wordtally_tools-0.3.1/src/wordtally/' \
   && ! printf '%s\n' "${wheel_list}" | grep -q '^src/'; then
  check "the sdist preserves the src/ prefix; the wheel has flattened it" "yes"
else
  check "the sdist preserves the src/ prefix; the wheel has flattened it" "no"
fi

# --------------------------------------------------------------------------
echo
echo "5. Install the wheel into a fresh environment"
# --------------------------------------------------------------------------

tryout="${work}/tryout"
"${python_bin}" -m venv "${tryout}" >/dev/null 2>&1
venv_exit=$?
if [ "${venv_exit}" -eq 0 ] && [ -x "${tryout}/bin/python" ]; then
  check "a fresh virtual environment is created" "yes"
else
  check "a fresh virtual environment is created (exit ${venv_exit})" "no"
fi

# Before installing, the environment knows nothing about this package.
if "${tryout}/bin/python" -c "import wordtally" >/dev/null 2>&1; then
  check "the fresh environment cannot import wordtally before installation" "no"
else
  check "the fresh environment cannot import wordtally before installation" "yes"
fi

# --no-index forbids pip from contacting any package index. The install is
# purely local, and it is offline by construction.
install_log="${work}/install.log"
"${tryout}/bin/pip" install --no-index --disable-pip-version-check -q "${wheel}" \
  >"${install_log}" 2>&1
install_exit=$?
if [ "${install_exit}" -eq 0 ]; then
  check "pip install --no-index of the wheel exits 0 (no index was contacted)" "yes"
else
  check "pip install --no-index of the wheel exits 0 (got ${install_exit})" "no"
  tail -20 "${install_log}"
fi

if [ -x "${tryout}/bin/wordtally" ]; then
  check "installing the wheel creates an executable named wordtally" "yes"
else
  check "installing the wheel creates an executable named wordtally" "no"
fi

# The generated launcher's first line points at the environment's interpreter,
# which is how a console script finds the right Python without a PATH game.
shebang="$(head -1 "${tryout}/bin/wordtally" 2>/dev/null)"
case "${shebang}" in
  \#\!*"/tryout/bin/python"*) check "the console script's shebang points at the environment's own python" "yes" ;;
  *) check "the console script's shebang points at the environment's own python (got ${shebang})" "no" ;;
esac

version_out="$("${tryout}/bin/wordtally" --version 2>&1)"
version_exit=$?
if [ "${version_exit}" -eq 0 ] && [ "${version_out}" = "wordtally 0.3.1" ]; then
  check "the installed command runs: wordtally --version prints 'wordtally 0.3.1', exit 0" "yes"
else
  check "the installed command runs: wordtally --version (got '${version_out}', exit ${version_exit})" "no"
fi

cp "${lab_dir}/examples/sample.txt" "${project}/sample.txt"
count_out="$(cd "${project}" && "${tryout}/bin/wordtally" count sample.txt 2>&1)"
count_exit=$?
if [ "${count_exit}" -eq 0 ] && [ "${count_out}" = "12" ]; then
  check "wordtally count sample.txt prints 12 and exits 0" "yes"
else
  check "wordtally count sample.txt prints 12 and exits 0 (got '${count_out}', exit ${count_exit})" "no"
fi

top_out="$(cd "${project}" && "${tryout}/bin/wordtally" top sample.txt -n 2 2>&1)"
top_exit=$?
expected_top="$(printf '     3  mat\n     1  cat')"
if [ "${top_exit}" -eq 0 ] && [ "${top_out}" = "${expected_top}" ]; then
  check "wordtally top sample.txt -n 2 ranks mat then cat, exit 0" "yes"
else
  check "wordtally top sample.txt -n 2 ranks mat then cat (got '${top_out}', exit ${top_exit})" "no"
fi

# `top` only works if the stop-word list was actually shipped inside the wheel.
if "${tryout}/bin/python" -c "from wordtally.core import load_stopwords; assert 'the' in load_stopwords()" >/dev/null 2>&1; then
  check "the packaged data file travelled inside the wheel and loads after install" "yes"
else
  check "the packaged data file travelled inside the wheel and loads after install" "no"
fi

# A bad exit code is as much a part of the interface as a good one.
missing_exit=0
(cd "${project}" && "${tryout}/bin/wordtally" count no-such-file.txt >/dev/null 2>&1) || missing_exit=$?
if [ "${missing_exit}" -eq 2 ]; then
  check "the installed command exits 2 on an unreadable file" "yes"
else
  check "the installed command exits 2 on an unreadable file (got ${missing_exit})" "no"
fi

# --------------------------------------------------------------------------
echo
echo "6. src layout — the import came from the environment, not this directory"
# --------------------------------------------------------------------------

# Standing INSIDE the project, ask the installed interpreter where the package
# is. This is the check the whole lab exists for.
where="$(cd "${project}" && "${tryout}/bin/python" -c 'import wordtally; print(wordtally.__file__)' 2>&1)"
case "${where}" in
  "${tryout}"/lib/*/site-packages/wordtally/__init__.py)
    check "standing in the project, import wordtally resolves to the environment's site-packages" "yes" ;;
  *)
    check "standing in the project, import wordtally resolves to site-packages (got ${where})" "no" ;;
esac
case "${where}" in
  *"${project}"*)
    check "the resolved path is NOT inside the working directory" "no" ;;
  *)
    check "the resolved path is NOT inside the working directory" "yes" ;;
esac

# The contrast. Same code, same install, flat layout: the working-directory
# copy wins, and your tests stop testing what your users get.
flat="${work}/flat"
cp -R "${lab_dir}/examples/wordtally-tools" "${flat}"
mv "${flat}/src/wordtally" "${flat}/wordtally"
rmdir "${flat}/src"
flat_where="$(cd "${flat}" && "${tryout}/bin/python" -c 'import wordtally; print(wordtally.__file__)' 2>&1)"
case "${flat_where}" in
  "${flat}"/wordtally/__init__.py)
    check "in a FLAT layout the same import silently picks up the working-directory copy" "yes" ;;
  *)
    check "in a FLAT layout the same import picks up the working-directory copy (got ${flat_where})" "no" ;;
esac
if [ "${where}" != "${flat_where}" ]; then
  check "the two layouts resolve the same import to different files" "yes"
else
  check "the two layouts resolve the same import to different files" "no"
fi

# --------------------------------------------------------------------------
echo
echo "7. Distribution name versus import name"
# --------------------------------------------------------------------------

# You install `wordtally-tools`. You import `wordtally`. There is no module
# called `wordtally_tools` at all — the same split as beautifulsoup4 and bs4.
freeze="$("${tryout}/bin/pip" list --disable-pip-version-check --format=freeze 2>/dev/null)"
if printf '%s\n' "${freeze}" | grep -qi '^wordtally-tools=='; then
  check "pip lists the DISTRIBUTION name wordtally-tools" "yes"
else
  check "pip lists the DISTRIBUTION name wordtally-tools" "no"
fi
if "${tryout}/bin/python" -c "import wordtally" >/dev/null 2>&1; then
  check "the IMPORT name wordtally works after installation" "yes"
else
  check "the IMPORT name wordtally works after installation" "no"
fi
if "${tryout}/bin/python" -c "import wordtally_tools" >/dev/null 2>&1; then
  check "there is no module called wordtally_tools — the names genuinely differ" "no"
else
  check "there is no module called wordtally_tools — the names genuinely differ" "yes"
fi
meta_version="$("${tryout}/bin/python" -c 'from importlib.metadata import version; print(version("wordtally-tools"))' 2>&1)"
if [ "${meta_version}" = "0.3.1" ]; then
  check "importlib.metadata.version('wordtally-tools') returns 0.3.1 — the version is single-sourced" "yes"
else
  check "importlib.metadata.version('wordtally-tools') returns 0.3.1 (got ${meta_version})" "no"
fi
dunder_version="$("${tryout}/bin/python" -c 'import wordtally; print(wordtally.__version__)' 2>&1)"
if [ "${dunder_version}" = "0.3.1" ]; then
  check "wordtally.__version__ agrees, because it reads the same metadata" "yes"
else
  check "wordtally.__version__ agrees (got ${dunder_version})" "no"
fi

# --------------------------------------------------------------------------
echo
echo "8. The package's own test suite"
# --------------------------------------------------------------------------

# Run against the project's source tree. The installed copy is byte-identical
# to it — the wheel was built from these files a few checks ago — and the
# `__file__` assertions in section 6 are what prove the installed copy is the
# one a user gets.
suite_out="$(cd "${project}" && PYTHONPATH="${project}/src" "${pytest_bin}" -q 2>&1)"
suite_exit=$?
if [ "${suite_exit}" -eq 0 ]; then
  check "the package's own pytest suite exits 0" "yes"
else
  check "the package's own pytest suite exits 0 (got ${suite_exit})" "no"
  printf '%s\n' "${suite_out}" | tail -20
fi
case "${suite_out}" in
  *"17 passed"*) check "the package's own suite reports 17 passed" "yes" ;;
  *) check "the package's own suite reports 17 passed" "no" ;;
esac

# --------------------------------------------------------------------------
echo
echo "9. Missing required metadata makes the build fail"
# --------------------------------------------------------------------------

for field in name version; do
  broken="${work}/broken-${field}"
  cp -R "${lab_dir}/examples/wordtally-tools" "${broken}"
  grep -v "^${field} = " "${broken}/pyproject.toml" > "${broken}/pyproject.new"
  mv "${broken}/pyproject.new" "${broken}/pyproject.toml"
  if grep -q "^${field} = " "${broken}/pyproject.toml"; then
    check "the edit really removed the ${field} field" "no"
  else
    check "the edit really removed the ${field} field" "yes"
  fi
  broken_log="${work}/broken-${field}.log"
  (cd "${broken}" && "${python_bin}" -m build --no-isolation) >"${broken_log}" 2>&1
  broken_exit=$?
  if [ "${broken_exit}" -ne 0 ]; then
    check "building without a ${field} fails (exit ${broken_exit}, not 0)" "yes"
  else
    check "building without a ${field} fails — it did not, so the metadata is not enforced" "no"
  fi
  if grep -q "must contain \['name', 'version'\]" "${broken_log}" \
     || grep -q "must contain \['${field}'\]" "${broken_log}"; then
    check "the failure names the missing field rather than failing vaguely" "yes"
  else
    check "the failure names the missing field rather than failing vaguely" "no"
  fi
  if [ -d "${broken}/dist" ]; then
    check "a failed build produces no artifact for ${field}" "no"
  else
    check "a failed build produces no artifact for ${field}" "yes"
  fi
done

# --------------------------------------------------------------------------
echo
echo "10. The starter still has work in it"
# --------------------------------------------------------------------------

starter="${work}/starter"
cp -R "${lab_dir}/starter/wordtally-tools" "${starter}"
starter_log="${work}/starter.log"
(cd "${starter}" && "${python_bin}" -m build --no-isolation) >"${starter_log}" 2>&1
starter_exit=$?
if [ "${starter_exit}" -eq 0 ]; then
  check "the starter builds as shipped, so you can begin from a green state" "yes"
else
  check "the starter builds as shipped (got ${starter_exit})" "no"
  tail -20 "${starter_log}"
fi

starter_wheel="${starter}/dist/wordtally_tools-0.1.0-py3-none-any.whl"
if [ -f "${starter_wheel}" ] && [ -f "${starter}/dist/wordtally_tools-0.1.0.tar.gz" ]; then
  check "the starter's artifacts carry its own declared version, 0.1.0" "yes"
else
  check "the starter's artifacts carry its own declared version, 0.1.0" "no"
fi

starter_list="$(unzip -Z1 "${starter_wheel}" 2>/dev/null)"
if printf '%s\n' "${starter_list}" | grep -q 'entry_points.txt'; then
  check "the starter wheel has no console script yet — exercise 4 is real work" "no"
else
  check "the starter wheel has no console script yet — exercise 4 is real work" "yes"
fi
if printf '%s\n' "${starter_list}" | grep -q 'wordtally/data/stopwords.txt'; then
  check "the starter wheel omits the data file — exercise 5 is real work" "no"
else
  check "the starter wheel omits the data file — exercise 5 is real work" "yes"
fi

# The starter's exercises name the exact fields the finished file has, so a
# learner who follows them arrives somewhere real.
for field in "description" "readme" "requires-python" "license" "classifiers" "project.scripts" "package-data"; do
  if grep -q "${field}" "${lab_dir}/starter/wordtally-tools/pyproject.toml"; then
    check "the starter's exercises mention ${field}" "yes"
  else
    check "the starter's exercises mention ${field}" "no"
  fi
done

# --------------------------------------------------------------------------
echo
echo "11. Nothing is uploaded, and nothing is fetched"
# --------------------------------------------------------------------------

# This lab describes publishing and never performs it. That claim is checked
# here rather than merely asserted in prose.
lab_scripts="${lab_dir}/tests/run_tests.sh ${lab_dir}/examples/build_and_inspect.sh"

# metadata.yml is the complete list of commands this lab ever asks anyone to
# run. If publishing happened anywhere, it would have to appear there.
if [ ! -f "${lab_dir}/metadata.yml" ]; then
  check "metadata.yml exists, so the command surface can be checked" "no"
elif grep -niE 'twine|upload|testpypi|--repository' "${lab_dir}/metadata.yml" >/dev/null 2>&1; then
  check "metadata.yml declares no publishing command anywhere in this lab" "no"
  grep -niE 'twine|upload|testpypi|--repository' "${lab_dir}/metadata.yml"
else
  check "metadata.yml declares no publishing command anywhere in this lab" "yes"
fi

# Every pip install this lab performs must be index-free.
install_lines="$(grep -hn 'pip. install' ${lab_scripts} | grep -v 'no-index' | grep -v '^ *#' || true)"
if [ -z "${install_lines}" ]; then
  check "every pip install in this lab passes --no-index, so no index is ever contacted" "yes"
else
  check "every pip install in this lab passes --no-index" "no"
  printf '%s\n' "${install_lines}"
fi

# The reference project declares no runtime dependencies, so installing it
# needs nothing from anywhere. That is what made --no-index possible.
if grep -q '^dependencies = \[\]' "${lab_dir}/examples/wordtally-tools/pyproject.toml"; then
  check "the reference project declares an empty runtime dependency list" "yes"
else
  check "the reference project declares an empty runtime dependency list" "no"
fi

# Nothing in the packaged source opens a socket or reads the clock, so every
# number above is reproducible.
if grep -rqE 'import (socket|urllib|requests|http)|datetime\.now|time\.time|random\.' \
     "${lab_dir}/examples/wordtally-tools/src" "${lab_dir}/starter/wordtally-tools/src" 2>/dev/null; then
  check "no network, clock or randomness in the packaged source" "no"
else
  check "no network, clock or randomness in the packaged source" "yes"
fi

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

Troubleshooting

Troubleshooting — Day 083 lab

No module named build

The build package is not installed for the interpreter you invoked. python3 on your PATH and .venv/bin/python are different interpreters with different packages.

.venv/bin/python -m build --no-isolation
## or, for the harness:
PYTHON=.venv/bin/python bash tests/run_tests.sh

If .venv/ does not exist yet, follow the Installation section of README.md.

Backend 'setuptools.build_meta' is not available

You passed --no-isolation and setuptools is not installed in that environment. Two fixes, and they teach opposite lessons:

.venv/bin/pip install -r requirements/requirements.txt   # install the backend

or drop the flag and let the frontend build its own isolated environment:

python3 -m build          # needs a network the first time

The second is what a real release does. The first is what this lab does, so that every run is offline and reproducible.

The build tries to reach the network and hangs or fails

You omitted --no-isolation. python -m build creates a fresh environment per build and installs the packages listed in [build-system] requires into it, which means contacting an index. Add --no-isolation back.

error: Multiple top-level packages discovered in a flat-layout

setuptools found more than one importable directory in the project root and refuses to guess. Either move the package under src/ (the layout this lab argues for) or say explicitly what to package:

[tool.setuptools.packages.find]
where = ["src"]

configuration error: 'project' must contain ['name', 'version'] properties

A required metadata field is missing from [project]. This is not a bug — it is exercise 9, and section 9 of the harness reproduces it deliberately. Put the field back.

The wheel built, but wordtally is not a command after installing

[project.scripts] is missing or misspelled. Check what actually landed in the artifact:

unzip -p dist/wordtally_tools-0.3.1-py3-none-any.whl \
  wordtally_tools-0.3.1.dist-info/entry_points.txt

If that file does not exist, the declaration never made it into the build. The value must be "import.path:callable" — a colon, not a dot, before the function name.

FileNotFoundError mentioning stopwords.txt after installing

The data file was not shipped. Files inside your package directory are not included just because they are there. Declare them:

[tool.setuptools.package-data]
wordtally = ["data/*.txt"]

Rebuild and confirm:

unzip -l dist/wordtally_tools-0.3.1-py3-none-any.whl | grep stopwords

This is the most common packaging bug there is, and it is invisible until somebody installs your package somewhere other than your machine — which is precisely why the lab installs into a fresh environment rather than trusting the source tree.

import wordtally resolves to a path inside my project, not the environment

Expected in a flat layout, and the reason this lab uses src. Python puts the script's directory (or, for python -c, the current directory) at the front of sys.path, so a wordtally/ folder in the working directory wins over the installed copy. Check what you actually have:

ls            # is there a bare wordtally/ directory here?
python3 -c "import wordtally; print(wordtally.__file__)"

Section 6 of the harness demonstrates both outcomes side by side.

pip install --no-index fails with Could not find a version that satisfies

The wheel you named does not exist at that path, or the project you built declares a dependency that is not available locally. The reference project declares none deliberately, so an offline install is always possible; if you added one as an extension exercise, that failure is the correct and instructive result.

The version number in the filenames is not what I expected

Artifact names come from [project] version, and stale artifacts are not deleted by a new build. If dist/ holds several versions, remove it and rebuild:

rm -rf dist && python3 -m build --no-isolation && ls dist/

unzip: command not found

The harness opens the wheel with unzip on purpose, because seeing a wheel open as an ordinary archive is the point. Install it (apt install unzip, dnf install unzip), or look inside with Python instead:

python3 -m zipfile -l dist/wordtally_tools-0.3.1-py3-none-any.whl

The harness fails only on the 17 passed check

You edited the packaged project's test suite, which is fine — the harness pins the count so that a silently disappearing test cannot pass unnoticed. Update the expected number in tests/run_tests.sh to match your suite.

Everything passes but workspace/ is still there

tests/run_tests.sh removes it in an EXIT trap, so a normal run cleans up even after a failure. If you interrupted the run with a signal the trap may not have fired: rm -rf workspace.

Windows

Use WSL. Native Windows differs in ways that break the paths in this lab: environment binaries live in Scripts\ rather than bin/, an installed console script is wordtally.exe rather than a shebang script, and both scripts here are bash.

Security notes

Security notes — Day 083 lab

What this lab does and does not do

  • It uploads nothing, anywhere. No package index is contacted at any point. Publishing is described in the lesson, its commands are printed, and the lab stops before running them.
  • It fetches nothing. Every pip install here passes --no-index, which forbids pip from contacting an index even if one is reachable. Builds use --no-isolation, which reuses the already-installed backend rather than downloading one. After the one-time requirements install, this lab is entirely offline.
  • It needs no credentials. There is no token, no password, no account, and no configuration file to put one in.
  • It writes only inside workspace/ and inside the two project directories, and the cleanup commands remove everything it wrote. Nothing is installed system-wide and nothing needs sudo.

tests/run_tests.sh checks the first of those claims mechanically: it reads metadata.yml — the complete list of commands this lab asks anyone to run — and fails if any publishing command appears there.

Installing a package runs the publisher's code

This is the single most important security fact in packaging, and it is easy to forget because installing feels passive.

A wheel is mostly inert: installing one unpacks files and writes a launcher. An sdist is not. Building it can execute the project's own build backend and, in older projects, a setup.py that is arbitrary Python. pip install some-package on a source distribution therefore runs code written by someone you have never met, with your user's permissions, before you have imported anything.

Practical consequences:

  • Prefer wheels. pip install --only-binary :all: <name> refuses to build from source at all, which is a reasonable default on a machine that matters.
  • Install into a virtual environment, always. This lab's throwaway environment is throwaway on purpose: deleting the directory undoes the entire install.
  • Read the name you typed. Typosquatting — registering a name one keystroke away from a popular package — is a real and recurring attack. So is dependency confusion, where a public package is published under the name of somebody's internal one so that a misconfigured installer prefers it. If your organisation has private packages, pin the index explicitly rather than letting the resolver choose.
  • Pin versions and use a lock file for applications. An unpinned dependency means that what you install today is not what you tested yesterday.

Credentials, if you ever do publish

This lab never does, but the rules matter the first time you do:

  • Authenticate with an API token, not a password, and scope it to one project rather than to your whole account.
  • Put the token in an environment variable or an operating-system keyring. Never in a file inside the repository, never in pyproject.toml, never in a shell script committed alongside the code. A token in git history is a leaked token even after you delete the line.
  • Prefer a trusted-publishing arrangement where your build system proves its identity to the index directly and no long-lived token exists to leak.
  • Rehearse against the test index first. It is a separate service with separate accounts, and it exists precisely so that a first release can be a mistake.

What you cannot undo

A version number, once published to the public index, is permanent. You may be able to hide (yank) a release, but you can never re-upload different bytes under the same version. Anyone who already installed it keeps what they got, and any lock file that pinned it keeps pointing at it.

The consequence is a rule, not a preference: a bad release is fixed by publishing a new version. Never by quietly replacing the old one — which the index will refuse anyway.

The second consequence is why this lab builds locally. An accidental upload is a class of mistake with no undo, so the safe way to teach packaging is to stop one command short of it.

Data handling

Nothing here reads personal data, the network, the clock, or a random number. The only input is examples/sample.txt, which contains one sentence about a cat. Every number in expected-output/ is reproducible from the pinned tool versions.