Where Python Looks for a Package
Two scripts import pydantic and behave differently — one renders {"a": 1}, the other {'a': 1}. The script is never the cause. Each shell resolved import pydantic to a different directory on disk, and neither programmer chose that directory deliberately; they inherited whatever a bare pip install happened to touch.
What creating an environment actually changes
python -m venv .venv
writes a private site-packages plus a three-line config:
/home/dev/summarizer/.venv/pyvenv.cfg
home = /usr/bin ← base interpreter whose stdlib is borrowed
include-system-site-packages = false
version = 3.12.3
That is the entire mechanism: a shim that points at the base Python for the standard library and at its own directory for third-party packages. Nothing global is copied, nothing global is modified.
The resolution order in one diagram
import pydantic
│
▼
1. script's own directory ← ./pydantic.py shadows the real package
2. PYTHONPATH entries ← usually accidental
3. venv site-packages ← what `uv add` / `pip install` populated
4. system site-packages ← only if include-system-site-packages = true
5. stdlib ← /usr/lib/python3.12
You can ask Python where it landed instead of guessing:
python -c "import sys, pydantic; print(sys.prefix); print(pydantic.__file__)"
Output:
/home/dev/summarizer/.venv
/home/dev/summarizer/.venv/lib/python3.12/site-packages/pydantic/__init__.py
sys.prefix names the environment that won; the module path names the copy. If either line points somewhere unplanned, you are not in the environment you think you are — which python, checked before any install, usually explains it.
The two-environment experiment
uv venv .venv-a --python 3.11
uv pip install --python .venv-a/bin/python pydantic==2.5.0
uv venv .venv-b --python 3.12
uv pip install --python .venv-b/bin/python pydantic==2.9.2
.venv-a/bin/python -c "import pydantic; print(pydantic.VERSION)"
.venv-b/bin/python -c "import pydantic; print(pydantic.VERSION)"
Output:
2.5.0
2.9.2
Two directories, two pins, zero conflict — that is the isolation guarantee. What it does not provide is reproducibility: 2.5.0 was chosen today, and a teammate installing "the same project" tomorrow resolves whatever satisfies their constraints on that day. Fixing that is a lock file's job.
Ranges for Humans, Pins for Machines
A teammate cloned the repo, installed from requirements.txt, and got httpx 0.27 while you run 0.28. Same file, different result: the file listed httpx>=0.27, a range, and each machine's resolver picked the newest satisfying version on the day it ran. Reproducibility failed not because a file was missing but because it recorded intent instead of outcome.
The uv workflow
uv init summarizer # project skeleton with pyproject.toml
uv add httpx "pydantic>=2.5" # declares intent, updates pyproject.toml + uv.lock
uv lock # re-resolve everything without adding anything
uv sync --frozen # install exactly what the lock says, or fail
uv add maintains two files with different audiences:
# pyproject.toml — what you mean, reviewed by humans
[project]
dependencies = ["httpx>=0.27,<1", "pydantic>=2.5,<3"]
# uv.lock — what you get, written by the resolver, committed to git
[[package]]
name = "httpx"
version = "0.27.2"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/.../httpx-0.27.2.tar.gz",
hash = "sha256:31adf3..." }
wheels = [{ url = "https://files.pythonhosted.org/.../httpx-0.27.2-py3-none-any.whl",
hash = "sha256:9de4c1..." }]
The hashes turn uv sync --frozen into verification rather than download: a wheel whose bytes changed under an unchanged version number fails the hash check instead of being installed.
What each file is responsible for
| File | Records | Change policy |
|---|---|---|
pyproject.toml |
compatible ranges | edit deliberately, in review |
uv.lock |
exact versions + sha256 | regenerate only via uv add / uv lock |
.python-version |
interpreter minor version | bump as a project decision |
CI python-version |
interpreter running checks | must equal .python-version |
uv sync --frozen is the enforcement point. If someone edits pyproject.toml and forgets to re-lock, CI fails loudly instead of quietly resolving a new set — which is the exact failure that otherwise appears three weeks later as "works on my machine." The command installs from the lock, recreates the environment when the interpreter changed, and removes packages that no longer belong.
Versions are only half the environment. The other half — credentials and the interpreter itself — leaks across machines just as easily, and both failures present identically from the outside: fine on one laptop, broken everywhere else.
Secrets and the Interpreter Pin
Isolation handles packages. Two other things travel between machines and teammates without anyone deciding they should: credentials and the interpreter version.
Keys belong to the environment, not the repository
# .env — local only, and listed in .gitignore
OPENAI_API_KEY=sk-proj-...
import os, sys, httpx
REQUIRED = ("OPENAI_API_KEY",)
missing = [k for k in REQUIRED if not os.environ.get(k)]
if missing:
sys.exit(f"missing environment variables: {', '.join(missing)}")
client = httpx.AsyncClient(
headers={"Authorization": "Bearer " + os.environ["OPENAI_API_KEY"]}
)
Output when the variable is unset: missing environment variables: OPENAI_API_KEY (exit code 1)
Three choices there are deliberate:
- The check runs at startup. A key read deep inside a request path fails on summary 200 of 200, with a stack trace pointing at the wrong line.
os.environis read once per process. Rotation becomes a worker restart — an explicit, observable event instead of half-updated state.sys.exit, not an exception. There is no useful stack trace for missing configuration; a one-line message beats forty frames.
The .gitignore entry keeps .env out of new commits. When a key is committed, removal from the tree is not the fix — the commit is already public on any shared remote. Rotation is: delete it from the file, then revoke it at the provider. Order matters only in that neither step substitutes for the other.
Pin the interpreter in two places that must agree
uv python pin 3.12 # writes .python-version; uv selects that interpreter
# .github/workflows/ci.yml
jobs:
test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: astral-sh/setup-uv@v4
with:
python-version: "3.12" # must match .python-version
- run: uv sync --frozen
- run: uv run pytest
Pinning the minor version matters because behavior drifts between minors: the type statement arrived in 3.12, exception groups changed how except* works, and asyncio internals moved across 3.10–3.12. A teammate on 3.10 whose code never runs under 3.12 until CI discovers it pays for the mismatch in a failing pipeline instead of a failing editor.
The cheap invariant: one number, written in .python-version and in the workflow, updated together. With interpreter, packages, and keys fixed, the remaining variable is the data crossing the boundary — JSON Handling and Pydantic Models.
Quiz
1. `python -c "import sys, pydantic; print(pydantic.__file__)"` prints a path under `.venv/lib/python3.12/site-packages`. What does that prove?
2. Two machines install from the same file listing `httpx>=0.27` and end up with different versions. Why?
3. CI fails with a syntax error on `X | Y` type unions while a teammate's laptop is fine. What is the fix?
Flashcards
Question
What does sys.prefix tell you?
Click to reveal answer
Answer
The root of the environment the running interpreter belongs to. If it is not your project's .venv, any install just went somewhere else — check it before running pip or uv.
Question
pyproject.toml versus uv.lock — what does each record?
Click to reveal answer
Answer
pyproject records compatible ranges (intent, human-reviewed); uv.lock records exact versions with sha256 hashes (outcome, resolver-written, committed to git).
Question
Why run uv sync --frozen in CI?
Click to reveal answer
Answer
It installs exactly what the lock specifies or fails — so a pyproject.toml edit that never ran uv lock breaks the build instead of silently resolving new versions.
Question
Where may a local API key live, and what fixes one that was committed?
Click to reveal answer
Answer
In a gitignored .env or an injected environment variable, validated once at startup. If committed: remove it from the tree and revoke it at the provider — rotation, not deletion, is the fix.
Revision Notes
Key Takeaways
- 1. A virtual environment is one private site-packages plus pyvenv.cfg — no global state is copied or modified
- 2. Import resolution is ordered: script directory, PYTHONPATH, venv site-packages, system site-packages, stdlib
- 3. Ranges express intent and pins deliver reproducibility; the lock file with hashes is what CI installs from
- 4. One Python minor version number lives in .python-version and the CI workflow and must be bumped together
- 5. Secrets are validated at startup and never enter the tree — a committed key is fixed by revocation, not deletion
Interview Tips
- • Describe the resolution order from memory and give the debugging command: print sys.prefix and the module __file__ to see which copy won
- • Explain why requirements.txt listing ranges is not reproducibility — expect the follow-up about what hashes in a lock file actually verify
- • Walk through the leaked-key response: remove from the tree, revoke at the provider, then consider history cleanup — and say why deletion alone is insufficient
- • Be ready with the failure signature pair: works locally, fails in CI means either lock drift or interpreter mismatch, and how you tell which
Cheat Sheet
Environments and Dependency Hygiene — Cheat Sheet
Resolution order for import x: script directory → PYTHONPATH → venv site-packages → system site-packages → stdlib.
python -c "import sys, pydantic; print(sys.prefix, pydantic.__file__)" # where am I?
uv init app && uv add httpx "pydantic>=2.5" # intent + lock
uv lock # re-resolve without adding
uv sync --frozen # CI: install exactly the lock, or fail
uv python pin 3.12 # writes .python-version
Four files, four jobs
| File | Records | Edited by |
|---|---|---|
pyproject.toml |
compatible ranges | humans, in review |
uv.lock |
exact versions + sha256 hashes | uv add / uv lock only |
.python-version |
interpreter minor | deliberate bump |
CI python-version |
interpreter running checks | must match .python-version |
Secrets
.envin.gitignore; read once at startup throughos.environ- fail at boot:
missing = [k for k in REQUIRED if not os.environ.get(k)]→sys.exit(...) - committed key → remove from the tree and revoke at the provider; rewriting history alone does not un-publish a leaked credential
Failure signatures
| Symptom | Likely cause |
|---|---|
| works locally, fails in CI | pyproject.toml edited without uv lock, or interpreter mismatch |
| different versions per machine | bare pip install outside the venv — check sys.prefix |
pydantic renders reprs differently |
two interpreters resolving two site-packages |
| syntax error only in CI | Python minor version not pinned in both places |
Continue Learning
Before You Start
- No prerequisites listed.