Python Lesson 26 of 71

Project Structure, Packaging & Documentation: src layout, pyproject.toml & READMEs

There’s a moment in every Python project where the thing that has been working perfectly stops working — not because you changed the code, but because someone else wants to use it.

You send them wordtally.py. They ask where to put it. You say anywhere. They ask how to run it, and you say python wordtally.py file.txt, and they ask why it says ModuleNotFoundError, and you realise they need the other file too, and it has to be in the same directory, and actually there’s a third file now, and it needs requests, and you have never once written down which version.

That conversation is what packaging is for: the boring, unglamorous discipline of making your code installable, so the answer to “how do I run your tool?” is pip install wordtally then wordtally file.txt, and there is no third question.

Everything below was run on Python 3.12.3, with real output. We’ll take one 40-line script all the way to a wheel — and break the build on purpose along the way, to show you the single most valuable thing project layout does for you.


Why this matters

Here’s the fastest way to see the problem:

python wordtally.py sample.txt

That works on your machine, in this directory, with your interpreter and your installed libraries. Change any one of those four and it stops. Your script isn’t a program — it’s a file that happens to run under conditions you’ve never written down.

A distribution is what you get when you write them down: your code plus a machine-readable statement of what it’s called, what version it is, what Python it needs, what libraries it needs, and what commands it provides. Once that statement exists, pip can act on it — fetch it, check the interpreter is compatible, install the dependencies, drop your code somewhere importable, and put your command on the user’s PATH. This lesson is about writing that statement correctly.

There’s a second reason, and it’s about you rather than your users. The moment your project is installable, your tests can import it the way a user would. That sounds minor. It isn’t — it’s the difference between “the tests passed” and “the tests passed against the thing I’m about to ship”, and those two can differ while your CI stays reassuringly green.

What you have What breaks What packaging gives you
python thing.py Only in that directory, with that interpreter thing works from anywhere
“just copy these three files” Nobody knows which three, or where One artifact, one install
“it needs requests I think” Version unknown, install manual dependencies — pip resolves them
“it needs Python 3.10 probably” Cryptic SyntaxError on 3.9 requires-python — pip refuses politely
“run python -m mytool.cli Users must know your internals [project.scripts] — a real command
Tests import from the source dir Green CI, broken install Tests import the installed package

The mental model for the whole lesson: your source tree and your distribution are different things, and the build backend is the function between them. Most packaging confusion is someone assuming those two are the same folder. They’re not, they don’t have the same shape, and the whole point of pyproject.toml is to describe the transformation.


Script, module, package, distribution

Python gives you four rungs of organisation, and you climb them in order. Each exists because the one below it ran out of road. Knowing exactly where each stops is most of the intuition.

A script is a file you run. python wordtally.py. No shame in it — a 40-line script that does one job well is a legitimate piece of software. It stops working when you want to reuse a piece of it: you can’t cleanly borrow top_words(), because importing the file runs it top to bottom, argument parsing and printing included.

A module is a file you import — the same file, organised so importing it is safe. The if __name__ == "__main__": guard exists precisely so a file can be both. Now from wordtally import top_words works. It stops working when one file gets too big, or when two of your projects both have a utils.py and need not to collide.

A package is a directory of modules with an __init__.py. Now you have wordtally.core, wordtally.cli, and a namespace of your own. It stops working the moment the package needs to live somewhere other than your current directory — which is to say, the moment anyone else wants it. (If import resolution itself is hazy, Modules, Packages, Imports & the Standard Library is the ground beneath this lesson.)

A distribution is a package plus metadata, built into an artifact pip can install. This is the rung almost nobody teaches, and the one that turns your code into software other people can use.

Rung What it is How you run it Where it stops
Script A file you execute python wordtally.py You can’t reuse one function without running everything
Module A file you import import wordtally One file gets too big; names collide across projects
Package A directory + __init__.py from wordtally.core import top_words Only importable from the directory above it
Distribution A package + metadata, built pip install wordtally (This is the top. Now it’s software)

Python’s own vocabulary is genuinely confusing here. The thing you pip install is a distribution; the thing you import is a package. They usually share a name but don’t have to, and the mismatch is a classic trap:

You install (distribution) You import (package)
pip install pillow import PIL
pip install scikit-learn import sklearn
pip install beautifulsoup4 import bs4
pip install PyYAML import yaml
pip install python-dateutil import dateutil
pip install wordtally import wordtally (match — do this)

Make them match unless you have a real reason not to. Your users will thank you by not filing an issue.


The layout debate: src/ vs flat

Now the structural question, and the one that generates more forum arguments than it deserves — because the actual reason is rarely stated and is completely decisive once you’ve seen it.

There are two ways to arrange a Python project. Flat layout puts the package at the repo root:

wordtally/                 <- the repo
├── wordtally/             <- the package, right here
│   ├── __init__.py
│   ├── cli.py
│   └── core.py
├── tests/
├── pyproject.toml
└── README.md

src/ layout buries it one level down:

wordtally/                 <- the repo
├── src/
│   └── wordtally/         <- the package, down here
│       ├── __init__.py
│       ├── cli.py
│       └── core.py
├── tests/
├── pyproject.toml
└── README.md

That’s the entire difference: one directory. The usual arguments for src/ are aesthetic — “it’s tidier”, “it separates code from config” — and they’re unpersuasive, because they’re matters of taste and one extra cd is a real cost.

The real reason has nothing to do with tidiness. It’s about what import wordtally finds.

The mechanism: sys.path[0]

When Python starts, it puts a directory at the very front of sys.path — ahead of site-packages, ahead of everything. Which directory depends on how you invoked it:

python -c "import sys; print(repr(sys.path[0]))"
''

Empty string means the current working directory. For python script.py it’s the script’s directory instead; for python -m mod it’s the CWD again.

How you start Python sys.path[0] is
python script.py The directory containing script.py
python -c "..." '' — the current working directory
python -m module '' — the current working directory
REPL (python) '' — the current working directory
python -P ... (3.11+) Nothing is prepended — the shadow is off
A console script (wordtally) The script’s dir — i.e. .venv/bin/, harmlessly

Read that table with a flat layout in mind. You’re sitting in your repo root, so sys.path[0] is your repo root — and your repo root contains a directory called wordtally/ with an __init__.py in it. import wordtally finds it instantly, before it ever looks at site-packages.

Which means: in a flat layout, import wordtally works whether or not your package is correctly installed. Python isn’t importing your installed package; it’s importing the folder you’re standing next to. Those are usually the same code, so nobody notices — until they aren’t.

The failure src/ prevents, demonstrated

Talk is cheap. Let’s build a genuinely broken package and see who catches it. A realistic mistake: the project used to be called wordcount, someone renamed it, and the packaging config kept the old glob:

[tool.setuptools.packages.find]
where = ["."]
include = ["wordcount*"]     # stale name after a rename

Build it:

python -m build --wheel
Successfully built wordtally-0.1.0-py3-none-any.whl

Successfully built. No warning, no error, exit code 0. Now look at what’s actually in it:

unzip -l dist/wordtally-0.1.0-py3-none-any.whl
Archive:  dist/wordtally-0.1.0-py3-none-any.whl
  Length      Date    Time    Name
---------  ---------- -----   ----
     1066  07-15-2026 14:09   wordtally-0.1.0.dist-info/licenses/LICENSE
      821  07-15-2026 14:09   wordtally-0.1.0.dist-info/METADATA
       91  07-15-2026 14:09   wordtally-0.1.0.dist-info/WHEEL
       49  07-15-2026 14:09   wordtally-0.1.0.dist-info/entry_points.txt
        1  07-15-2026 14:09   wordtally-0.1.0.dist-info/top_level.txt
      500  07-15-2026 14:09   wordtally-0.1.0.dist-info/RECORD
---------                     -------
     2528                     6 files

Six files, and not one of them is Python code. The glob matched nothing, so the backend shipped pure metadata. This wheel is an empty box with a label on it. Install it, and now the two layouts diverge.

Flat layout, from the repo root — which is where you work, and where CI runs:

pip install dist/wordtally-0.1.0-py3-none-any.whl
python -c "import wordtally; print('import OK ->', wordtally.__file__)"
pytest -q
import OK -> /Users/you/pkgdemo/flat/wordtally/__init__.py
..                                                                       [100%]
2 passed in 0.01s

Two tests passed. Green. Ship it. But read that path — .../flat/wordtally/__init__.py. That is not site-packages. Python imported the source directory sitting in your CWD. Your test suite just validated code that isn’t in the artifact, and told you everything was fine.

Now be the user. Same environment, different directory:

cd /tmp
python -c "import wordtally"
  File "<string>", line 1, in <module>
ModuleNotFoundError: No module named 'wordtally'

And the command they installed your tool for:

wordtally sample.txt
  File "/Users/you/pkgdemo/flat/.venv/bin/wordtally", line 5, in <module>
    from wordtally.cli import main
ModuleNotFoundError: No module named 'wordtally'

Now the exact same broken config, in a src/ layout:

python -c "import wordtally"
  File "<string>", line 1, in <module>
ModuleNotFoundError: No module named 'wordtally'
pytest -q
    from wordtally.core import tokenize, top_words
E   ModuleNotFoundError: No module named 'wordtally'
=========================== short test summary info ============================
ERROR tests/test_core.py
!!!!!!!!!!!!!!!!!!!! Interrupted: 1 error during collection !!!!!!!!!!!!!!!!!!!!
1 error in 0.05s

There it is. Identical bug. Flat layout: “2 passed”. src/ layout: your tests won’t even collect.

That’s the argument, and it isn’t about tidiness. src/ makes your source tree unimportable by accident: src/ is not a package (no __init__.py) and your package isn’t at the repo root, so there’s no path from your CWD to import wordtally — the import can only succeed if the install actually worked. Your test suite becomes physically incapable of testing anything but the installed package. The failure isn’t prevented so much as made impossible to hide, which is better.

Flat layout src/ layout
Package location ./wordtally/ ./src/wordtally/
import wordtally from repo root Finds the source dir via CWD Only finds the installed package
Works without installing? Yes — and that’s the bug No — and that’s the feature
Tests exercise… Whatever CWD happens to hold The installed artifact, always
A broken build config Silently passes CI Fails instantly, loudly
Missing package-data Hidden (files are right there) Caught (they’re not in the install)
pip install -e . needed? Not strictly Yes — which is the forcing function
Extra typing None One src/ in a few paths
Used by Many older projects, tiny scripts Modern packaging guidance, most serious libraries

Two honest caveats. src/ costs you something real — you must pip install -e . before anything works, and beginners hit ModuleNotFoundError on day one and blame the layout. And flat layout isn’t evil: for a five-file personal tool it’s fine, and plenty of major projects use it successfully because they’re disciplined about testing installs in CI. src/ just means you don’t have to be disciplined, because the filesystem is disciplined for you.

If you’d rather see the shadowing than take my word for it, Python 3.11+ has a switch. -P (or PYTHONSAFEPATH=1) tells Python not to prepend that directory:

python    -c "import wordtally; print('shadowed ->', wordtally.__file__)"
python -P -c "import wordtally; print('with -P  ->', wordtally.__file__)"
shadowed -> /Users/you/pkgdemo/flat/wordtally/__init__.py
ModuleNotFoundError: No module named 'wordtally'

Same command, same directory, one flag. The first line is the accident; the second is the truth. -P is a flat layout’s src/ layout, applied one command at a time — useful for diagnosing this in a repo you don’t control.

Version note: -P and PYTHONSAFEPATH arrived in Python 3.11. On 3.10 and older there’s no built-in switch; you’d cd elsewhere to test, which is the manual version of the same idea.

The canonical tree

Here’s the layout to reach for. Nothing exotic, and every entry earns its place:

wordtally/
├── src/
│   └── wordtally/
│       ├── __init__.py      # public API + __version__
│       ├── core.py          # the logic — no I/O, no CLI
│       └── cli.py           # argument parsing, printing, exit codes
├── tests/
│   └── test_core.py         # imports wordtally like a user does
├── docs/                    # optional, until it isn't
├── pyproject.toml           # the contract — name, version, deps, scripts, tools
├── README.md                # what/why/install/quickstart
├── LICENSE                  # without this, nobody may legally use it
├── CHANGELOG.md             # what changed, per version
└── .gitignore               # .venv/, dist/, build/, __pycache__/
Path Why it exists
src/ Not a package — a wall. Makes the source tree unimportable by accident
src/wordtally/__init__.py Makes it a package; holds __version__ and the public API
core.py vs cli.py Logic separate from I/O. core is testable without a terminal
tests/ Outside src/ — so tests import the install, and don’t ship in the wheel
pyproject.toml The single source of truth for build, metadata and tool config
README.md Renders on PyPI and GitHub. Your entire first impression
LICENSE No licence = all rights reserved. Legally unusable by others
CHANGELOG.md The only place a user learns why 2.0 broke them
.gitignore dist/, build/, *.egg-info/, .venv/ are build output, never source

The tests/ placement is worth a beat: they sit beside src/, not inside your package. So your tests import wordtally exactly as a user would (the src/ guarantee applies to them), and they don’t get shipped inside the wheel to everyone who installs you.


pyproject.toml, field by field

One file describes the whole project. It’s TOML, it’s standardised, and it replaced a genuine mess — setup.py, setup.cfg, MANIFEST.in, requirements.txt, .flake8, .isort.cfg, mypy.ini and a dozen more. Here’s the complete file for our project; we’ll take it apart section by section:

[build-system]
requires = ["hatchling"]
build-backend = "hatchling.build"

[project]
name = "wordtally"
version = "0.1.0"
description = "Report the most common words in a text file."
readme = "README.md"
requires-python = ">=3.10"
license = "MIT"
license-files = ["LICENSE"]
authors = [{ name = "Your Name", email = "you@example.com" }]
keywords = ["text", "wordcount", "cli"]
classifiers = [
  "Development Status :: 3 - Alpha",
  "Environment :: Console",
  "Intended Audience :: Developers",
  "Programming Language :: Python :: 3",
  "Programming Language :: Python :: 3.12",
  "Topic :: Text Processing",
]
dependencies = []

[project.optional-dependencies]
dev = ["pytest>=8", "ruff>=0.6"]

[project.urls]
Homepage = "https://example.com/wordtally"
Source = "https://github.com/you/wordtally"
Issues = "https://github.com/you/wordtally/issues"

[project.scripts]
wordtally = "wordtally.cli:main"

[tool.ruff]
line-length = 88
src = ["src", "tests"]

[tool.ruff.lint]
select = ["E", "F", "I", "UP", "B"]

[tool.pytest.ini_options]
testpaths = ["tests"]
addopts = "-q"

Three kinds of table live in there, and the distinction matters: [build-system] tells pip how to build, [project] is standardised metadata (PEP 621 — every backend reads it identically), and [tool.*] is a free-for-all where each tool keeps its own settings.

[build-system] — how to build

[build-system]
requires = ["hatchling"]
build-backend = "hatchling.build"

Two lines, with more going on than they look. requires lists what pip must install into a temporary, isolated environment before your build runs; build-backend names the Python object pip calls to do it. Watch:

python -m build
* Creating isolated environment: venv+pip...
* Installing packages in isolated environment:
  - hatchling
* Getting build dependencies for sdist...
* Building sdist...
* Building wheel from sdist
* Creating isolated environment: venv+pip...
* Installing packages in isolated environment:
  - hatchling
* Getting build dependencies for wheel...
* Building wheel...
Successfully built wordtally-0.1.0.tar.gz and wordtally-0.1.0-py3-none-any.whl

“Creating isolated environment” is PEP 517 working: your build tool isn’t taken from your venv, it’s installed fresh from the versions you declared. That’s why builds are reproducible on a machine that has never heard of hatchling, and why requires isn’t optional decoration.

Which backend? Honest comparison:

Backend requires Config style Best for Watch out for
hatchling ["hatchling"] Sensible defaults; finds src/pkg automatically New projects. The default recommendation Newer, so older StackOverflow answers won’t mention it
setuptools ["setuptools>=61"] Explicit; huge surface area Existing projects; C extensions Auto-discovery is a footgun (see the whole troubleshooting table)
flit-core ["flit_core>=3.2"] Minimal; opinionated Small pure-Python libs, one package Deliberately can’t do complex builds
poetry-core ["poetry-core"] [tool.poetry], its own dialect Teams already on Poetry Historically ignored PEP 621; use Poetry 2.x for [project]
pdm-backend ["pdm-backend"] PEP 621 + dynamic versioning PDM users Smaller community
maturin ["maturin"] Rust ↔ Python Rust extension modules Only if you’re writing Rust
scikit-build-core ["scikit-build-core"] CMake C/C++ extensions Only if you’re compiling

For a pure-Python project in 2026, hatchling is the answer, and it’s the one this lesson uses. It found src/wordtally/ with zero configuration. Use setuptools when you’re compiling C, or when you’ve joined a project that already uses it — which will be often, and that’s fine.

[project] — the metadata

This block is PEP 621, and it’s the same in every backend. Every field, what it does, and whether you can skip it:

Field Required? What it does Gotcha
name Yes The pip install name. Global on PyPI Normalised: Word.Tally == word-tally == word_tally
version Yes* The release number *Unless it’s in dynamic — never both
description No One-line summary, shown in search One line. Not a paragraph
readme No Path to the long description Wrong markup = broken PyPI page. twine check it
requires-python No — but do it Which interpreters may install this Omit it and 3.8 users install and get SyntaxError
license No SPDX expression, e.g. "MIT" New PEP 639 form; the old {text = "MIT"} still works
license-files No Globs of licence files to ship Lands in dist-info/licenses/
authors / maintainers No [{name = "...", email = "..."}] A list of tables — TOML syntax bites here
keywords No PyPI search terms Flat list of strings
classifiers No Trove classifiers — a fixed vocabulary Invented values are rejected at upload
dependencies No What pip installs alongside you Ranges, not pins, for libraries
optional-dependencies No Named extras: pip install pkg[dev] Great for dev/test/docs groups
urls No Links in the PyPI sidebar Homepage, Source, Issues, Changelog
dynamic No “This field is computed by the backend” Listing a field here and setting it = build error
scripts No Console commands (below — it’s the payoff) The reason anyone reads this section

Two of those deserve emphasis because skipping them causes support tickets rather than errors.

requires-python is the field everyone forgets. Our code uses list[str] | None, which is a SyntaxError before Python 3.10. Declare it, and pip protects those users on their behalf:

pip install dist/wordtally-0.1.0-py3-none-any.whl     # on Python 3.9.6
ERROR: Package 'wordtally' requires a different Python: 3.9.6 not in '>=3.10'

That is a good error. It names the problem, on the right machine, before anything is installed. Without requires-python, that same user installs successfully and gets a SyntaxError from inside your library at import time — and files a bug report you cannot reproduce.

classifiers are a fixed vocabulary, not free text. You can’t invent one; PyPI rejects unknown values at upload. They drive PyPI’s filters, and Development Status is the one users read to decide whether to trust you.

Version note: PEP 639 changed how licences are declared. Modern: license = "MIT" (an SPDX expression) plus license-files = ["LICENSE"], which produces License-Expression: MIT in the metadata. Older projects use license = { text = "MIT" } and a License :: OSI Approved :: MIT License classifier — still accepted, now redundant. Don’t use both the SPDX form and the licence classifier.

[project.scripts] — the payoff

This is the one that makes the whole exercise worth it. Three words:

[project.scripts]
wordtally = "wordtally.cli:main"

Read it as: make a command called wordtally; when it runs, import wordtally.cli and call main(). The syntax is command = "package.module:function" — the colon separates the import path from the callable.

Install the package, and:

which wordtally
wordtally sample.txt -n 3
/Users/you/pkgdemo/wordtally/.venv/bin/wordtally
     3  packaging
     3  you
     3  package

Your tool is a command now. No python, no .py, no path. That’s the difference between a script and a program, and it cost you two lines of TOML.

There’s no magic — look at what pip generated:

cat .venv/bin/wordtally
#!/Users/you/pkgdemo/wordtally/.venv/bin/python
# -*- coding: utf-8 -*-
import re
import sys
from wordtally.cli import main
if __name__ == '__main__':
    sys.argv[0] = re.sub(r'(-script\.pyw|\.exe)?$', '', sys.argv[0])
    sys.exit(main())

Seven lines. A shebang pinning the venv’s interpreter — which is why the command uses the right Python without you activating anything — then literally from wordtally.cli import main, then sys.exit(main()). That last part is why main() should return an exit code rather than print and hope: sys.exit(0) means success, non-zero means failure, and shell scripts and CI depend on it.

Note also what this implies: the shim lives in .venv/bin/. Deactivate the venv and the command vanishes from PATH — which is the single most common “it worked yesterday” report for a tool you installed into a project venv. If you want a CLI available everywhere, that’s what pipx is for.

scripts isn’t the only entry-point group. [project.gui-scripts] is identical but spawns no console window on Windows, and [project.entry-points."some.group"] is how the plugin ecosystem works: a package advertises itself under a group name everyone agreed on, and the host discovers it at runtime without ever importing your code by name. That’s pytest-cov appearing inside pytest the moment you install it.

[tool.*] — everything else lives here too

The other reason pyproject.toml won: it absorbed the dotfile swarm.

Table Replaces Typical use
[tool.ruff] .flake8, .isort.cfg, setup.cfg Lint + format config
[tool.pytest.ini_options] pytest.ini, tox.ini testpaths, addopts, markers
[tool.mypy] mypy.ini Strictness, per-module overrides
[tool.coverage.run] .coveragerc source, omit, branch coverage
[tool.black] pyproject.toml (always lived here) Line length, target versions
[tool.hatch.*] Backend-specific Dynamic version, file inclusion
[tool.setuptools.*] setup.py, setup.cfg packages.find, package-data

[tool.X] is namespaced by convention: a tool reads its own table and ignores every other. Nothing enforces this — it’s just a name everyone agreed on. Your repo root loses six dotfiles and gains one file you can actually read.

The death of setup.py (and what you’ll still meet)

setup.py was executable configuration: a Python script that pip ran to discover your metadata. Which meant you couldn’t know a package’s dependencies without executing arbitrary code from a stranger. setup.cfg fixed the executable part and kept the mess. pyproject.toml fixed both.

You will still meet all of them. Translation table:

Old New
setup(name="x") in setup.py name = "x" in [project]
install_requires=[...] dependencies = [...]
extras_require={"dev": [...]} [project.optional-dependencies]dev = [...]
python_requires=">=3.10" requires-python = ">=3.10"
entry_points={"console_scripts": [...]} [project.scripts]
packages=find_packages("src") Backend auto-discovery (hatchling: nothing at all)
long_description=open("README.md").read() readme = "README.md"
MANIFEST.in [tool.hatch.build] / [tool.setuptools.package-data]
python setup.py sdist bdist_wheel python -m build
python setup.py install pip install .
python setup.py develop pip install -e .

Two rules for the ones you’ll meet in the wild. Never run python setup.py directly — it’s deprecated, it bypasses PEP 517 isolation, and python setup.py install in particular installs in a way pip can’t cleanly uninstall. Use pip install . and python -m build, which work on setup.py projects too. And a setup.py project is not automatically broken; setuptools is alive and maintained. You only need a setup.py today for genuinely dynamic builds like compiling C extensions.


Versioning: what the number promises

Your version is not a changelog and not a vibe. It’s a promise to a resolver. When a user writes wordtally>=1.2,<2, they’re relying on you to mean something specific by “2.0”.

Semantic VersioningMAJOR.MINOR.PATCH — is that shared meaning:

Bump Example Means You do it when
PATCH 1.4.2 → 1.4.3 Nothing broke, nothing new Bug fix, docs, internal refactor
MINOR 1.4.3 → 1.5.0 New stuff, still backward compatible New function, new optional param, new feature
MAJOR 1.5.0 → 2.0.0 Something you relied on is gone or different Removed/renamed a public thing, changed a default, dropped a Python version
0.x.y 0.1.0 → 0.2.0 “Anything may change” Pre-1.0. The escape hatch — use it honestly

The rule that surprises people: breaking changes are decided by your users’ code, not your intent. Renaming a parameter is MAJOR even if the old name was a typo. Changing a default from n=5 to n=10 is MAJOR — every caller relying on it gets new behaviour with no code change. Dropping Python 3.10 is MAJOR. Adding a keyword argument with a default is MINOR. Clearer error text is PATCH.

And 0.x isn’t a lesser status, it’s an honest one: “I reserve the right to break you.” Ship 0.x until the API stops moving — 1.0.0 is a commitment, so don’t make it accidentally.

Version string What it signals Valid?
1.4.2 Stable release
0.1.0 Early; API may move without warning
2.0.0rc1 Release candidate — pip skips it unless --pre
1.0.0a1 / 1.0.0b2 Alpha / beta — also pre-releases
1.4.2.post1 Packaging fix; same code
1.4.2.dev3 Development build, pre-release
1.4 Fine — PEP 440 doesn’t require three parts
v1.4.2 Normalised to 1.4.2 by pip; don’t write it ⚠️
1.4.2-beta Normalised to 1.4.2b0 ⚠️
2026.07.15 CalVer — legal, but says nothing about breakage

Single source of truth

Your version wants to exist in exactly one place. Put it in two and they will drift — guaranteed, and usually the week you’re mid-release.

The two honest strategies:

Static in pyproject.toml. version = "0.1.0". Dead simple, and import wordtally; wordtally.__version__ doesn’t exist unless you also write it in __init__.py — which is the drift.

Dynamic, read from the source. Keep __version__ in __init__.py and let the backend read it:

# src/wordtally/__init__.py
__version__ = "0.1.0"
[project]
name = "wordtally"
dynamic = ["version"]      # "the backend computes this"

[tool.hatch.version]
path = "src/wordtally/__init__.py"
python -m build --wheel
Successfully built wordtally-0.1.0-py3-none-any.whl

The backend read __version__ straight out of your source and named the wheel after it. One place, no drift.

⚠️ The classic error is declaring it twice. Set version and list it in dynamic:

ValueError: Metadata field `version` cannot be both statically defined and listed in field `project.dynamic`

Or list it in dynamic and forget to tell the backend where to look:

ValueError: Missing `tool.hatch.version` configuration

Both are build-time, both are loud, both are five-second fixes. This is the good kind of error.

Strategy Version lives in __version__ available? Best for
Static pyproject.toml only No (unless duplicated — don’t) Apps, simple libs
Dynamic from source __init__.py Yes — it’s the source Libraries where users check __version__
Dynamic from VCS tag Your git tag Yes (generated) Teams where releases are tags (hatch-vcs, setuptools-scm)
importlib.metadata The install’s metadata Yes — read at runtime Avoids duplication entirely

The fourth is worth knowing: importlib.metadata.version("wordtally") reads the version out of the installed metadata, so there’s nothing to duplicate. The catch is it only works when installed, and it’s slower than a constant.


Editable installs, building, and publishing

Now the pipeline itself: from the folder you edit to the command your user types.

The diagram below is the whole journey. Read it left to right: your src/ tree plus pyproject.toml go into a build backend, which emits two artifacts; pip unpacks the wheel into site-packages and writes the console-script shim onto PATH. The pink branch is pip install -e ., pointing a one-line .pth straight back at your source. Badge 2 marks the flat-layout accident — note it happens before the pipeline, which is exactly why the pipeline never catches it.

Python packaging pipeline drawn left to right: a source tree of src/wordtally with init, core and cli plus a pyproject.toml holding project metadata and scripts, with the flat-layout trap marked in red where a package directory sitting in the current working directory shadows the installed one; feeding python -m build which spins an isolated environment and calls the hatchling build backend to choose what ships; emitting a 3 KB sdist tar.gz containing src and tests and a 4 KB py3-none-any wheel, checked by twine for README rendering; then pip install which simply unzips with no build step, alongside the editable .pth branch pointing back at the source directory; landing in site-packages holding wordtally with no src prefix, plus the bin/wordtally console-script shim on PATH

Six things go wrong here and the badges mark all of them: pyproject.toml is the only contract the backend reads (1); the flat-layout shadow means CWD beats site-packages (2); the backend silently ships whatever the config selects, including nothing (3); wheels are zips and sdists make the user build (4); -e . is a text file, not sorcery (5); and your entry point only exists while its venv is on PATH (6).

pip install -e . — the dev workflow

src/ layout makes your package unimportable until it’s installed. So install it — but not a copy, or you’d reinstall after every keystroke:

pip install -e ".[dev]"
Building editable for wordtally (pyproject.toml): finished with status 'done'
Successfully built wordtally
Installing collected packages: wordtally, ruff, pygments, pluggy, packaging, iniconfig, pytest
Successfully installed iniconfig-2.3.0 packaging-26.2 pluggy-1.6.0 pygments-2.20.0 pytest-9.1.1 ruff-0.15.21 wordtally-0.1.0

Note ".[dev]" — the dot is “the project in this directory”, [dev] pulls in optional-dependencies.dev, and the quotes stop your shell from globbing the brackets. One command installed your package and your whole dev toolchain.

Now, what did -e actually do? Almost nothing, and that’s the lesson:

ls .venv/lib/python3.12/site-packages/ | grep -iE "wordtally|editable"
cat .venv/lib/python3.12/site-packages/_editable_impl_wordtally.pth
_editable_impl_wordtally.pth
wordtally-0.1.0.dist-info
/Users/you/pkgdemo/wordtally/src

That’s it. A .pth file containing one line: the absolute path of your src/ directory. Python reads .pth files in site-packages at startup and appends each line to sys.path. So import wordtally searches your src/ folder — the actual files you’re editing:

python -c "import wordtally; print(wordtally.__file__)"
/Users/you/pkgdemo/wordtally/src/wordtally/__init__.py

Change the source, re-run, see the change. No reinstall. And the dist-info beside it is real metadata, so pip list, pip show, dependency resolution and your entry point all behave exactly as for a normal install.

Two consequences fall out of “it’s an absolute path in a text file”: move or rename the repo and the editable install dangles, and -e is for you, never for your users — it hard-wires a path on your disk.

pip install . pip install -e .
What lands in site-packages A copy of your code A .pth pointing at src/
Edit source → effect Nothing until you reinstall Live
wordtally.__file__ .../site-packages/wordtally/... .../src/wordtally/...
Survives moving the repo Yes No — dangling path
Use for Testing the real artifact; deploys Development. Always

Building: sdist and wheel

python -m build

You get two files, and they are not two formats of the same thing:

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

Look inside both. The sdist is your repo:

tar tzf dist/wordtally-0.1.0.tar.gz
wordtally-0.1.0/sample.txt
wordtally-0.1.0/src/wordtally/__init__.py
wordtally-0.1.0/src/wordtally/cli.py
wordtally-0.1.0/src/wordtally/core.py
wordtally-0.1.0/tests/test_core.py
wordtally-0.1.0/.gitignore
wordtally-0.1.0/LICENSE
wordtally-0.1.0/README.md
wordtally-0.1.0/pyproject.toml
wordtally-0.1.0/PKG-INFO

Tests, README, pyproject.toml, src/ — the lot. It’s a snapshot of the project as source, and installing it means building it on the user’s machine.

The wheel is the built result:

unzip -l dist/wordtally-0.1.0-py3-none-any.whl
Archive:  dist/wordtally-0.1.0-py3-none-any.whl
  Length      Date    Time    Name
---------  ---------- -----   ----
      164  02-02-2020 00:00   wordtally/__init__.py
     1045  02-02-2020 00:00   wordtally/cli.py
     1009  02-02-2020 00:00   wordtally/core.py
     1423  02-02-2020 00:00   wordtally-0.1.0.dist-info/METADATA
       87  02-02-2020 00:00   wordtally-0.1.0.dist-info/WHEEL
       49  02-02-2020 00:00   wordtally-0.1.0.dist-info/entry_points.txt
     1066  02-02-2020 00:00   wordtally-0.1.0.dist-info/licenses/LICENSE
      632  02-02-2020 00:00   wordtally-0.1.0.dist-info/RECORD
---------                     -------
     5475                     8 files

Three details in that listing repay a close read.

wordtally/ is at the top level. There is no src/. That’s the answer to “won’t src/ layout annoy my users?” — it’s a build-time detail the backend strips. They never type it, never see it, never know.

No tests/. The sdist has them; the wheel doesn’t. Your users don’t need your test suite.

That 02-02-2020 timestamp. Not a bug — hatchling zeroes timestamps so building the same source twice yields a byte-identical wheel. Reproducible builds.

And the entry point, made concrete:

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

Your TOML became a tiny INI file inside the zip. At install time, pip reads it and generates the shim. That’s the entire mechanism.

sdist (.tar.gz) wheel (.whl)
What it is Your source, archived The built package
Contains src/, tests/, pyproject.toml, README Just the importable package + dist-info
Installing means Run your build backend on the user’s machine Unzip. That’s all
Needs a compiler? Maybe — for C extensions, yes Never
Speed Slow (build every time) Fast (copy files)
Runs your code at install? Yes — the backend No
Can pip resolve deps without installing? Not reliably Yes — metadata is right there
Ship it? Yes — the fallback, and the source of truth Yes — what 99% of users get

Ship both. The wheel is what everyone actually installs; the sdist is the fallback for platforms you didn’t build for, and it’s what distro packagers and auditors need.

The wheel’s filename is structured data — {name}-{version}-{python tag}-{abi tag}-{platform tag}.whl:

Filename Reads as Meaning
wordtally-0.1.0-py3-none-any.whl py3 · none · any Pure Python. Any Python 3, any OS, any CPU
numpy-2.1.0-cp312-cp312-manylinux_2_17_x86_64.whl cp312 · cp312 · manylinux…x86_64 CPython 3.12 only, Linux only, x86-64 only
numpy-2.1.0-cp312-cp312-macosx_11_0_arm64.whl cp312 · cp312 · macosx…arm64 CPython 3.12, macOS 11+, Apple Silicon
pkg-1.0-py2.py3-none-any.whl py2.py3 Ancient; supports both Python 2 and 3

py3-none-any is the good outcome: one file serves every user on earth. That’s the reward for pure Python. The moment you compile C, you’re building a matrix — every Python version × every OS × every architecture — which is why projects like NumPy publish dozens of wheels per release, and why cibuildwheel exists.

Publishing — accurately, and not right now

⚠️ Do not publish the lab package. PyPI is a public, permanent namespace, and wordtally is a tutorial name. Read this section; don’t run the last command.

First, always:

twine check dist/*
Checking dist/wordtally-0.1.0-py3-none-any.whl: PASSED
Checking dist/wordtally-0.1.0.tar.gz: PASSED

twine check renders your README exactly as PyPI will. This catches the failure the build cannot, because the build never validates markup:

Checking dist/wordtally-0.1.0-py3-none-any.whl: FAILED
ERROR    `long_description` has syntax errors in markup and would not be
         rendered on PyPI.
         line 4: Error: Unknown target name: "broken link reference".

That package builds, installs and runs perfectly. Its PyPI page would be a wall of raw text — permanently, because you cannot re-upload a version to fix it.

Then TestPyPI first — a real, separate instance of PyPI meant for exactly this:

twine upload --repository testpypi dist/*

pip install --index-url https://test.pypi.org/simple/ --no-deps wordtally

--no-deps matters: TestPyPI doesn’t mirror real PyPI, so your dependencies mostly aren’t there. You’re testing your artifact, not the resolver.

Only then, the real thing:

twine upload dist/*
Step Command Why
1. Clean rm -rf dist/ build/ Old artifacts get uploaded by dist/* too
2. Build python -m build Fresh sdist + wheel
3. Validate twine check dist/* Catches broken README before it’s permanent
4. Test-install pip install dist/*.whl in a clean venv Prove the artifact works, not your repo
5. TestPyPI twine upload --repository testpypi dist/* A rehearsal you can throw away
6. Verify pip install --index-url …/simple/ --no-deps pkg Install it as a stranger would
7. PyPI twine upload dist/* ⚠️ Permanent

On credentials: PyPI requires 2FA, and you authenticate with an API token (username __token__, password the token) — never your account password. Better still, if you release from CI, use Trusted Publishing: GitHub Actions proves its identity to PyPI over OIDC and gets a short-lived token, so no long-lived secret lives in your repo at all. Since a release is also a tag and a commit, Git & GitHub Workflow: Branches, PRs & Collaboration covers that half.

Now the part that catches people, and the reason to rehearse.

A name is global, first-come, and effectively permanent. There is one wordtally on PyPI for all of humanity, and names are normalised per PEP 503 — Word.Tally, word-tally, word_tally and WORD--TALLY are all the same name. If someone has it, you cannot have it.

A version is immutable. Once wordtally-0.1.0.tar.gz exists on PyPI, that filename can never be uploaded again — not after you delete it, not ever. Deleting a release doesn’t free the version; it only breaks everyone pinned to it. Shipped a typo at 1.0.0? You don’t fix 1.0.0, you ship 1.0.1. That asymmetry — trivially easy to publish, impossible to unpublish — is why steps 3 to 6 exist.


Dependencies: abstract vs concrete

This is the distinction most people get wrong, and getting it right is most of dependency management.

Abstract dependencies say what your code needs, as loosely as honestly possible. They live in pyproject.toml, they’re ranges, and they’re for libraries:

[project]
dependencies = [
  "requests>=2.31,<3",
  "rich>=13",
]

Concrete dependencies say what a specific working environment contained, exactly. They live in a lockfile, they’re pins of the entire resolved tree, and they’re for applications:

certifi==2026.6.17
charset-normalizer==3.4.9
idna==3.18
requests==2.31.0
rich==13.7.1
urllib3==2.7.0

The reason both exist: a library cannot know what else will be installed next to it. If your library pins requests==2.31.0, and the user’s other dependency needs requests==2.32.0, they can’t have both — and your package is the reason. You’ve made yourself un-co-installable to save yourself a compatibility test.

An application has the opposite problem. It’s the final word; nothing gets installed next to it. So “whatever’s newest” isn’t flexibility, it’s an uncontrolled variable: your July build and your August build install different code from the same commit, and you find out in production.

Library (people pip install it) Application (a service, a CLI you deploy)
Style Rangesrequests>=2.31,<3 Exact pinsrequests==2.31.0
Lives in pyproject.toml dependencies requirements.txt / uv.lock / poetry.lock
Scope Direct deps only The whole resolved tree
Who resolves Your user’s pip, at install time You, once, deliberately
Committed? Yes — it’s your contract Yes — it’s your build input
If you get it wrong Users hit ResolutionImpossible because of you “Works on my machine”; CI drifts from prod

The trap is doing it backwards, and it’s common in both directions: a library that pins exactly is antisocial, an application that doesn’t pin is unreliable. Plenty of projects are both — a library you also deploy — and then you do both: ranges in pyproject.toml for your users, a lockfile in the repo for your own CI. They answer different questions.

Extras are named optional groups. pip install wordtally gets nothing extra; pip install "wordtally[dev]" pulls in pytest and ruff:

[project.optional-dependencies]
dev = ["pytest>=8", "ruff>=0.6"]
docs = ["mkdocs-material>=9"]

Extras are for your users’ optional features (pandas[excel]), and by long convention also for dev tooling — which is why pip install -e ".[dev]" is the universal onboarding command. (Newer tooling has [dependency-groups] for dev-only deps that never ship in your metadata; extras remain the widely-supported option.)

Never put a dev dependency in dependencies — every user of your library would install pytest. It happens constantly. dependencies is what your code needs at runtime, nothing more.


Documentation that earns its keep

Docs aren’t a separate deliverable you write at the end. They’re four layers, each answering a different question at a different moment.

The README

Your README is your entire first impression: it renders on GitHub and on PyPI, and it’s the only documentation most users will ever read. It has one job — answer, in order, the questions a stranger actually has:

Question Section Failure if missing
What is this? One line, at the top They leave in 5 seconds
Why would I use it? 2-3 lines, honest about scope “How is this different from X?” issues
How do I install it? pip install wordtally They guess, and guess wrong
Show me it working A copy-pasteable example with real output They can’t tell if it fits
Where are the real docs? One link Every question becomes an issue
Can I legally use it? Licence line Companies can’t adopt it

That’s the whole list. The commonest README failure isn’t being too short — it’s opening with a badge wall and an architecture essay while never showing the tool running. The quickstart is the load-bearing part: a ## Quickstart heading, one fenced wordtally sample.txt -n 3, and the three lines it actually prints. Readers decide whether your tool fits from that block alone, and an example without its output is an assertion rather than a demonstration.

⚠️ A packaging-specific gotcha: your README ships inside your metadata, so relative image links (![](docs/logo.png)) resolve on GitHub and 404 on PyPI, which has no repo to resolve against. Use absolute URLs.

Docstrings

A docstring is a string literal as the first statement of a module, class or function. It isn’t a comment — it’s an attribute, readable at runtime by help(), your IDE, and every doc generator:

def top_words(text: str, n: int = 5) -> list[tuple[str, int]]:
    """Return the `n` most common non-stop words in `text`, highest first.

    Args:
        text: Raw text to analyse.
        n: How many words to return. Defaults to 5.

    Returns:
        A list of `(word, count)` pairs, most common first.

    Example:
        >>> top_words("the cat sat on the mat cat", n=1)
        [('cat', 2)]
    """
    return Counter(tokenize(text)).most_common(n)

PEP 257 sets the conventions: triple double-quotes always (even one-liners), a summary line in the imperative mood ending in a period, a blank line before any elaboration, and closing quotes on their own line for multi-line docstrings.

The format of the body is not standardised, and three conventions compete:

Style Looks like Pros Cons Used by
Google Args: / Returns: / Raises: Most readable as plain text; compact Needs the napoleon extension for Sphinx Google, most modern projects
NumPy Parameters with ---------- underlines Excellent for many params; scientific norm Verbose; lots of vertical space NumPy, SciPy, pandas, scikit-learn
reST :param text: / :returns: Sphinx-native, zero config Hard to read in the source Older Sphinx projects

Pick one and never mix. Google style is the right default for a new project: it reads well in the terminal, in your IDE hover, and rendered. NumPy if you’re in the scientific stack, because your neighbours use it. reST only if the project already does.

That Example: block with >>> isn’t decoration either — python -m doctest and pytest --doctest-modules will execute it and check the output. Docs that fail CI when they lie are the only docs that stay true.

Type hints as documentation

Look at the signature again:

def top_words(text: str, n: int = 5) -> list[tuple[str, int]]:

That line already told you the argument types, the default, and the exact return shape — before you read one word of prose. Type hints are documentation that cannot go stale, because mypy checks them and your IDE completes from them. Docstrings describe meaning (“non-stop words, highest first”); types describe shape. You want both, and the types mean the docstring doesn’t have to repeat itself.

Sphinx vs MkDocs, and a CHANGELOG

When the README stops being enough, you need a docs site:

Sphinx MkDocs (+ Material)
Source reStructuredText (Markdown via MyST) Markdown, natively
API docs from docstrings autodoc — mature, deep mkdocstrings — good, newer
Learning curve Steep Gentle
Cross-references Excellent (intersphinx links across projects) Basic
Looks good by default Not really Yes — Material is excellent
Best for Large libraries; the Python stdlib itself uses it Most projects; anything doc-site-shaped

Sphinx if you’re a big library with a big API surface and want intersphinx. MkDocs + Material otherwise — the barrier to writing is lower, and docs that get written beat docs that are theoretically better.

And the CHANGELOG: one Markdown file, newest version on top, grouped by Added / Changed / Deprecated / Removed / Fixed / Security (the Keep a Changelog convention). It’s not your git log — git log is what you did, a changelog is what changed for the user. Your MAJOR bump promised a breaking change; this is where you say which, and it’s the only thing between an upgrader and an issue on your tracker.


Tooling: ruff, mypy, and pre-commit

Four tools, and the modern answer is shorter than it used to be.

ruff is a linter and formatter in one, written in Rust, and it’s the modern choice by a wide margin — it replaces flake8, isort, pyupgrade, autoflake, pydocstyle, a pile of plugins, and (as a formatter) black, at 10-100× the speed. Config lives in pyproject.toml:

[tool.ruff]
line-length = 88
src = ["src", "tests"]

[tool.ruff.lint]
select = ["E", "F", "I", "UP", "B"]

Those five rule families are a good starting set: E pycodestyle, F pyflakes (real bugs — unused imports, undefined names), I isort (import sorting), UP pyupgrade (modernises old syntax), B bugbear (likely-bug patterns). Two commands:

ruff check .        # lint
ruff format .       # format
All checks passed!
4 files left unchanged

And when it isn’t happy, it’s specific:

F401 [*] `os` imported but unused
 --> src/wordtally/core.py:1:8
  |
1 | import os
  |        ^^
help: Remove unused import: `os`

Found 5 errors.
[*] 2 fixable with the `--fix` option.

[*] means ruff can fix it itself — ruff check --fix . and the unused import is gone.

black is the formatter ruff’s formatter deliberately imitates (near-identical output). Its real contribution was cultural: it ended formatting arguments by removing the options. If a project already uses black, leave it — mixing the two is pointless churn. For new projects, ruff format means one tool, one config, one install.

mypy checks your type hints statically — it catches top_words(text, n="5") before your users do. Start lenient and tighten:

[tool.mypy]
python_version = "3.12"
strict = true

strict = true on a new project from day one is realistic; on a large existing codebase it will produce thousands of errors and you should turn it on per-module instead.

pre-commit ties them together. It installs a git hook that runs your tools on staged files before each commit, so broken formatting never reaches the branch:

# .pre-commit-config.yaml
repos:
  - repo: https://github.com/astral-sh/ruff-pre-commit
    rev: v0.6.9
    hooks:
      - id: ruff
        args: [--fix]
      - id: ruff-format
pip install pre-commit && pre-commit install
Tool Job Config in Modern verdict
ruff Lint and format [tool.ruff] Use it. Replaces flake8 + isort + pyupgrade + black
black Format [tool.black] Fine, but ruff format matches it and is one fewer tool
flake8 Lint .flake8 (can’t use pyproject) Superseded by ruff
isort Sort imports [tool.isort] Superseded by ruff’s I rules
mypy Static type checking [tool.mypy] Use it; strict on new code
pytest Tests [tool.pytest.ini_options] The standard
pre-commit Run all of it on commit .pre-commit-config.yaml The glue. Same hooks in CI

The point of pre-commit isn’t the hooks — it’s that every contributor runs the same checks you do, without being told. Run the identical config in CI and formatting stops being a code review topic forever.


Hands-on lab

You’ll take the 40-line script from the top of this lesson and promote it into a real distributable: src/ layout, an entry point that becomes a shell command, an editable install, a README, ruff, a built wheel you’ll open up — and then you’ll break it on purpose to prove the src/ layout claim.

Needs Python 3.10+ (we use X | None syntax) and an internet connection. I’ll write python3.12; substitute your interpreter.

Step 0 — the script we’re starting from.

mkdir -p ~/pkgdemo && cd ~/pkgdemo

Create ~/pkgdemo/wordtally.py — 40 lines, works, goes nowhere:

#!/usr/bin/env python3
"""wordtally - report the most common words in a text file."""
import argparse
import re
import sys
from collections import Counter
from pathlib import Path

STOP_WORDS = {"the", "a", "an", "and", "or", "of", "to", "in", "is", "it", "that"}
WORD_RE = re.compile(r"[a-z']+")


def tokenize(text: str) -> list[str]:
    """Lowercase, split on words, drop stop-words."""
    return [w for w in WORD_RE.findall(text.lower()) if w not in STOP_WORDS]


def top_words(text: str, n: int = 5) -> list[tuple[str, int]]:
    """Return the n most common non-stop words, highest first."""
    return Counter(tokenize(text)).most_common(n)


def main() -> int:
    parser = argparse.ArgumentParser(description="Count the most common words in a file.")
    parser.add_argument("path", type=Path, help="text file to read")
    parser.add_argument("-n", "--number", type=int, default=5, help="how many words")
    args = parser.parse_args()

    if not args.path.is_file():
        print(f"wordtally: no such file: {args.path}", file=sys.stderr)
        return 1

    text = args.path.read_text(encoding="utf-8")
    for word, count in top_words(text, args.number):
        print(f"{count:>6}  {word}")
    return 0


if __name__ == "__main__":
    sys.exit(main())

And ~/pkgdemo/sample.txt:

Packaging is the part of Python that beginners skip and professionals cannot.
A script is a file you run. A module is a file you import. A package is a
directory of modules. A distribution is a package that other people can install.
Packaging is what turns a package into a distribution, and a distribution is
what pip install actually consumes. Packaging is boring right up to the moment
a colleague asks how do I run your tool and the honest answer is you cannot.
python3.12 wordtally.py sample.txt
     3  packaging
     3  you
     3  package
     3  distribution
     2  cannot

What just happened: it works — from this directory, with this interpreter. Change either and it doesn’t. That’s what we’re fixing.

Step 1 — build the src/ layout.

mkdir -p ~/pkgdemo/wordtally-pkg/src/wordtally ~/pkgdemo/wordtally-pkg/tests
cd ~/pkgdemo/wordtally-pkg
cp ~/pkgdemo/sample.txt .

Split the script into logic and CLI. src/wordtally/core.py:

"""Word counting, with no I/O and no CLI - just text in, counts out."""

import re
from collections import Counter

STOP_WORDS = frozenset(
    {"the", "a", "an", "and", "or", "of", "to", "in", "is", "it", "that"}
)
WORD_RE = re.compile(r"[a-z']+")


def tokenize(text: str) -> list[str]:
    """Lowercase `text`, split it into words, and drop stop-words.

    Args:
        text: Raw text to split.

    Returns:
        The surviving words, in document order.
    """
    return [w for w in WORD_RE.findall(text.lower()) if w not in STOP_WORDS]


def top_words(text: str, n: int = 5) -> list[tuple[str, int]]:
    """Return the `n` most common non-stop words in `text`, highest first.

    Args:
        text: Raw text to analyse.
        n: How many words to return. Defaults to 5.

    Returns:
        A list of `(word, count)` pairs, most common first.

    Example:
        >>> top_words("the cat sat on the mat cat", n=1)
        [('cat', 2)]
    """
    return Counter(tokenize(text)).most_common(n)

src/wordtally/cli.py:

"""Command-line entry point for wordtally."""

import argparse
import sys
from pathlib import Path

from wordtally import __version__
from wordtally.core import top_words


def main(argv: list[str] | None = None) -> int:
    """Parse arguments, read the file, print the tally. Returns an exit code."""
    parser = argparse.ArgumentParser(
        prog="wordtally", description="Count the most common words in a file."
    )
    parser.add_argument("path", type=Path, help="text file to read")
    parser.add_argument("-n", "--number", type=int, default=5, help="how many words")
    parser.add_argument(
        "--version", action="version", version=f"wordtally {__version__}"
    )
    args = parser.parse_args(argv)

    if not args.path.is_file():
        print(f"wordtally: no such file: {args.path}", file=sys.stderr)
        return 1

    text = args.path.read_text(encoding="utf-8")
    for word, count in top_words(text, args.number):
        print(f"{count:>6}  {word}")
    return 0


if __name__ == "__main__":
    sys.exit(main())

src/wordtally/__init__.py — the public API:

"""wordtally - report the most common words in a text file."""

__version__ = "0.1.0"

from wordtally.core import top_words

__all__ = ["top_words", "__version__"]

And tests/test_core.py:

from wordtally.core import tokenize, top_words


def test_tokenize_drops_stop_words():
    assert tokenize("The cat and a dog") == ["cat", "dog"]


def test_top_words_orders_by_count():
    assert top_words("cat cat dog", n=2) == [("cat", 2), ("dog", 1)]

What just happened: main() takes argv now, so tests can call it. core has no I/O, so it’s testable without a terminal. And note tests/ imports wordtally like a stranger — which currently cannot possibly work, because nothing is installed.

Step 2 — prove the src/ wall exists.

cd ~/pkgdemo/wordtally-pkg
python3.12 -c "import wordtally"
  File "<string>", line 1, in <module>
ModuleNotFoundError: No module named 'wordtally'

What just happened: this is the feature. You’re standing in the repo root and your own package is not importable, because src/ isn’t a package and src/wordtally/ isn’t in your CWD. There is no way to import this except by installing it. Remember this error — in a flat layout you’d never have seen it.

Step 3 — the pyproject.toml. Create it at ~/pkgdemo/wordtally-pkg/pyproject.toml:

[build-system]
requires = ["hatchling"]
build-backend = "hatchling.build"

[project]
name = "wordtally"
version = "0.1.0"
description = "Report the most common words in a text file."
readme = "README.md"
requires-python = ">=3.10"
license = "MIT"
license-files = ["LICENSE"]
authors = [{ name = "Your Name", email = "you@example.com" }]
keywords = ["text", "wordcount", "cli"]
classifiers = [
  "Development Status :: 3 - Alpha",
  "Environment :: Console",
  "Intended Audience :: Developers",
  "Programming Language :: Python :: 3",
  "Programming Language :: Python :: 3.12",
  "Topic :: Text Processing",
]
dependencies = []

[project.optional-dependencies]
dev = ["pytest>=8", "ruff>=0.6"]

[project.urls]
Homepage = "https://example.com/wordtally"
Source = "https://github.com/you/wordtally"
Issues = "https://github.com/you/wordtally/issues"

[project.scripts]
wordtally = "wordtally.cli:main"

[tool.ruff]
line-length = 88
src = ["src", "tests"]

[tool.ruff.lint]
select = ["E", "F", "I", "UP", "B"]

[tool.pytest.ini_options]
testpaths = ["tests"]
addopts = "-q"

Plus a README.md, a LICENSE (paste the MIT text with your name), and .gitignore:

.venv/
__pycache__/
*.pyc
build/
dist/
*.egg-info/
.pytest_cache/
.ruff_cache/

What just happened: dependencies = [] is honest — we use only the stdlib. requires-python = ">=3.10" is not decoration: list[str] | None in cli.py is a SyntaxError on 3.9.

Step 4 — venv, then pip install -e ..

python3.12 -m venv .venv
source .venv/bin/activate
python -m pip install -e ".[dev]"
Building editable for wordtally (pyproject.toml): finished with status 'done'
Successfully built wordtally
Installing collected packages: wordtally, ruff, pygments, pluggy, packaging, iniconfig, pytest
Successfully installed iniconfig-2.3.0 packaging-26.2 pluggy-1.6.0 pygments-2.20.0 pytest-9.1.1 ruff-0.15.21 wordtally-0.1.0

What just happened: hatchling found src/wordtally/ with no configuration, and [dev] brought the toolchain. The wall from Step 2 is down:

python -c "import wordtally; print(wordtally.__file__)"
cat .venv/lib/python3.12/site-packages/_editable_impl_wordtally.pth
/Users/you/pkgdemo/wordtally-pkg/src/wordtally/__init__.py
/Users/you/pkgdemo/wordtally-pkg/src/wordtally

The whole editable install is one line of text: the absolute path of your src/, appended to sys.path at startup.

Step 5 — collect the payoff.

which wordtally
wordtally sample.txt -n 3
wordtally --version
/Users/you/pkgdemo/wordtally-pkg/.venv/bin/wordtally
     3  packaging
     3  you
     3  package
wordtally 0.1.0

What just happened: your script became a command. Look at the shim pip wrote:

cat .venv/bin/wordtally
#!/Users/you/pkgdemo/wordtally-pkg/.venv/bin/python
# -*- coding: utf-8 -*-
import re
import sys
from wordtally.cli import main
if __name__ == '__main__':
    sys.argv[0] = re.sub(r'(-script\.pyw|\.exe)?$', '', sys.argv[0])
    sys.exit(main())

Seven lines: a shebang pinning the venv’s python, and a call to your function.

Step 6 — tests and ruff.

pytest
ruff check .
ruff format .
..                                                                       [100%]
2 passed in 0.00s
All checks passed!
4 files left unchanged

What just happened: pytest imported the installed package (via the editable .pth), not a directory that happened to be lying around. Now watch ruff earn its place — add a junk import at the top of src/wordtally/core.py:

import os
ruff check .
I001 [*] Import block is un-sorted or un-formatted
 --> src/wordtally/core.py:1:1
F401 [*] `os` imported but unused
 --> src/wordtally/core.py:1:8
  |
1 | import os
  |        ^^
help: Remove unused import: `os`

Found 5 errors.
[*] 2 fixable with the `--fix` option.
ruff check --fix .

What just happened: [*] marks what ruff can fix itself. The unused import is gone; ruff check is green again.

Step 7 — build, and open the box.

python -m pip install build twine
python -m build
ls dist/
Successfully built wordtally-0.1.0.tar.gz and wordtally-0.1.0-py3-none-any.whl
wordtally-0.1.0-py3-none-any.whl
wordtally-0.1.0.tar.gz
unzip -l dist/wordtally-0.1.0-py3-none-any.whl
Archive:  dist/wordtally-0.1.0-py3-none-any.whl
  Length      Date    Time    Name
---------  ---------- -----   ----
      164  02-02-2020 00:00   wordtally/__init__.py
     1045  02-02-2020 00:00   wordtally/cli.py
     1009  02-02-2020 00:00   wordtally/core.py
     1423  02-02-2020 00:00   wordtally-0.1.0.dist-info/METADATA
       87  02-02-2020 00:00   wordtally-0.1.0.dist-info/WHEEL
       49  02-02-2020 00:00   wordtally-0.1.0.dist-info/entry_points.txt
     1066  02-02-2020 00:00   wordtally-0.1.0.dist-info/licenses/LICENSE
      632  02-02-2020 00:00   wordtally-0.1.0.dist-info/RECORD
---------                     -------
     5475                     8 files

What just happened: three things. wordtally/ is at the top — no src/: the layout that protects you never reaches your users. No tests/ — they’re in the sdist, not the wheel. And the 02-02-2020 timestamps are hatchling zeroing them so the build is byte-reproducible.

unzip -p dist/wordtally-0.1.0-py3-none-any.whl wordtally-0.1.0.dist-info/entry_points.txt
twine check dist/*
[console_scripts]
wordtally = wordtally.cli:main
Checking dist/wordtally-0.1.0-py3-none-any.whl: PASSED
Checking dist/wordtally-0.1.0.tar.gz: PASSED

What just happened: your two lines of TOML became a tiny INI inside the zip — that’s the entire entry-point mechanism. And twine check rendered your README the way PyPI will. ⚠️ Stop here. Do not twine uploadwordtally is a tutorial name and PyPI is permanent.

Step 8 — now break it, and prove the point.

Switch to setuptools with a stale package name — exactly what happens when a project gets renamed and the config doesn’t:

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

[project]
name = "wordtally"
version = "0.1.0"
description = "Report the most common words in a text file."
readme = "README.md"
requires-python = ">=3.10"

[project.scripts]
wordtally = "wordtally.cli:main"

[tool.setuptools.packages.find]
where = ["src"]
include = ["wordcount*"]
rm -rf dist build
python -m build --wheel
unzip -l dist/wordtally-0.1.0-py3-none-any.whl | grep -c "\.py$"
Successfully built wordtally-0.1.0-py3-none-any.whl
0

What just happened: “Successfully built” — and zero Python files in the wheel. The glob matched nothing, so the backend shipped metadata and nothing else. No error, exit code 0.

python -m pip install --force-reinstall dist/wordtally-0.1.0-py3-none-any.whl
python -c "import wordtally"
pytest
  File "<string>", line 1, in <module>
ModuleNotFoundError: No module named 'wordtally'
    from wordtally.core import tokenize, top_words
E   ModuleNotFoundError: No module named 'wordtally'
=========================== short test summary info ============================
ERROR tests/test_core.py
!!!!!!!!!!!!!!!!!!!! Interrupted: 1 error during collection !!!!!!!!!!!!!!!!!!!!
1 error in 0.05s

What just happened: your src/ layout caught it in the first second. You cannot proceed.

Step 9 — what flat layout would have done. Same broken wheel, flat layout:

mkdir -p ~/pkgdemo/flat && cd ~/pkgdemo/flat
cp -r ~/pkgdemo/wordtally-pkg/src/wordtally ./wordtally
cp -r ~/pkgdemo/wordtally-pkg/tests .

Copy the same broken pyproject.toml, with where = ["."] instead of where = ["src"], then:

python3.12 -m venv .venv && source .venv/bin/activate
python -m pip install -q build pytest
python -m build --wheel
python -m pip install --force-reinstall dist/wordtally-0.1.0-py3-none-any.whl
python -c "import wordtally; print('import OK ->', wordtally.__file__)"
pytest
import OK -> /Users/you/pkgdemo/flat/wordtally/__init__.py
..                                                                       [100%]
2 passed in 0.01s

What just happened: there it is. Same empty wheel. Same broken install. “2 passed.” Read the import path — that’s your source directory, found via the CWD, not site-packages. Your CI is green and your artifact is hollow. Now be your user:

cd /tmp
~/pkgdemo/flat/.venv/bin/python -c "import wordtally"
~/pkgdemo/flat/.venv/bin/wordtally sample.txt
ModuleNotFoundError: No module named 'wordtally'
  File "/Users/you/pkgdemo/flat/.venv/bin/wordtally", line 5, in <module>
    from wordtally.cli import main
ModuleNotFoundError: No module named 'wordtally'

What just happened: that is the whole argument for src/ layout, and it took two directories to prove. Same bug: src/ made it un-hideable; flat handed you a green checkmark and shipped it. Confirm the mechanism with one flag:

cd ~/pkgdemo/flat
python    -c "import wordtally; print('shadowed ->', wordtally.__file__)"
python -P -c "import wordtally; print('with -P  ->', wordtally.__file__)"
shadowed -> /Users/you/pkgdemo/flat/wordtally/__init__.py
ModuleNotFoundError: No module named 'wordtally'

What just happened: -P (3.11+) removes the CWD from sys.path. The shadow disappears and the truth appears. The bug was always there — the layout decided whether you could see it.

Step 10 — repair, and finish clean. Restore the hatchling pyproject.toml from Step 3 in ~/pkgdemo/wordtally-pkg:

cd ~/pkgdemo/wordtally-pkg
rm -rf dist build
python -m pip install -e ".[dev]" --force-reinstall
python -m build
unzip -l dist/*.whl | grep -c "\.py$"
pytest && ruff check . && wordtally sample.txt -n 3
3
..                                                                       [100%]
2 passed in 0.00s
All checks passed!
     3  packaging
     3  you
     3  package

What just happened: three .py files in the wheel, green tests, clean lint, working command. Count the .py files in your wheel before every releaseunzip -l dist/*.whl | grep -c "\.py$" is the ten-second check that would have caught everything in Steps 8 and 9.

⚠️ Cleanup: rm -rf ~/pkgdemo deletes the lot. Check the path before you press enter.

Now try these:

  1. Add a second command — wordtally-json = "wordtally.cli:main_json", write main_json(), reinstall, run it. Why was a reinstall needed?
  2. Put a stopwords.txt next to core.py and rebuild. Is it in the wheel? Now switch to setuptools and rebuild. (Hatchling ships it; setuptools needs [tool.setuptools.package-data].)
  3. Make the version dynamic, then also leave version = "0.1.0" in [project]. Read the error.
  4. Set requires-python = ">=3.13" and install the wheel on 3.12. What exactly does pip say?
  5. Break your README with an undefined `link reference`_, rebuild, run twine check. Would python -m build have caught it?

Common mistakes and troubleshooting

Symptom / traceback Cause Fix
ModuleNotFoundError: No module named 'wordtally' right after pip install -e . src/ layout and the backend never found your package — check what’s in the wheel unzip -l dist/*.whl. If no .py files, your packages/include config is wrong or stale
Works in the repo, ModuleNotFoundError for users The flat-layout trap — you were importing the source dir via CWD Move to src/ layout. Right now: python -P -c "import pkg" or cd /tmp and import
error: Multiple top-level packages discovered in a flat-layout: ['helpers', 'wordtally'] setuptools auto-discovery refuses to guess with 2+ root dirs Move to src/ layout (setuptools’ own first suggestion), or set [tool.setuptools.packages.find] explicitly
Successfully built but the wheel has no .py files A stale/typo’d include glob after a rename. The build never fails for shipping nothing unzip -l dist/*.whl | grep -c "\.py$" before every release
wordtally: command not found The venv isn’t active — the shim lives in .venv/bin/ source .venv/bin/activate, or run .venv/bin/wordtally. For a global CLI use pipx install
Entry point runs but ModuleNotFoundError: No module named 'wordtally' inside the shim The command installed; the package didn’t Same as row 1 — the wheel is empty. pip show -f wordtally lists what landed
New module invisible after pip install -e . Strict editable mode (editable_mode=strict) maps files at install time pip install -e . --force-reinstall. Or drop strict mode — the default .pth picks up new files
Editable install imports the wrong code after moving the repo The .pth holds an absolute path that no longer exists pip uninstall wordtally && pip install -e . from the new location
ValueError: Metadata field \version` cannot be both statically defined and listed in field `project.dynamic`` version = "0.1.0" and dynamic = ["version"] Pick one. Delete version, or delete it from dynamic
ValueError: Missing \tool.hatch.version` configuration` You declared dynamic = ["version"] and never said where to read it Add [tool.hatch.version] with path = "src/pkg/__init__.py"
Data file missing at runtime (FileNotFoundError for a .txt/.json in your package) setuptools ships only .py by default [tool.setuptools.package-data]pkg = ["*.txt"], or include-package-data = true + MANIFEST.in. (Hatchling ships it by default)
twine check`long_description` has syntax errors in markup and would not be rendered on PyPI Broken README markup. The build doesn’t validate it Fix the markup, rebuild, re-check. You cannot fix it after upload — only a new version can
README images 404 on PyPI but work on GitHub Relative links have no repo to resolve against on PyPI Use absolute URLs
User on 3.9 installs happily, then gets SyntaxError from inside your library You forgot requires-python requires-python = ">=3.10" → pip refuses with requires a different Python: 3.9.6 not in '>=3.10'
HTTPError: 400 File already exists on twine upload Versions are immutable; that file was already published Bump the version. You cannot re-upload, even after deleting
Invalid classifier / 400 Bad Request on upload Classifiers are a fixed vocabulary — you invented one Copy from the official Trove list; twine check won’t catch this
Users can’t install your library alongside another You pinned exactly in dependencies Libraries use ranges (>=2.31,<3); pins belong in an app’s lockfile
Everyone who installs your library also gets pytest A dev dependency in [project] dependencies Move it to [project.optional-dependencies] dev
python setup.py install “works” but pip can’t uninstall it Deprecated, bypasses PEP 517, leaves untracked files pip install . — works on setup.py projects too
.pth file present but import still fails You edited src/ but the .pth points somewhere else, or two installs are fighting pip uninstall pkg (twice — check it’s gone), then reinstall

Three of these are worth more than a table row.

1. The build never fails for shipping nothing. This is the most dangerous fact in this lesson. Your linter fails on an unused import; your type checker fails on a bad annotation; your tests fail on a wrong answer. But python -m build will cheerfully print Successfully built over a wheel containing zero lines of your code, because “which files ship” is configuration, and an empty selection is a valid selection. Nothing in the toolchain finds this suspicious. So make it a habit, before every release:

unzip -l dist/*.whl | grep -c "\.py$"

If that number isn’t what you expect, stop. And test-install the artifact in a clean venv from a different directory, rather than trusting a green test run in your repo.

2. The day-one pain of src/ layout is the product. Beginners meet it, hit ModuleNotFoundError before writing a line, and conclude it’s ceremony. It’s the opposite: that error is the layout doing its only job — refusing to import code that isn’t properly installed. Flat layout doesn’t spare you the error, it defers it from your machine to your user’s. Cost: one pip install -e .. Benefit: “works on my machine” becomes structurally impossible for this class of bug — which also covers missing package data, a missing __init__.py in a subpackage, and any include/exclude mistake, all of which flat layout hides identically.

3. Publishing is one-way. Everything else in software has an undo; PyPI doesn’t. You cannot re-upload a version — not after deleting it, not with a support ticket. Deleting a release doesn’t free the version, it just breaks everyone pinned to it. And the name is global and first-come for all of humanity. That asymmetry is the entire reason for twine check, TestPyPI, and installing your own wheel in a clean venv first — and why this lab stops at twine check.


Cheat-sheet

Command / snippet What it does
Layout
src/pkg/, tests/, pyproject.toml The canonical tree. tests/ outside src/
python -c "import sys; print(sys.path[0])" '' = CWD — the flat-layout shadow, in one command
python -P -c "import pkg" 3.11+: import without the CWD shadow. The truth
pyproject.toml
[build-system] requires = ["hatchling"] What pip installs to build you
build-backend = "hatchling.build" Who does the building
[project] name, version Required. version unless it’s dynamic
requires-python = ">=3.10" Don’t skip. pip refuses old interpreters for you
dependencies = ["requests>=2.31,<3"] Ranges for libraries
[project.optional-dependencies] dev = [...] pip install -e ".[dev]"
[project.scripts] cmd = "pkg.cli:main" The payoffcmd becomes a shell command
license = "MIT" + license-files = ["LICENSE"] PEP 639 SPDX form
dynamic = ["version"] + [tool.hatch.version] Single source of truth in __init__.py
[tool.ruff] · [tool.pytest.ini_options] · [tool.mypy] Tool config, same file, no dotfiles
Develop
python3.12 -m venv .venv && source .venv/bin/activate Always. Every project
pip install -e ".[dev]" Editable install + dev tools. The dev command
cat .venv/lib/python3.12/site-packages/*editable*.pth See the one line that makes -e work
pip show -f wordtally What actually got installed, file by file
Build
python -m build sdist + wheel into dist/
python -m build --wheel Wheel only (faster)
unzip -l dist/*.whl Look inside the box. Do this every time
unzip -l dist/*.whl | grep -c "\.py$" Count shipped modules. If it’s 0, stop
unzip -p dist/*.whl *.dist-info/METADATA The metadata pip will read
unzip -p dist/*.whl *.dist-info/entry_points.txt Your commands, as shipped
tar tzf dist/*.tar.gz What’s in the sdist (has tests/; the wheel doesn’t)
Publish
twine check dist/* Does the README render on PyPI? Nothing else checks
twine upload --repository testpypi dist/* Rehearse on TestPyPI
pip install --index-url https://test.pypi.org/simple/ --no-deps pkg Install the rehearsal
twine upload dist/* ⚠️ Permanent. Name is global; version is immutable
Tools
ruff check . · ruff check --fix . Lint; [*] rules auto-fix
ruff format . Format (black-compatible)
mypy src/ Type-check
pytest Test the installed package
pre-commit install Run all of it before every commit
Versions
PATCH 1.4.2→1.4.3 · MINOR →1.5.0 · MAJOR →2.0.0 Fix · add · break
0.x.y “Anything may change.” Honest, not lesser

Interview and exam questions

Q: What’s the difference between a package and a distribution? A: A package is what you import — a directory of modules with an __init__.py. A distribution is what you pip install — a package plus metadata (name, version, dependencies, entry points), built into an sdist or wheel. They usually share a name but don’t have to: you pip install pillow and import PIL; you pip install scikit-learn and import sklearn. Make yours match unless you have a reason.

Q: Why src/ layout? Give the real reason, not the tidiness one. A: Because sys.path[0] is the current working directory. In a flat layout, import mypkg from the repo root finds the source directory before site-packages — so your tests pass whether or not the install works. In src/ layout there’s no path from your CWD to the package, so the import can only succeed if the install genuinely worked; your test suite becomes physically incapable of testing anything but the installed artifact. The demo: given a wheel with a stale include glob shipping zero .py files, flat layout reports “2 passed” while src/ can’t even collect the tests.

Q: What does [build-system] do, and why does the build create an isolated environment? A: requires lists build-time dependencies; build-backend names the object pip calls to build you. Per PEP 517, pip installs requires into a fresh temporary environment rather than using your venv — so builds are reproducible on machines that have never heard of your backend, and your dev environment can’t influence the artifact. It’s why python -m build prints “Creating isolated environment”.

Q: sdist or wheel — what’s the difference, and which do you ship? A: An sdist is your source archived (src/, tests/, pyproject.toml); installing it runs your build backend on the user’s machine, which may need a compiler. A wheel is the built result; installing it is just unzipping into site-packages — no code runs, no compiler needed. Ship both: the wheel is what 99% of users get, the sdist is the fallback and what packagers need. py3-none-any means pure Python, one file for everyone.

Q: How does mytool become a shell command? A: [project.scripts] mytool = "mypkg.cli:main" — command name, then package.module:function. At build time it becomes entry_points.txt inside the wheel; at install time pip reads that and generates .venv/bin/mytool, a ~7-line script whose shebang hardcodes the venv’s interpreter and which does from mypkg.cli import main; sys.exit(main()). That’s why main() should return an exit code, and why the command disappears when you deactivate.

Q: What does pip install -e . actually do? A: It writes a .pth file into site-packages containing the absolute path of your src/ directory, plus real dist-info metadata. Python appends .pth lines to sys.path at startup, so import mypkg reads the files you’re editing right now, from any directory, with no reinstall. It’s a text file holding an absolute path — so moving the repo breaks it, and it’s for development only.

Q: A colleague pins requests==2.31.0 in their library’s dependencies. What do you tell them? A: That it makes the library un-co-installable. Any user whose other dependency needs a different requests gets ResolutionImpossible, and their package is the cause. Libraries declare abstract dependencies — ranges like requests>=2.31,<3 — because they can’t know what will be installed next to them. Exact pins are for applications, in a lockfile, where you control the deployment and want identical bytes everywhere. Same project can do both: ranges in pyproject.toml, a lockfile for its own CI.

Q: python -m build printed “Successfully built” but users get ModuleNotFoundError. Debug it. A: The wheel is probably empty. unzip -l dist/*.whl — if there are no .py files, the backend selected nothing: a stale include glob after a rename, a wrong where, or a packages list that doesn’t match the directory. The build won’t fail for this, because an empty file selection is a valid selection. Then ask why you didn’t notice — almost always flat layout, where your CWD supplied the package your tests imported. Fix the config, verify with unzip -l, and install the wheel in a clean venv from another directory.

Q: What does a MAJOR version bump promise? A: That something a user relied on is gone or behaves differently, so they must read the changelog before upgrading. It’s decided by their code, not your intent: renaming a parameter is MAJOR even if the old name was a typo; changing a default from n=5 to n=10 is MAJOR because every caller relying on it silently changes behaviour; dropping a Python version is MAJOR. Adding an optional keyword is MINOR. Clearer error text is PATCH. And 0.x means “I reserve the right to break you” — honest, not lesser.

Q: What must a README answer, and what does twine check do that the build doesn’t? A: What is this, why use it, how to install, a copy-pasteable quickstart with real output, a link to full docs, and the licence — in that order. twine check renders your README exactly as PyPI will and fails on broken markup: `long_description` has syntax errors in markup and would not be rendered on PyPI. The build never validates markup, so a package that builds, installs and runs perfectly can still have an unreadable PyPI page — permanently, since you can’t re-upload a version to fix it.

Q (practical): Promote tool.py into an installable CLI called tool. Commands only. A:

mkdir -p tool-pkg/src/tool tool-pkg/tests && cd tool-pkg
# move the logic to src/tool/core.py, the argparse main() to src/tool/cli.py
python3.12 -m venv .venv && source .venv/bin/activate
python -m pip install -e ".[dev]"
tool --help

With this pyproject.toml:

[build-system]
requires = ["hatchling"]
build-backend = "hatchling.build"

[project]
name = "tool"
version = "0.1.0"
requires-python = ">=3.10"

[project.scripts]
tool = "tool.cli:main"

The points being tested: src/ layout, hatchling needing no package config, main() returning an exit code, and -e ".[dev]" as the one onboarding command.

Q (practical): You inherit a repo with setup.py, setup.cfg, .flake8 and MANIFEST.in. What do you do? A: Nothing hasty — it isn’t broken, and setuptools is maintained. First, stop running python setup.py (deprecated, bypasses PEP 517, and setup.py install leaves files pip can’t uninstall); use pip install . and python -m build, which work on setup.py projects fine. Then migrate incrementally: [build-system] with setuptools>=61, setup() kwargs into [project] (install_requiresdependencies, python_requiresrequires-python, entry_points[project.scripts]), .flake8[tool.ruff], MANIFEST.in[tool.setuptools.package-data]. Keep a setup.py only for a genuinely dynamic build like a C extension. Verify each step with unzip -l dist/*.whl — the artifact is the only thing that counts.


Key takeaways


Next: your pyproject.toml now declares dependencies, and pip & Virtual Environments: Isolating Dependencies the Right Way covers the pinning-versus-ranges half in full — ~=, ResolutionImpossible, lockfiles and all. If the sys.path mechanics behind the src/ argument were the interesting part, Modules, Packages, Imports & the Standard Library takes import apart properly. And since a release is a tag and a version bump is a commit, Git & GitHub Workflow: Branches, PRs & Collaboration covers the half of publishing that happens before twine upload.

pythonpackagingpyproject-tomlsrc-layoutwheelsdisthatchlingsetuptoolsentry-pointstwinepypiruffsemverdocstrings
Need this built for real?

Vinod is a Senior Cloud Architect (22+ yrs) — available for Azure / AWS / GCP architecture, landing zones, and migrations.

Work with me

Comments