Modules, Packages, Virtualenvs, pip & uv
How Python code is organised, versioned, and shipped — from a single script to an installable package, with reproducible envs on every machine.
Original publication date unrecordedUpdated
Lesson preparation & details
Level: beginner
By the end, you should be able to
- Ordinary successful imports cache in sys.modules, but reload, failures, aliases and custom import hooks complicate once-only wording
Bring with you
Sources checked: . A source check does not change the publication or update date.
Editorial review: · What review means
In this article · 20 sections
Review boundary and corrected contracts
Ordinary successful imports cache in sys.modules, but reload, failures, aliases and custom import hooks complicate once-only wording. Namespace packages need no init.py. A venv normally excludes system site-packages. requirements files may be fully pinned/hashed; a lockfile alone does not guarantee platform-independent reproduction.
The examples below teach the stated input contracts; fragments and deliberately incorrect counterexamples are exercises, not production implementations. Complexity counts elementary operations unless a bit-cost or allocation caveat is stated. Practice times are suggestions, not measured performance or hiring guarantees.
Learning objective: Turn Python source into an installable, testable package and distinguish an editable development install from a locked, clean-environment check.
Why this session exists
Moving from one Python file to a package introduces several separate questions: how modules find each other, which environment supplies dependencies, and what an installer actually installs. The ImportError: attempted relative import with no known parent package, the "why does it work in the REPL but not when I run the file", the pip install pandas that breaks another project — all of it is the same story: you're missing the mental model of what a module is, what a package is, and what a virtual environment does. This session builds that model and uses uv to make the corresponding steps explicit. Tools automate resolution and installation; they do not eliminate platform, dependency or import-design errors.
You will be able to
-
Explain the difference between a script, a module, a package, and a distribution — in one sentence each.
-
Read Python's import resolution order (sys.path) and predict where an import will resolve from.
-
Turn a folder of .py files into an installable package with a pyproject.toml and
uv pip install -e . -
Create + pin an isolated environment with
uv venvand reproduce it on a fresh machine from a lockfile. -
Diagnose relative-import context, regular-package layout, working-directory, wrong-environment and shadowed-name errors. A namespace package intentionally need not have
__init__.py.
Prerequisites
- S001 · Dev Environment — you need
uvinstalled and know how to activate a venv. - S005–S011 — comfortable writing functions, classes, and running Python from the command line.
Pre-read · skim before the session (30 min total)
Further reading: Python Tutorial · Modules
Further reading: uv — the docs
Further reading: Packaging Python Projects
(a) Intuition · 5 min
Modules, packages, distributions — a library metaphor
Real:
Think of your Python project as a library building. A single book is a module — one .py file with related functions inside. A shelf holding related books is a regular package — a folder with an __init__.py and several modules. Namespace packages can span directories and omit that file. A whole section with many shelves and a catalogue at the front is a distribution — the thing you actually publish to PyPI so others can install it.
The card catalogue at the entrance is pyproject.toml. It describes the distribution and build configuration; Python import discovery itself uses the running interpreter's import machinery.
Concretely: math is a module, numpy is a package, and the file on PyPI called numpy-2.1.0-cp312-cp312-linux_x86_64.whl is a distribution.
You write modules. You organise them into packages. You describe the package as a distribution in pyproject.toml. Then uv build turns your source tree into a .whl that pip install understands.
The three problems the whole packaging stack solves
Every packaging tool exists to solve one of these
-
Discoverability — Python needs to know WHERE to look when you write
import foo. That's sys.path. -
Isolation — project A's numpy 2.1 must not collide with project B's numpy 1.24. That's virtual environments.
-
Reproducibility — a coworker (or future-you on a fresh laptop) must be able to install the exact same versions. That's lockfiles.
Start import debugging with the running interpreter
A: Ordinary import resolution involves sys.modules, finders and sys.path. The exact path depends on launch mode and environment; venvs exclude system site-packages by default unless configured otherwise.
A quick history so you know why the world looks like this
The packaging layers accumulated rather than arriving as one tool. PEP 518 describes the earlier distutils/setuptools setup.py model and introduced pyproject.toml to declare build-system dependencies; the PEP was created in May 2016, not 2018. PEP 405 specifies the virtual-environment mechanism for Python 3.3, building on existing environment tools. Today pip, build backends and project managers such as uv occupy different layers, so adopting one does not rewrite Python's import rules.
(b) Connecting import resolution to installed packages · 15 min
How an import actually resolves
The packaging tools have changed, but an import still needs to locate a module in the running interpreter’s environment. Follow the cache hit and cache miss paths separately in this simplified regular-package example.
Figure text equivalent — labels and relationships
- Node “import mypkg.utils” (square).
- Node “Is 'mypkg.utils' in sys.modules as a module object?” (diamond).
- Node “Reuse the cached submodule” (square).
- Node “Import or reuse parent package mypkg” (square).
- Node “Find mypkg.utils using import finders and the parent's __path__” (square).
- Node “Submodule spec found?” (diamond).
- Node “Create submodule; cache before execution” (square).
- Node “Execute via loader; bind utils on parent after success” (square).
- Node “Raise ModuleNotFoundError” (square).
- “import mypkg.utils” leads to “Is 'mypkg.utils' in sys.modules as a module object?”. Line: normal; ends: arrow point.
- “Is 'mypkg.utils' in sys.modules as a module object?” leads to “Reuse the cached submodule”: yes. Line: normal; ends: arrow point.
- “Is 'mypkg.utils' in sys.modules as a module object?” leads to “Import or reuse parent package mypkg”: no. Line: normal; ends: arrow point.
- “Import or reuse parent package mypkg” leads to “Find mypkg.utils using import finders and the parent's __path__”. Line: normal; ends: arrow point.
- “Find mypkg.utils using import finders and the parent's __path__” leads to “Submodule spec found?”. Line: normal; ends: arrow point.
- “Submodule spec found?” leads to “Create submodule; cache before execution”: yes. Line: normal; ends: arrow point.
- “Create submodule; cache before execution” leads to “Execute via loader; bind utils on parent after success”. Line: normal; ends: arrow point.
- “Execute via loader; bind utils on parent after success” leads to “Reuse the cached submodule”. Line: normal; ends: arrow point.
- “Submodule spec found?” leads to “Raise ModuleNotFoundError”: no. Line: normal; ends: arrow point.
A cached parent package is not enough: mypkg can be present while mypkg.utils has never been imported. The full submodule name has its own cache entry. This diagram shows ordinary successful imports; a None cache entry, loader failures, reloads and custom import hooks require the fuller Python import reference. For regular filesystem packages, the parent's __path__ guides the submodule search; namespace packages and non-filesystem loaders also exist.
The layers of the packaging stack
From source tree to installed package
Name: 1 · Source tree
Desc: Python modules arranged into regular packages with __init__.py, or deliberately designed namespace packages without it.
Name: 2 · pyproject.toml
Desc: [project] holds standardized metadata (PEP 621); [build-system] declares build requirements and backend (PEP 518/517). [tool.*] tables configure individual tools.
Name: 3 · Build backend (hatchling, setuptools)
Desc: Reads pyproject.toml, packages your source tree into a wheel (.whl) or sdist (.tar.gz).
Name: 4 · Wheel — the .whl file
Desc: A ZIP-format built distribution with code and installation metadata. Installation also handles scripts and records; compatible platform/Python tags matter for binary wheels.
Name: 5 · pip / uv install
Desc: Installs compatible distributions into the selected Python environment, resolving dependencies as requested. Source distributions may require a build. Site-packages paths differ by platform; a venv is not selected merely because one exists.
Name: 6 · site-packages
Desc: The folder Python looks in for third-party imports. Your venv has its own; the system has another.
The install path — one time per project
uv init myproj
Desc: In uv 0.12, the default application template includes a build system, src-layout package and console command. Before 0.12, applications did not define a build system by default. Use uv init --lib myproj for the library template, or inspect your installed version before relying on generated files.
cd myproj && uv add requests
Desc: Adds the requirement, updates the resolution in uv.lock, and synchronizes the project environment by default. This is a workflow, not a promise of an atomic transaction across files and installed packages.
uv run myproj
Desc: Runs the generated uv 0.12 application command inside the project environment without manual activation. For an existing script, use uv run python path/to/script.py; default templates vary by uv version.
uv lock
Desc: Resolve project dependencies into uv.lock. Commit it so later installations can use that resolution; environment markers can select different dependencies on different Python/platform combinations.
uv sync
Desc: Creates or synchronizes the project environment. By default it can refresh an outdated lockfile; use uv sync --locked to fail instead. Reproduction still depends on Python, platform, build inputs and artifact availability.
pip vs uv vs poetry vs conda
Name: uv
Tagline: The project manager used in this lesson
-
Implemented in Rust; measure performance for your workload rather than assuming a fixed speed ratio
-
Manages Python versions too
-
Combines environment, dependency, lockfile and Python-management workflows; not every command is interchangeable with another tool
-
Lockfile is a first-class citizen
-
Use this for new projects
Name: pip + venv
Tagline: Standard environment and installer tools
-
venv is in the standard library; pip bootstrapping/availability depends on the Python distribution
-
Requirements can pin versions and hashes; current pip also documents experimental
pip lock, bound to the current Python/platform -
Resolves dependencies during installation unless given a sufficiently constrained input
-
Still the lingua franca — you'll always know it
-
Fine for tiny scripts
Name: Poetry
Tagline: An alternative project and packaging workflow
-
Uses poetry.lock for resolved dependencies
-
Current Poetry supports standardized
[project]metadata alongside tool-specific configuration -
Check the repository's supported Poetry version before migrating configuration
-
Still popular in existing repos
-
Use it if the repo already does
Name: conda / mamba
Tagline: Data-science land
-
Manages non-Python deps too (CUDA, MKL)
-
Own package repo (Anaconda / conda-forge)
-
Heavy, opinionated
-
Use when you need C/CUDA libraries pip cannot ship
-
Overkill for pure Python
The mental model to hold
Two words: environment and lockfile
First identify the interpreter and its environment, then the project's dependency resolution. Also check module naming, initialization order, platform compatibility and build inputs: neither a venv nor a lockfile fixes those automatically.
Myth: "import finds and runs the file each time I import it. So if two modules both import config, each gets its own copy — and re-importing is how I pick up changes."
Truth: In an ordinary successful import, the fully qualified name is cached in sys.modules and later imports of that name reuse its module object. Importers can therefore share mutable module state. Reload, failed initialization, cache manipulation or loading the same file under another name can execute code again; the cache is per interpreter, not a universal process-wide singleton guarantee.
Why: Because "import = load the file" is the right intuition for the first import and produces correct predictions for pure-function modules, which is most of what you write early on. It breaks in three places at once: module-level mutable state (a cache, a connection, a registry) is shared by everyone who imports it and persists for the process lifetime; expensive module-level work runs during initial loading, which can be triggered by an indirect import; and editing a file has no effect on a running interpreter, which is why a notebook keeps using your old function until you restart or explicitly importlib.reload. The caching is not an optimisation detail — it is what makes circular imports possible at all, and also what makes them fail in the particular half-initialised way they do.
Test: Same object, every time — and the cache is inspectable:# mod.py # print('executing mod') # registry = []
import mod # prints 'executing mod' import mod as m2 # prints nothing mod.registry.append(1) print(m2.registry) # [1] <- same object
import sys print(sys.modules['mod'] is mod) # True print(mod.__file__) # where it actually came from
Question: Why do circular imports sometimes work and sometimes fail with "cannot import name X from partially initialized module"? The dependency cycle is identical in both cases — derive what makes the difference.
Premise: Importing a module means executing its top-level code, statement by statement, to build its namespace.
Because: Python has no separate declaration phase; definitions are produced by running the file
Premise: To prevent infinite recursion, the module object is inserted into sys.modules before its body runs — initially empty, filling up as execution proceeds.
Because: a cycle would otherwise re-enter the same import forever
Premise: So if module A imports B, and B imports A while A is still executing, B receives the real A object — but only partially populated, containing whatever A defined above its import of B.
Because: the cache hit succeeds; only the contents are incomplete
Premise: In this ordinary cycle, import a can bind the already-cached, partially initialized module; accessing a.X immediately still fails if X is not defined. from a import X requests X immediately. Imports are not unconditionally successful: loading or code executed around the cycle can raise exceptions.
Because: from ... import performs an attribute lookup at import time, and the attribute may not exist yet
Premise: A reference used only inside a function body is resolved at call time. It works if both modules have finished initializing before the function is called.
Because: function bodies are compiled at definition but names in them are looked up on execution
Deferring an import can break an initialization-time cycle, but is not a universal fix: the function may run while the other module is still initializing. Prefer clarifying module responsibilities.
Name: Import is execution plus a cache
Picture: Import checks the name in sys.modules first. On a cache miss, finders locate a module; for ordinary Python source the loader creates and caches a module object before executing its body. A failed load normally removes that failing cache entry. A module is a live namespace, not necessarily a file: builtins, extensions and custom loaders also exist. In the successful cached case, later importers share that namespace rather than rerunning its source.
-
Module top level should define, not do. Connections, file reads, and expensive setup at import time make import order significant and testing painful — hide them behind a function or a lazy accessor.
-
Absolute imports everywhere; relative (from .x import y) only inside a package. Never rely on the script's directory being on sys.path — that's why python -m pkg.mod and python pkg/mod.py behave differently.
-
if __name__ == "__main__": exists because a module can be either imported or run, and the same file must not execute its CLI when imported.
-
Ship with pyproject.toml and install with pip install -e . during development. Manipulating sys.path in code is a symptom that packaging was skipped.
Trigger: Fire this model the moment you see: ImportError: cannot import name ... (most likely due to a circular import) · sys.path.append at the top of a file · a notebook still running old code after an edit · a module that opens a database connection when imported · tests that pass alone and fail together.
Decision: You have shared utility code used by three services. Do you copy it, extract an internal package, or keep everything in a monorepo?
Name: Copy-paste into each service
Gain: no shared library release process; each service changes its local copy independently. Services can still be coupled through protocols, schemas and business rules
Cost: fixes must be applied N times and inevitably won't be; the copies diverge silently until they subtly disagree — worst when the code encodes business rules or schema
Pick: the shared code is small, stable, and genuinely incidental — a helper function, not a contract; if it encodes a rule two services must agree on, this is already wrong
Name: Internal versioned package
Gain: one source of truth, a real API with semantic versions, and consumers upgrade on their own schedule instead of being broken by your merge
Cost: you now run a release process — build, publish, changelog, private index — and consumers drift onto old versions; a bug fix reaches production only as fast as the slowest consumer upgrades
Pick: consumers are owned by different teams or deploy on different schedules; versioning is exactly the mechanism for decoupling release cadences
Name: Monorepo, imported directly
Gain: one commit can update a library and its callers with CI checking the combination. A monorepo does not itself prevent different deployed services from running different revisions
Cost: requires real build tooling to keep CI times sane, and everything must be deployable together or you reintroduce skew at runtime anyway
Pick: one org owns all consumers and can deploy them in lockstep, and you have (or will build) the tooling for selective test execution
Verdict: The decision is about who absorbs the cost of a change, not about code reuse. Versioned packages push it onto consumers at a time of their choosing; monorepos push it onto the author immediately; copies push it onto whoever discovers the divergence months later, which is the only genuinely bad answer for code carrying business logic.The practical trap in the middle option is under-specified dependencies. An internal package with insufficiently tested dependency ranges can allow incompatible combinations. Declare supported ranges in distribution metadata and test them; a library's development lockfile is not automatically imposed on its consumers. Each service should lock its own deployment resolution.
(c) Hands-on · 25 min
You're going to create wordstats, a small ASCII-word counter, and check its editable installation, imports, tests and console entry point in a new temporary directory. The script may download Python/build/test dependencies and leaves its temporary directory for inspection; it is not a locked build or a cross-machine reproducibility proof. Review it before running bootstrap.sh. Its tokenizer accepts ASCII letters and apostrophes, not general multilingual word boundaries.
#!/usr/bin/env bash
# bootstrap.sh — build a real Python package from scratch.
# Requires: uv (see S001). Each run creates a separate temporary project.
set -euo pipefail
PROJECT="wordstats"
log() { printf "\033[1;36m▶ %s\033[0m\n" "$*"; }
log "1/8 Fresh project folder"
WORK_DIR=$(mktemp -d "${TMPDIR:-/tmp}/packaging-workshop.XXXXXX")
mkdir -p "$WORK_DIR"
cd "$WORK_DIR"
log "2/8 Package skeleton — src layout is the modern default"
mkdir -p "src/$PROJECT" tests
cat > "src/$PROJECT/__init__.py" <<'PY'
"""wordstats — count words in text, the boring way."""
from .core import word_counts, top_n
__all__ = ["word_counts", "top_n"]
__version__ = "0.1.0"
PY
cat > "src/$PROJECT/core.py" <<'PY'
"""Core word-counting logic."""
from collections import Counter
from pathlib import Path
import re
_WORD_RE = re.compile(r"[A-Za-z']+")
def word_counts(text: str) -> Counter[str]:
"""Return a Counter of lowercased words in `text`."""
return Counter(w.lower() for w in _WORD_RE.findall(text))
def top_n(text: str, n: int = 10) -> list[tuple[str, int]]:
"""Return the n most common (word, count) pairs."""
return word_counts(text).most_common(n)
def from_file(path: str | Path, n: int = 10) -> list[tuple[str, int]]:
"""Convenience: read a file and return top-n words."""
return top_n(Path(path).read_text(encoding="utf-8"), n)
PY
cat > "src/$PROJECT/__main__.py" <<'PY'
"""Enable `python -m wordstats path/to/file.txt`."""
import sys
from .core import from_file
def main(argv: list[str] | None = None) -> int:
if argv is None:
argv = sys.argv
if len(argv) < 2:
print("usage: python -m wordstats <file.txt> [n]", file=sys.stderr)
return 2
n = int(argv[2]) if len(argv) > 2 else 10
for word, count in from_file(argv[1], n):
print(f"{count:>6} {word}")
return 0
if __name__ == "__main__":
raise SystemExit(main(sys.argv))
PY
log "3/8 The card catalogue — pyproject.toml"
cat > pyproject.toml <<'TOML'
[project]
name = "wordstats"
version = "0.1.0"
description = "Count words in a text file — the boring way."
readme = "README.md"
requires-python = ">=3.11"
dependencies = []
[project.scripts]
wordstats = "wordstats.__main__:main"
[build-system]
requires = ["hatchling"]
build-backend = "hatchling.build"
[tool.hatch.build.targets.wheel]
packages = ["src/wordstats"]
TOML
cat > README.md <<'MD'
# wordstats
Tiny word-frequency tool. `python -m wordstats file.txt`.
MD
log "4/8 A test — proves the package is real"
cat > tests/test_core.py <<'PY'
from wordstats import word_counts, top_n
def test_word_counts_is_case_insensitive():
assert word_counts("The the THE")["the"] == 3
def test_top_n_returns_sorted_pairs():
text = "a a a b b c"
assert top_n(text, 2) == [("a", 3), ("b", 2)]
PY
log "5/8 Isolated venv + editable install"
uv venv .venv --python 3.12
# shellcheck disable=SC1091
source .venv/bin/activate
uv pip install -e . pytest
log "6/8 Show sys.path so you SEE where imports resolve from"
python - <<'PY'
import sys, wordstats
print("wordstats loaded from:", wordstats.__file__)
print("first 4 sys.path entries:")
for p in sys.path[:4]:
print(" -", p)
PY
log "7/8 Run the tests"
pytest -q
log "8/8 Use the CLI entry point"
echo "Hello hello HELLO world" > sample.txt
wordstats sample.txt 5What each block does
Anatomy of the script
Name: Step 2 · src/ layout
Desc: The src/wordstats/ folder isolates your package from your working directory. Prevents the classic ‘tests import from cwd not from installed package’ bug.
Name: Step 2 · __init__.py re-exports
Desc: By importing core.word_counts here, you let users write from wordstats import word\_counts instead of from wordstats.core import word\_counts. Public API lives in __init__.py.
Name: Step 2 · __main__.py
Desc: Enables python -m wordstats …. This is how stdlib modules like http.server and venv are runnable — copy that idiom.
Name: Step 3 · [project.scripts]
Desc: Registers a shell command called wordstats. The generated console-script wrapper calls main() with no arguments, so the function defaults to sys.argv; passing an explicit list remains useful in tests. This follows the PyPA entry-point contract.
Name: Step 3 · [build-system]
Desc: Tells pip/uv which backend and build requirements to use. This example chooses Hatchling and explicitly lists the package path; it is not a claim that every tool defaults to that backend.
Name: Step 5 · uv pip install -e .
Desc: Editable installation exposes your source tree through the backend's editable mechanism. A new interpreter normally sees source edits without reinstalling; an already-imported module remains cached until deliberately reloaded or the process restarts. Metadata or entry-point changes can require reinstalling.
Name: Step 6 · print(sys.path)
Desc: The single most useful debugging print in the entire Python ecosystem. When an import fails, print this first.
What to observe
ClassName: ml-4 list-decimal space-y-1
wordstats.__file__ should point inside src/wordstats/__init__.py for this Hatchling editable install.
pytest -q should show 2 passed. For ModuleNotFoundError, check the selected interpreter, installation result and package layout rather than assuming activation is the only cause.
which wordstats resolves inside .venv/bin/wordstats — the entry point is installed as a real command.
Edit src/wordstats/core.py, re-run tests, changes take effect without re-install.
Goal: Check locked synchronization, then distinguish it from a fresh-machine test
Hint: After locking, run uv sync --locked to require that the lockfile agrees with project metadata. This synchronizes dependencies in the existing environment; it is not evidence of a fresh-machine or byte-for-byte reproduction. A clean-environment test must separately record Python, platform and artifact inputs.
Run these commands and observe:
# 1. Convert this project to uv-native layout
uv add pytest --dev # writes pyproject + uv.lock
uv lock # explicitly refresh the lockfile
cat uv.lock | head -30 # see exact resolved versions
# 2. Check the existing environment against the lock
deactivate 2>/dev/null || true
# Keep the existing environment. This checks synchronization, not a fresh-machine rebuild.
uv sync --locked # require an up-to-date lockfile; sync the current environment
# 3. Verify it works
uv run --locked pytest -qBonus challenge: add rich as a dependency (uv add rich), import it in __main__.py to pretty-print the table, commit pyproject.toml + uv.lock, include the sample input, then have a friend clone and run uv sync --locked && uv run --locked wordstats sample.txt. Record Python, platform and tool versions; failures may expose missing files, incompatible wheels or unavailable build inputs rather than a simple user mistake.
(d) Production reality · 15 min
Where this shows up in the rest of the plan
Root: Once you can package Python, everything downstream unlocks
Label: S013 · pytest
Desc: Tests import your package the same way users do — that's why src/ layout matters.
Label: S014 · Typing + dataclasses
Desc: Type hints live in the modules you just organised; mypy reads pyproject.toml for config.
Label: S040 · FastAPI
Desc: Your API is a package with entry points; deploy is pip install . inside a container.
Label: S056 · Docker
Desc: Copying dependency metadata before source can improve Docker layer reuse. Installing the project itself may require its source, README and build inputs; use a documented staged-install workflow rather than assuming two files suffice.
Label: S102 · CI/CD
Desc: CI can check uv sync --locked and tests with recorded Python/platform inputs. Passing CI is evidence for that environment, not every possible installation.
Label: S125 · Ship a real product
Desc: Publishing adds distribution build/inspection, a unique project name, account authorization and registry selection. See the TestPyPI exercise before uploading anything.
(e) Recall + stretch · 10 min
Self-check (answer follows each question).
Recall — click each to reveal
Q: In one sentence each: what is a module, a package, and a distribution?
A: A module is an importable namespace, often a .py file but sometimes a builtin or extension. Regular packages use init.py; namespace packages may not. A distribution is the installable packaging artifact.
Q: Where does Python actually look when you write import foo?
A: Ordinary import resolution involves sys.modules, finders and sys.path. The exact path depends on launch mode and environment; venvs exclude system site-packages by default unless configured otherwise.
Q: What problem does a virtual environment solve that pip install --user does not?
A: Per-project isolation. --user is still one global environment for your user — project A's pandas 2.2 will collide with project B's pandas 1.5. A venv gives each project its own site-packages.
Q: You clone a coworker's project with a pyproject.toml and a uv.lock. What is the exact one-command sequence to reproduce their environment?
A: Start with uv sync --locked on a compatible Python/platform. It requires current lock metadata rather than silently relocking; compare tool versions, artifacts and tests before claiming an equivalent environment.
Q: Why do we put source under src/mypkg/ instead of just mypkg/ at the repo root?
A: Because with source at the root, running python -c "import mypkg" from the repo dir will import it from cwd — even if the install failed. The src/ layout forces you to install the package to import it, catching packaging bugs before your users do.
Q: You get ImportError: attempted relative import with no known parent package. What did you do wrong?
A: You ran a file inside a package by path (python src/mypkg/main.py). Python doesn't know it's inside a package that way. Fix: python -m mypkg.main (after pip install -e .) or install a console script entry point.
Q: What is the difference between requirements.txt and uv.lock / poetry.lock?
A: A requirements file may pin exact versions and hashes. Lockfiles capture a resolution under specified conditions; reproducibility still depends on Python/platform, artifact availability and installation inputs.
Q: Which two blocks does this installable example use, and what do they do?
A: [project] declares standardized distribution metadata; [build-system] specifies the backend and its build dependencies. A pyproject.toml used only for tool configuration need not contain both, and some backends support other metadata arrangements.
Q: What does pip install -e . do that pip install . does not?
A: Editable installation exposes the source tree using a build-backend-specific mechanism rather than making an ordinary independent installed copy. New processes normally see source edits without reinstalling, but imports in an existing process remain cached. Metadata and entry-point changes may require reinstalling. It is useful in development; production policy should explicitly decide whether depending on a mutable source checkout is acceptable.
Q: Stretch — publish wordstats to TestPyPI. Walk through the exact commands.
Stretch: true
A: First choose an available distribution name (not the shared tutorial name), update [project].name, create a TestPyPI project/account and configure a scoped publishing credential through a masked secret store or Trusted Publishing. Do not put tokens in command history. The current uv workflow is:
# Add to pyproject.toml before using --index testpypi
[[tool.uv.index]]
name = "testpypi"
url = "https://test.pypi.org/simple/"
publish-url = "https://test.pypi.org/legacy/"
explicit = trueRun uv build, inspect the wheel/sdist in dist/, then deliberately upload with uv publish --index testpypi using the configured credential (uv supports UV_PUBLISH_TOKEN). In a separate fresh venv install your exact published name/version using uv pip install --index-url https://test.pypi.org/simple/ --no-deps YOUR_UNIQUE_NAME==0.1.0, then test python -m wordstats sample.txt. --no-deps is appropriate here only because this original example has no runtime dependencies; the optional rich extension needs separately resolved dependencies. PyPI and TestPyPI have separate accounts and names. Do not assume uv reads .pypirc; use its documented index/publishing configuration.
Explain-out-loud test
If you can't teach these three to a friend without notes, redo the session:
- What is the difference between a module, a package, and a distribution? (one sentence each, with an example)
- What does a lockfile capture, and how does it differ from a pinned requirements file? (one concrete failure it prevents)
- What is sys.path, and how would you use it to debug an ImportError? (name two lines of Python)
What comes next
Hub: The 6-Month Learning Plan
Part of a 130-session evergreen learning series. Session structure: intuition → visual → hands-on → production war stories → recall. Duration: 90 minutes.
Corrective source notes
- 6. Modules — Python 3.14.8 documentation — Ordinary successful imports cache a module; deliberate reload is a separate operation. Checked 2026-10-07.
- Packaging Python Projects - Python Packaging User Guide — A pyproject.toml can configure a build system and package metadata; packaging commands are not executed by this review. Checked 2026-10-07.
- venv — Creation of virtual environments — Python 3.14.8 documentation — Virtual environments isolate Python package environments; they are not OS security sandboxes. Checked 2026-10-07.
These citations support the named claims, not blanket certification of the lesson. Worked proofs are derived in the text; execution scope and finite-test limits are recorded separately.
Pause / Recall / Apply
Can you explain it without the page?
Close the example. Reconstruct the core idea, then change one assumption. Mark complete when you’re ready; you can always undo it.
Stored in this browser only. No account, no sync. Clearing browser data removes your record.