Search Tech Journey

Find topics, journeys and posts

6-month learning plan12 / 130
back to blog
pythonbeginner 15m read

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)


(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__.py marks the folder as a package. It can be empty or re-export symbols (from .core import main).
  • pyproject.toml is the single config file — replaces setup.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:

Taskpip + venvuv
Create envpython -m venv .venv && source .venv/bin/activateuv venv
Install depspip install -e ".[dev]"uv sync
Add a depedit toml, pip install -e . againuv add httpx
Run a scriptactivate venv first, then python foo.pyuv run python foo.py
Lockfilepip 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 -q

Observe when you run:

  1. uv init --package created src/greeter/__init__.py and a valid pyproject.toml — no boilerplate you wrote by hand.
  2. uv add rich updated pyproject.toml, resolved dependencies, and wrote a uv.lock — commit both.
  3. uv run auto-created .venv/, installed everything, and executed — no manual activate needed.
  4. The test imports greeter without any sys.path hacks — because the package is installed editable.
  5. uv.lock contains 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

  1. What is the difference between a module and a package?
  2. Why do we use virtualenvs? What breaks without them?
  3. What does uv.lock contain that pyproject.toml does not?
  4. What is the "src layout" and why do teams prefer it over a flat layout?
  5. 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:

  1. What is Modules, Packages, Virtualenvs, pip & uv? (one sentence, no jargon)
  2. When would you use it? (one concrete example)
  3. When would you NOT use it? (one anti-pattern)

What comes next


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 →
  1. 005Python Variables & Types — Mental Model of Memory
  2. 006Control Flow — if/else, loops, comprehensions
  3. 007Functions — arguments, scope, closures
  4. 008Data Structures — list, tuple, dict, set (when to use what)
  5. 009Classes & Objects — the OOP Mental Model
  6. 010Inheritance, Composition & Polymorphism