Search Tech Journey

Find topics, journeys and posts

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

S012 · 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.

🧠SoftwareM01 · Python Foundations· Session 012 of 130 90 min

🎯 Take a folder of .py files and turn it into an installable, testable, reproducibly-buildable package that any teammate can `pip install` in 30 seconds.

Why this session exists

Writing one Python file is easy. Writing fifty — and having them import each other cleanly, share dependencies without collision, and land on a coworker's laptop in one command — is where 90% of Python beginners get stuck. 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 hands you the modern toolchain (uv) that makes it all disappear.

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 venv` and reproduce it on a fresh machine from a lockfile.
  • Diagnose the top-5 import errors (relative import, missing __init__, wrong cwd, wrong venv, shadowed name) in under 60 seconds.

Prerequisites

  • S001 · Dev Environment — you need uv installed and know how to activate a venv.
  • S005–S011 — comfortable writing functions, classes, and running Python from the command line.


(a) Intuition · 5 min

Modules, packages, distributions — a library metaphor
🌍 Real world

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 package — a folder with an __init__.py and several modules. 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. Without it, nobody can find your books.

💻 Code world

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.

A quick history so you know why the world looks like this

  1. 1991
    Python 0.9 · imports exist
    Guido ships `import` in the first release. Modules are just .py files in the current directory.
  2. 2000
    distutils lands
    First official ‘how do I ship this?’ tool. Painful `setup.py` boilerplate.
  3. 2008
    pip released
    One command to install from PyPI. Replaces easy_install. Becomes the de-facto standard.
  4. 2012
    virtualenv & pyvenv
    Per-project Python environments. `python -m venv` becomes stdlib in 3.3.
  5. 2018
    pyproject.toml (PEP 518)
    Declarative project config. Kills setup.py for 95% of projects.
  6. 2024
    uv 0.1 · Astral
    Rust-based, 10-100× faster than pip, replaces pip + venv + pyenv + pip-tools in one binary.

(b) Visual walkthrough · 15 min

How an import actually resolves

The layers of the packaging stack

From source tree to installed package

1 · Source tree
Your folder of .py files with __init__.py files marking package boundaries.
you write
2 · pyproject.toml
Declarative metadata: name, version, deps, entry points. The PEP 517/518 standard.
you write
3 · Build backend (hatchling, setuptools)
Reads pyproject.toml, packages your source tree into a wheel (.whl) or sdist (.tar.gz).
tool
4 · Wheel — the .whl file
A ZIP with metadata + pre-built code. Fast to install because it's just an unzip + register.
artifact
5 · pip / uv install
Downloads the wheel from PyPI (or a local path), unpacks into .venv/lib/pythonX.Y/site-packages/
tool
6 · site-packages
The folder Python looks in for third-party imports. Your venv has its own; the system has another.
runtime

The install path — one time per project

1
uv init myproj

Creates myproj/ with pyproject.toml, README, .python-version, and an empty package skeleton.

2
cd myproj && uv add requests

Adds requests to pyproject.toml AND installs it into .venv AND updates uv.lock atomically.

3
uv run python main.py

Runs the script inside the project's venv without ever needing `source .venv/bin/activate`.

4
uv lock

Refresh uv.lock. Commit it — it pins every transitive dep so coworkers get the exact same tree.

5
uv sync

On a fresh machine: creates .venv and installs exactly what uv.lock says. Reproducible in seconds.

pip vs uv vs poetry vs conda

uv

The 2024+ default

  • Rust, 10-100× faster than pip
  • Manages Python versions too
  • Replaces pip + venv + pip-tools + pyenv
  • Lockfile is a first-class citizen
  • Use this for new projects
pip + venv

The stdlib fallback

  • Ships with Python
  • No lockfile (requirements.txt is not one)
  • Slow resolver
  • Still the lingua franca — you'll always know it
  • Fine for tiny scripts
Poetry

The 2018–2023 favourite

  • First tool to bring lockfiles to the mainstream
  • Own pyproject.toml dialect (not fully PEP-standard)
  • Slower than uv
  • Still popular in existing repos
  • Use it if the repo already does
conda / mamba

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


Common misconception
✗ What most people think

"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."

✓ What is actually true

A module is executed once per process, on first import. The resulting module object is cached in sys.modules, and every later import of that name is a dictionary lookup returning the same object. Module-level state is therefore process-global singleton state.

Why the myth is so sticky

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 once and its timing is unpredictable; 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.

Prove it to yourself

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
From first principles
Start with the 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.

  1. 1
    Importing a module means executing its top-level code, statement by statement, to build its namespace.
    forced by · Python has no separate declaration phase; definitions are produced by running the file
  2. 2
    To prevent infinite recursion, the module object is inserted into sys.modules before its body runs — initially empty, filling up as execution proceeds.
    forced by · a cycle would otherwise re-enter the same import forever
  3. 3
    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.
    forced by · the cache hit succeeds; only the contents are incomplete
  4. 4
    Therefore import a inside B succeeds unconditionally (it just binds the module object), while from a import X fails unless X was defined before A reached its import statement.
    forced by · from ... import performs an attribute lookup at import time, and the attribute may not exist yet
  5. 5
    And a reference used only inside a function body is resolved at call time, by which point both modules have finished executing.
    forced by · function bodies are compiled at definition but names in them are looked up on execution
⇒ Therefore

Therefore the failure depends entirely on when the name is needed relative to where the import sits — not on the existence of the cycle. This is why adding one innocuous top-level import can break a codebase that had cycles working for years.

And note what this predicts, and each is verifiable: switching from a import X to import a plus a.X inside functions usually fixes it · moving the import inside the function that needs it always fixes it · and if TYPE_CHECKING: imports never cause cycles because they don't execute at runtime. It also predicts the real diagnosis: a cycle means the two modules share a concern that belongs in a third module.

Mental modelImport is execution plus a cache

Two things happen on import x, in order: find (walk sys.path for a matching module) and execute once (run its top level, cache the result in sys.modules). Every subsequent import of that name skips straight to the cache.

So a module is not a file — it is a live object that happened to be built by running a file. Its top level is not a declaration section; it is startup code that runs at an unpredictable moment, exactly once, in an order determined by whoever imported first.

  • 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.
🔔 Fires when you see

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.

The tradeoff

You have shared utility code used by three services. Do you copy it, extract an internal package, or keep everything in a monorepo?

Copy-paste into each service
+ you gain zero coupling and zero release process; each service evolves its copy freely and a change can never break someone else
− you pay 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 when 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
Internal versioned package
+ you 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
− you pay 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 when consumers are owned by different teams or deploy on different schedules; versioning is exactly the mechanism for decoupling release cadences
Monorepo, imported directly
+ you gain atomic cross-cutting changes — one commit updates the library and every caller, with CI validating all of them together; no version skew is possible
− you pay requires real build tooling to keep CI times sane, and everything must be deployable together or you reintroduce skew at runtime anyway
pick when one org owns all consumers and can deploy them in lockstep, and you have (or will build) the tooling for selective test execution
What a senior engineer actually does

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 that floats its own requirements will eventually pull an incompatible transitive version into a consumer's environment — so pin ranges deliberately, and give the package the same lockfile discipline you would give a service.


(c) Hands-on · 25 min

You're going to build a tiny installable package called wordstats — it counts words in a text file — and prove it's installable, importable, and reproducible on a fresh venv. Copy this whole script into bootstrap.sh and run it.

#!/usr/bin/env bash# bootstrap.sh build a real Python package from scratch.# Requires: uv (see S001). Idempotent safe to re-run.set -euo pipefail PROJECT="wordstats"WORK_DIR="$HOME/projects/learning/s012/$PROJECT" log() { printf "\033[1;36m %s\033[0m\n" "$*"; } log "1/8 Fresh project folder"rm -rf "$WORK_DIR"mkdir -p "$WORK_DIR"cd

What each block does

Anatomy of the script

Step 2 · src/ layout
The `src/wordstats/` folder isolates your package from your working directory. Prevents the classic ‘tests import from cwd not from installed package’ bug.
layout
Step 2 · __init__.py re-exports
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.
api
Step 2 · __main__.py
Enables `python -m wordstats …`. This is how stdlib modules like `http.server` and `venv` are runnable — copy that idiom.
cli
Step 3 · [project.scripts]
Registers a real shell command called `wordstats`. After install, it's on your PATH — just like any other tool.
console
Step 3 · [build-system]
Tells pip/uv which build backend to use. `hatchling` is the modern default: fast, zero-config, PEP-517 compliant.
build
Step 5 · uv pip install -e .
Editable install — Python imports directly from your source folder. Save the file, next import sees the change. No re-install needed.
dev
Step 6 · print(sys.path)
The single most useful debugging print in the entire Python ecosystem. When an import fails, print this first.
debug
Try itFeel the ‘reproduce on a fresh machine’ workflow

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. Now simulate ‘fresh machine’
deactivate 2>/dev/null || true
rm -rf .venv
uv sync                          # rebuilds .venv from uv.lock, byte-for-byte
 
# 3. Verify it works
uv run pytest -q

Bonus challenge: add rich as a dependency (uv add rich), import it in __main__.py to pretty-print the table, commit pyproject.toml + uv.lock, then have a friend clone and run uv sync && uv run wordstats sample.txt. If it doesn't Just Work, you missed something.

💡 Hint · After you lock, delete the venv folder and run `uv sync`. Watch it rebuild the exact same env in seconds — that is what a lockfile buys you.

(d) Production reality · 15 min

War story Instagram · engineering blog· 2019thousands of engineers, one monorepo
🔥 What broke

Instagram famously runs the largest Django deployment on Earth. Their monorepo has hundreds of internal packages. Early on, engineers used ad-hoc PYTHONPATH hacks to import between them, and requirements.txt without pins.

Result: two teams could deploy the same commit hash and get different behaviour because a transitive dep had released a new version between the two deploys. Nightmare to debug.

🧯 The fix
Migrate to Bazel-managed packages with a single, committed lockfile for every third-party dep. Every build reads the same pins. Downgraded ‘what changed?’ from ‘could be anything on PyPI’ to ‘look at the lockfile diff’.
🎓 Lesson to steal
Without a lockfile, ‘reproducible’ is a marketing word. With one, it's a guarantee. Commit uv.lock (or poetry.lock, or requirements.lock) into Git. Always.
Post-mortem
War story Common failure · every new devuniversal
🔥 What broke
You cd into myproj/, run python src/myproj/main.py, and get ImportError: attempted relative import with no known parent package. You Google. Stack Overflow has 400 answers, all contradicting.
🧯 The fix

The rule: never run a file inside a package directly. Always run it as a module from the project root:

# wrong
python src/myproj/main.py

# right (after: pip install -e .)
python -m myproj.main
# or
uv run python -m myproj.main

Or, better, register a console script entry point so users get a real command.

🎓 Lesson to steal
Relative imports (from .core import x) only work when Python knows the module's parent package. Running a file by path breaks that. Use python -m or console scripts.
War story Common failure · data-science teamsteam-wide slowdown
🔥 What broke
A data-science team shares a single conda environment on a shared VM. Someone pip installs tensorflow 2.15 to try something, which downgrades numpy, which breaks another engineer's pandas code, which breaks a scheduled notebook, which alerts at 3 a.m.
🧯 The fix
Kill the shared environment. Each notebook / project gets its own uv venv committed alongside the code. Use JupyterLab's kernel selector to attach to the project venv. The 30-second cost of activation is invisible compared to the hours of ‘who upgraded what’ archaeology.
🎓 Lesson to steal
Shared environments are shared failure modes. One venv per project, no exceptions, even for ‘tiny’ notebooks.

Where this shows up in the rest of the plan

Once you can package Python, everything downstream unlocks
S013 · pytest
Tests import your package the same way users do — that's why src/ layout matters.
S014 · Typing + dataclasses
Type hints live in the modules you just organised; mypy reads pyproject.toml for config.
S040 · FastAPI
Your API is a package with entry points; deploy is `pip install .` inside a container.
S056 · Docker
Dockerfiles copy pyproject.toml + uv.lock first, then `uv sync` — that's the whole install layer.
S102 · CI/CD
GitHub Actions runs `uv sync` and `pytest` — reproducibility is what makes CI trustworthy.
S125 · Ship a real product
You'll publish a package to PyPI at the end of the plan. Same pyproject.toml, one `uv publish` away.

(e) Recall + stretch · 10 min

Recall — click each to reveal · click to reveal
★ = stretch question

Explain-out-loud test

If you can't teach these three to a friend without notes, redo the session:

  1. What is the difference between a module, a package, and a distribution? (one sentence each, with an example)
  2. What is a lockfile, and why does it matter more than requirements.txt? (one concrete failure it prevents)
  3. 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.