S012 · Modules, Packages, Virtualenvs, pip & uv
How real projects are organised and shipped.
Module M01: Python Foundations · Session 12 of 130 · Track: SW 📚
What you'll be able to do after this session
- Explain Modules, Packages, Virtualenvs, pip & uv to a friend in one minute, out loud, no notes.
- Recognise it when you see it in real code / systems / papers.
- Complete the hands-on exercise at the end without looking up help.
Prerequisites
If you can't answer these in 30 seconds each, redo the linked session first:
Pre-read (skim before the session)
- 🎥 Intuition (5–15 min) · Corey Schafer — Python Modules and Packages — the classic "what is a module" explainer.
- 🎥 Deep dive (30–60 min) · ArjanCodes — Python Project Structure Done Right — src layout, pyproject.toml, editable installs.
- 🎥 Hands-on demo (10–20 min) · uv — the Fast Python Package Manager (Astral) — live uv workflow, 10× faster than pip.
- 📖 Canonical article · Python Packaging User Guide — the official reference on shipping packages.
- 📖 Book chapter / tutorial · Real Python — Python Modules and Packages — deep beginner walkthrough with diagrams.
- 📖 Engineering blog · Astral — uv: Python packaging in Rust — why uv exists and what it replaces.
(a) Intuition · 5 min
The one-paragraph mental model. Why this concept exists, what problem it solves, and the analogy that makes it stick.
In one sentence: How real projects are organised and shipped.
Think of your Python code like a kitchen. A module is a single cookbook — one .py file with related recipes (functions/classes). A package is a shelf of cookbooks organised by cuisine — a folder with an __init__.py and multiple modules inside. Once your kitchen has more than ~200 lines of code, keeping everything in one file becomes chaos; you split into modules so you can find things and reuse them.
A virtualenv is a separate kitchen for each project — its own set of pans, its own oven, its own ingredients (dependencies). Without virtualenvs, installing pandas==2.0 for project A silently upgrades project B, which breaks in mysterious ways. pip is the delivery truck that brings packages from PyPI; uv is the same truck but ten times faster (written in Rust) and it also builds the kitchen for you.
The forever-gotcha: pip install without an active virtualenv installs globally and pollutes your system Python. Always activate a venv first — or use uv, which enforces per-project isolation by default.
(b) Visual walkthrough · 15 min
Diagrams, worked examples, step-by-step visuals.
Anatomy of a modern Python project (the src layout — recommended by PyPA):
- Import path from anywhere:
from mypkg.api.routes import register. __init__.pymarks the folder as a package. It can be empty or re-export symbols (from .core import main).pyproject.tomlis the single config file — replacessetup.py,setup.cfg,requirements.txt.
Worked example — a minimal pyproject.toml:
[project]
name = "mypkg"
version = "0.1.0"
requires-python = ">=3.11"
dependencies = ["httpx>=0.27", "pydantic>=2.6"]
[project.optional-dependencies]
dev = ["pytest>=8", "ruff>=0.5"]
[build-system]
requires = ["hatchling"]
build-backend = "hatchling.build"Workflow comparison — pip vs uv:
| Task | pip + venv | uv |
|---|---|---|
| Create env | python -m venv .venv && source .venv/bin/activate | uv venv |
| Install deps | pip install -e ".[dev]" | uv sync |
| Add a dep | edit toml, pip install -e . again | uv add httpx |
| Run a script | activate venv first, then python foo.py | uv run python foo.py |
| Lockfile | pip freeze > requirements.txt (fragile) | uv.lock (auto, exact) |
| Cold install of 30 deps | ~40 seconds | ~2 seconds |
Import resolution order — when you write import mypkg, Python searches sys.path in order: (1) the directory of the running script, (2) PYTHONPATH env var, (3) the virtualenv's site-packages, (4) the system Python. Whichever hits first wins — which is why installing your project as -e . (editable) matters: your src/mypkg becomes importable everywhere.
(c) Hands-on · 20 min
Code you type and run. Not pseudo-code — real, runnable, copy-pasteable.
Install uv once: curl -LsSf https://astral.sh/uv/install.sh | sh. Then:
# 1. scaffold a new project
mkdir greeter && cd greeter
uv init --package # writes pyproject.toml + src/greeter/
tree -L 3 -a -I '.git|.venv'
# 2. add a runtime and a dev dependency
uv add rich # pretty terminal output
uv add --dev pytest # test framework
# 3. write a tiny module
cat > src/greeter/core.py <<'EOF'
from rich import print
def hello(name: str) -> str:
msg = f"[bold green]hello, {name}![/bold green]"
print(msg)
return msg
EOF
# 4. expose it via __init__.py
cat > src/greeter/__init__.py <<'EOF'
from .core import hello
__all__ = ["hello"]
EOF
# 5. write a test
mkdir -p tests
cat > tests/test_core.py <<'EOF'
from greeter import hello
def test_hello_returns_msg():
assert "alice" in hello("alice")
EOF
# 6. run everything
uv run python -c "from greeter import hello; hello('world')"
uv run pytest -qObserve when you run:
uv init --packagecreatedsrc/greeter/__init__.pyand a validpyproject.toml— no boilerplate you wrote by hand.uv add richupdatedpyproject.toml, resolved dependencies, and wrote auv.lock— commit both.uv runauto-created.venv/, installed everything, and executed — no manualactivateneeded.- The test imports
greeterwithout anysys.pathhacks — because the package is installed editable. uv.lockcontains exact hashes — reproducible builds across machines.
Try this modification: add a second module src/greeter/farewell.py with a goodbye(name) function, re-export it in __init__.py, and write a second test. Run uv run pytest — both tests should pass with no reinstall.
(d) Production reality · 10 min
How this shows up in real systems, gotchas, war stories, common bugs.
The #1 production Python bug related to packaging is "works on my laptop, breaks on the server." Almost always the cause is an unpinned dependency: requirements.txt said pandas>=2.0 and yesterday's fresh Docker build pulled pandas==2.3.0 which changed a default and broke a groupby. The fix is a lockfile: uv.lock, poetry.lock, or pip-tools's requirements.txt with hashes. Netflix's Metaflow, Airbnb's data-platform, and Anthropic all ship with lockfiles committed to git — never trust "latest".
The src layout exists because of a subtle bug: without it, pytest can import your package from the project root without it being installed, which means your tests pass locally but the shipped wheel is broken (missing files, wrong __init__.py). Putting code under src/ forces you to install the package before you can import it, so tests always run against the same artefact your users get.
__init__.py is often abused: people put 500 lines of logic in it and then wonder why import mypkg takes 3 seconds. Keep __init__.py cheap — just re-exports. Heavy work goes in submodules, imported lazily.
Big teams handle this with three layers: (1) pyproject.toml declares the loose contract (>=), (2) lockfile pins the exact resolved graph for CI/prod, (3) Docker multi-stage builds freeze the OS + Python + wheels into an immutable image. At Microsoft, our ODSP pipelines use uv sync --frozen in Docker so the image build fails if the lockfile is stale — never a silent drift.
Gotcha: never mix pip install and conda install in the same environment — they don't know about each other and will happily install conflicting versions of numpy. Pick one package manager per env.
Gotcha 2: relative imports (from .utils import x) only work inside a package. If you python src/mypkg/core.py directly it will ImportError. Run modules with python -m mypkg.core or use uv run which resolves this correctly.
(e) Quiz + exercise · 10 min
- What is the difference between a module and a package?
- Why do we use virtualenvs? What breaks without them?
- What does
uv.lockcontain thatpyproject.tomldoes not? - What is the "src layout" and why do teams prefer it over a flat layout?
- When you write
import mypkg, in what order does Python search for it?
Stretch (connects to S011 Errors & Debugging): turn the greeter project into a package with a CLI entry point ([project.scripts] in pyproject.toml mapping greet = "greeter.cli:main"). Have the CLI catch KeyboardInterrupt gracefully and exit code 130. Verify with uv run greet alice and Ctrl-C.
Explain-out-loud test
If you can't teach these 3 things to a friend without notes, redo the session:
- What is Modules, Packages, Virtualenvs, pip & uv? (one sentence, no jargon)
- When would you use it? (one concrete example)
- When would you NOT use it? (one anti-pattern)
What comes next
- Next session (S013): Testing with pytest — TDD Workflow
- Previous (S011): Errors, Exceptions & Debugging with pdb
- Hub: The 6-Month Learning Plan
Part of a 130-session evergreen learning series. Session structure: (a) intuition · (b) visual walkthrough · (c) hands-on · (d) production reality · (e) quiz + exercise. Duration: 60 minutes.
More from M01 · Python Foundations
all modules →