Environments, packaging & dependencies
Reproducible setups the pro way: venvs, pinned deps, semantic versioning, dependency resolution & conflicts, packaging with pyproject, and enforcing team-wide reproducibility in CI.
Learning objectives
- Use virtual environments and pinned dependencies fluently.
- Reason about semantic versioning and dependency resolution.
- Structure, build, and publish a package.
- Guarantee reproducibility across a team.
code/df5-environments/. Python runs offline; configs are ready to use.1 · Virtual environments — isolation essential
Here's the afternoon that turns everyone into a venv believer. You finish project A, which pins an old version of some library. You switch to project B, run pip install for something it needs, and pip quietly upgrades that shared library to satisfy B. A week later you open A again — and it's broken. Nothing in A's code changed; a package it relied on got upgraded out from under it by an unrelated project. You lose an hour bisecting versions before realising the two projects were fighting over one shared toolbox the whole time.
The everyday analogy is a shared kitchen. If every cook uses one set of pans, someone searing fish in the good skillet ruins the next person's pancakes. The fix isn't better etiquette — it's giving each cook their own counter with their own pans. A virtual environment is that private counter: a folder holding one project's packages and nothing else, so project A can keep its old library while project B pins a new one and neither ever notices the other exists.
Why it works is almost anticlimactic: a venv is mostly a folder plus a tweak to where your shell looks for python and pip. When you "activate" it, your terminal's search path is rewired so those commands point inside the venv instead of at the system Python. That single path change is the isolation — installs land in the project's box, and when you deactivate, the path flips back. The common mistake is skipping the venv "just for a quick test" and installing globally; that one shortcut is the single most common root cause of the dreaded "but it works on my machine." The rule is boring and absolute: one project, one venv, created before you install anything.
A venv is a private package folder per project so versions don't collide. Every project starts with one.
.venv and activation points python/pip inside it, so A keeps its old version while B pins a new one.
Compare the two halves left and right: the left shows the shared-toolbox trap, the right shows the fix. In both, projects sit on top and the package store sits below.
- On the left, both projects point (dashed grey arrows) at one red "system Python" box holding a single shared library — so an upgrade requested by B overwrites the copy A depended on, breaking A.
- On the right, each project points (solid blue arrows) at its own green
.venvbox: A's holds the old lib, B's holds the new lib, and neither can touch the other's. - The payoff is the green caption underneath: "activate" isn't magic — it just rewires where the shell finds
pythonandpipso installs land in that project's box. That path change is the isolation.
In short: one shared Python = collisions; one .venv per project = independent, reproducible package sets.
venv.shpython3 -m venv .venv
source .venv/bin/activate # prompt shows (.venv)
pip install requests
pip freeze > requirements.txt # snapshot exact versions
deactivate
A virtual environment (venv) is a private, throwaway folder that holds one project's Python packages. Without it, every pip install dumps packages into your system Python, so two projects that need different versions of the same package fight each other. A venv gives each project its own clean box. This lab creates one, turns it on, installs a package into it, and records what got installed.
python3 -m venv .venvcreates the environment in a hidden folder named.venvinside your project. It's just a directory of files — nothing is installed system-wide yet.source .venv/bin/activateswitches on the venv for this terminal. You'll see(.venv)appear at the start of your prompt — that's how you know packages will now go into the project's box, not the system.pip install requestsdownloads therequestspackage into the active venv only. Other projects never see it.pip freeze > requirements.txtwrites the exact versions of everything installed into a text file (the>saves the output to that file). That file is the recipe someone else uses to rebuild the same setup.deactivateturns the venv back off, returning your terminal to the system Python.
What the output means: Nothing dramatic prints; the payoff is on disk — a .venv/ folder holding requests, and a requirements.txt listing its exact version.
Try this: Run these lines, then type which python before and after deactivate. Inside the venv it points into .venv/; after deactivating it points back at the system Python. That path change is the isolation.
2 · Dependencies & requirements essential
A venv keeps your project's packages private — but privacy alone doesn't make them repeatable. Picture handing your project to a teammate with the note "just pip install the stuff it uses." They install the latest of everything, a minor version has shifted since you built it, and now a function you relied on behaves differently. The code is identical on both machines; the versions underneath it are not. The gap between "it has the packages" and "it has the same packages I tested" is exactly what this section closes.
Think of it like a recipe. "Add some flour and a bit of sugar" works when you cook it, because your hands know the amounts — but it's useless to anyone else, and useless to future you who's forgotten. A real recipe says "240g flour, 100g sugar." A requirements.txt is that precise recipe for your environment: it doesn't say "install requests-ish," it says requests==2.32.3, down to the patch. Anyone with the file can reproduce your exact kitchen.
The mechanic that makes this honest is pip freeze, and here's the detail beginners miss: it records everything installed, not just the handful of packages you named. When you pip install fastapi, fastapi drags in a dozen packages of its own (its dependencies), and those have versions too. Freeze captures the whole tree, so the rebuild is complete rather than "the top-level names, and whatever versions of the rest happen to be latest today." The common mistake is hand-writing requirements.txt with only the packages you typed — that leaves the transitive dependencies floating, and floating is precisely where "works on my machine" hides. Freeze the full set; that's the recipe that actually reproduces.
pip freeze pins all of them, which is why the rebuild is complete instead of "top names, latest rest."
Read top to bottom, like a family tree: one command at the top, the packages you named in the middle, and the dependencies-of-dependencies fanning out below.
- The top box is the single command you ran. Its solid blue arrows point to the two packages you actually named —
fastapianduvicorn. - Each named package has dashed grey arrows down to the amber boxes it dragged in automatically (
starlette,pydantic,click,h11) — the transitive dependencies you never typed. - The green line at the bottom is the payoff:
pip freezesnapshots every box in the tree at its exact version. Hand-writing only the two you typed leaves the amber layer floating — which is where "works on my machine" hides.
In short: you name a few packages; pip freeze pins the entire tree, so pip install -r rebuilds it exactly.
requirements.shpip install fastapi uvicorn
pip freeze > requirements.txt
pip install -r requirements.txt # reproduce exactly, elsewhere/CI/container
cat requirements.txt # fastapi==0.115.0 uvicorn==0.30.0 ...
requirements.txt is your project's shopping list of exact package versions. This lab shows the full loop: install what you need, snapshot it, then rebuild it identically on another machine. This is how you avoid "but it worked on my laptop".
pip install fastapi uvicorninstalls two packages (a web framework and the server that runs it) into the active venv.pip freeze > requirements.txtsnapshots every installed package and its exact version into the file — not just the two you named, but everything they pulled in too.pip install -r requirements.txtis the reverse: the-rflag means "read this requirements file and install exactly what it lists." Run on a teammate's machine, a CI runner, or inside a container, it recreates the same environment.cat requirements.txtjust prints the file so you can see the pinned lines, e.g.fastapi==0.115.0. The==means "exactly this version".
What the output means: The cat shows lines like fastapi==0.115.0 — one per package, each locked to a specific version.
Try this: Open requirements.txt after running freeze. You'll see more lines than the two packages you installed — those extras are dependencies-of-dependencies, captured automatically so the rebuild is complete.
3 · Semantic versioning intermediate
You've pinned your versions — so now the question is how to read them. A number like 2.3.1 looks arbitrary until you learn it's a promise, and once you see the promise you can answer the question every upgrade forces on you: is this safe to take, or is it about to break my code? Almost everyone learns this the hard way — you bump a library to grab a bug fix, the install succeeds, and an import that worked yesterday now throws because the number quietly told you it might and you didn't listen.
The everyday analogy is the way we talk about house renovations. Repainting a wall (a patch) is safe — walk in tomorrow and everything's where you left it. Adding a new room (a minor change) gives you more without touching what already worked. Knocking down a load-bearing wall (a major change) means the old floor plan is gone; your muscle memory for where the kitchen was is now wrong. Semantic versioning encodes exactly that in three numbers: MAJOR.MINOR.PATCH. The leftmost number is the one that can hurt you.
Why this matters for the specs you write: the version rule you put in a requirements file is a bet on how much change you'll tolerate. ==2.3.1 says "exactly this, never surprise me" — right for an app you deploy, wrong for a shared library that would then clash with everyone else's pins. >=2.3,<3 says "any 2.x from 2.3 up, but never cross into 3.0" — you're trusting the promise that anything below the major bump won't break you, while slamming the door on the one number that's allowed to. The common mistake is treating a major bump as routine because "the install worked." Installing is not the same as running; a green install with a major bump is precisely the moment to read the changelog and test, not to ship on Friday afternoon.
Versions are MAJOR.MINOR.PATCH. MAJOR = breaking, MINOR = new-but-compatible, PATCH = bugfix. This tells you whether an upgrade is safe, and which spec to write.
Read the top row first (what the three digits mean), then the number line below (what each spec lets in).
- The three coloured boxes split
2.3.1into its parts: red MAJOR is the only one allowed to break your code, amber MINOR adds compatible features, green PATCH is bugfixes only. - The horizontal line is the version axis from
2.2up past3.0. Each bar below shows what a spec accepts:==2.3.1is a single dot;>=2.3,<3is the wide blue bar covering every 2.x from 2.3 up to (but not including) 3.0;~=2.3.0is the short green bar covering only 2.3.x. - The payoff: every bar stops before 3.0 — none of them cross the red MAJOR boundary, because that's the one bump that can hurt you. Exact pins suit apps; ranges suit libraries.
In short: the MAJOR digit is the danger digit, and your spec is a bet on how wide a slice of the line — always stopping short of the next major — you'll trust.
| Spec | Means | Use for |
|---|---|---|
x==2.3.1 | exactly this | apps (reproducible) |
x>=2.3,<3 | compatible range | libraries |
x~=2.3.0 | 2.3.* only | conservative pins |
semver.pydef parse(v): return tuple(int(x) for x in v.split(".")[:3])
def is_compatible(have, spec_min, spec_max_major):
h = parse(have)
return parse(spec_min) <= h and h[0] < spec_max_major
print("2.3.1 satisfies >=2.3,<3 :", is_compatible("2.3.1", "2.3.0", 3))
print("3.0.0 satisfies >=2.3,<3 :", is_compatible("3.0.0", "2.3.0", 3)) # breaking major
print("major bump = review before upgrading")
2.3.1 satisfies >=2.3,<3 : True
3.0.0 satisfies >=2.3,<3 : False
major bump = review before upgrading
Package versions look like 2.3.1 = MAJOR.MINOR.PATCH. A MAJOR bump (2 → 3) can break your code; MINOR and PATCH bumps are meant to be safe. This tiny program checks whether a version falls inside an allowed range, which is exactly the question pip asks before upgrading.
parse(v)turns the string"2.3.1"into the number triple(2, 3, 1)by splitting on the dots — so versions can be compared like numbers instead of text.is_compatible(...)returnsTrueonly if the version is at or above the minimum and its major number (h[0]) is below the cutoff major. That mirrors a spec like>=2.3,<3.- The first
printchecks2.3.1against>=2.3,<3— it fits, so it printsTrue. - The second checks
3.0.0against the same range. Its major is 3, which is not below 3, so it printsFalse— a breaking major that you should review before upgrading.
What the output means: True then False: 2.3.1 is inside the allowed range, 3.0.0 is a breaking major that falls outside it.
Try this: Change the check to is_compatible("2.9.9", "2.3.0", 3) — still True, because any 2.x is allowed. Then try "2.2.0": False, because it's below the 2.3 minimum.
4 · Advanced — dependency resolution & conflicts advanced
Sooner or later pip will look you dead in the eye and say ResolutionImpossible, and it feels like the tool is broken. It isn't. You asked for library A and library B, both of which you legitimately need, and it turns out A insists on urllib3>=2 while B insists on urllib3<2. There is no single version of urllib3 that makes both happy, and since only one copy can be installed, pip has no move to make. The first time it happens you'll stare at the error convinced you typed something wrong; understanding what's actually going on turns a lost afternoon into a five-minute diagnosis.
Here's the analogy that makes it click. Imagine planning a dinner for two friends: one is vegan, the other is on a strict all-meat diet. Any single dish that satisfies one insults the other — the requirements don't overlap, so no menu exists. That's a dependency conflict exactly: each requirement carves out a range of acceptable versions, and a solution only exists where those ranges intersect. When the intersection is empty, no amount of cleverness conjures a version into being. This shape has a name — the diamond dependency — because A and B both point down at the same shared package from different directions.
What a resolver does is search: it walks the available versions of each package looking for one combination that lands inside every requirement's range at once, and it reports failure only when it can prove no such combination exists. Modern pip does this properly (older pip used to just install the last one it saw and leave you with a silently broken environment — worse than an honest error). The common mistake is reading "ResolutionImpossible" as "pip is buggy" and forcing the install with flags; that just recreates the old silent breakage. The real fix is to widen someone's range — upgrade B to a release that accepts the new urllib3, or hold A back — so the intervals overlap again. The lab below is a miniature resolver so you can watch that intersection logic run.
When two of your dependencies need different versions of a third, you get a conflict. Understanding this saves hours. A resolver finds a version satisfying all constraints — or fails. Here's the core idea, runnable.
urllib3, but A demands >=2 and B demands <2; the two ranges meet at the 2.0 line and never overlap, so no single version satisfies both — that's ResolutionImpossible.
Read the top as a diamond (app → two libraries → one shared package), then the bottom as a number line showing why no version fits.
- The blue arrows show your app depending on library A and library B. The dashed grey arrows are each library's own requirement on the shared red
urllib3box — labelled>=2from A and<2from B. - Because only one copy of
urllib3can be installed, both requirements must be met by the same version — that's what makes the diamond dangerous. - The bottom bars are the two ranges laid on the version line: B's
<2bar ends exactly where A's>=2bar begins. They touch but never overlap, so their intersection is empty — the resolver reportsResolutionImpossible. Widen one range so the bars overlap and a version reappears.
In short: a resolver looks for a version inside every range at once; when the ranges don't overlap, no such version exists and the honest answer is failure.
resolver.pydef resolve(constraints, available):
"""constraints: list of (min_major, ...). available: sorted versions.
Return the highest version satisfying ALL constraints, or None."""
def ok(v, c): return c["min"] <= v < c["lt_major"]
for v in sorted(available, reverse=True):
if all(ok(v, c) for c in constraints):
return v
return None
# pkgA needs >=1.2,<2 ; pkgB needs >=1.5,<2 -> highest 1.x that fits
avail = [1.1, 1.3, 1.6, 1.9, 2.0, 2.1]
cons = [{"min": 1.2, "lt_major": 2.0}, {"min": 1.5, "lt_major": 2.0}]
print("resolved to:", resolve(cons, avail)) # 1.9
# now a conflict: pkgC demands >=2.0
cons2 = cons + [{"min": 2.0, "lt_major": 3.0}]
print("with pkgC>=2.0:", resolve(cons2, avail)) # None -> CONFLICT
resolved to: 1.9
with pkgC>=2.0: None
When two of your packages each demand a different version of a third package, something has to give — that's a dependency conflict. Real tools like pip run a resolver to find one version that satisfies everyone, or to report failure. This lab is a miniature resolver so you can see the logic.
resolve(constraints, available)takes a list of rules and the list of versions that actually exist, and returns the highest version that obeys all the rules.ok(v, c)is true when a versionvsits inside one constraint's window (at or abovemin, belowlt_major).- The loop walks versions highest-first (
reverse=True) and returns the first that passesall(...)the constraints — so you always get the newest version that fits. - First run: pkgA needs
>=1.2,<2and pkgB needs>=1.5,<2. The newest version satisfying both is1.9. - Then a conflict is added: pkgC demands
>=2.0, but the others cap at below2.0. No single version can satisfy all three, soresolvereturnsNone.
What the output means: resolved to: 1.9 (a version everyone accepts), then with pkgC>=2.0: None — None is the resolver's way of saying "impossible, these requirements conflict."
Try this: Add 2.5 to the avail list and change pkgA/pkgB's lt_major to 3.0. Now the conflict disappears and the resolver finds a version that satisfies pkgC too.
requirements.txt from pip freeze, or poetry/uv locks) records the exact resolved versions so every install is identical. Apps commit a lockfile; libraries specify ranges and let the app resolve.5 · Advanced — a clean project layout advanced
Every messy Python project starts the same innocent way: one script.py at the top of a folder. Then a second script, a helpers.py, a stray test.py, a data.csv, three notebooks, and a requirements.txt someone added once and never updated. It runs fine on your laptop — right up to the day you try to install it somewhere else and discover the imports only worked because you happened to be standing in that exact folder. Nothing was wrong with the code; it was never arranged to be shippable.
The analogy is a kitchen versus a junk drawer. In a junk drawer everything is technically present — but finding the tape means digging past batteries and takeout menus. A professional kitchen has a place for knives, a place for pans, a place for spices, and any cook who walks in already knows where to look. A standard project layout is that shared floor plan: your importable code lives under src/, your tests live under tests/, and a single control file (pyproject.toml) sits at the top describing the whole thing. The value isn't tidiness for its own sake — it's that tools and teammates already know the layout, so packaging, testing, and imports all just work without special-casing your project.
The one non-obvious piece worth understanding is why the code goes under src/ rather than loose at the top. With a flat layout, import my_project can accidentally succeed by picking up the folder you're standing in — even before the package is properly installed — so you test something subtly different from what your users will install. Pushing the code down into src/ removes that shortcut: the only way to import it is to install it, which means you test the installed package, exactly as a teammate would get it. The common mistake is dismissing all this as ceremony and leaving scripts at the root; that flat pile is precisely what can't be pip-installed, can't be tested in isolation, and quietly imports the wrong thing. The tree below is the shape to grow into.
pyproject.toml describes the project; code lives under src/ and tests under tests/ — and pushing code into src/ means you can only import it once installed, so you test exactly what a teammate installs.
Read it as a folder tree top-down: the project root at the top, its three top-level parts on the next row, and the package's files hanging below src/.
- The blue root box is the project folder. Its three solid blue arrows fan out to the three things every clean project has: the amber
pyproject.tomlcontrol file, the greensrc/my_project/code folder, and thetests/folder. - The dashed grey arrow from
src/down to__init__.py·core.pyshows where your actual importable code lives —__init__.pymarks it as a package,core.pyis a module. - The green payoff line is the whole reason for
src/: with a flat layout you can accidentally import the folder you're standing in before installing; burying the code undersrc/removes that shortcut, so the only way to import it is to install it — you test the installed package, not a lucky path.
In short: one control file, code under src/, tests apart — a layout tools and teammates already know, and one that forces you to test what you actually ship.
layout.txtmy_project/
├── README.md
├── .gitignore
├── pyproject.toml # metadata + deps + entry points (modern standard)
├── src/my_project/
│ ├── __init__.py
│ └── core.py
└── tests/ # your Testing section lives here
└── test_core.py
This isn't code that runs — it's the standard folder shape of a professional Python project. Following it means tools, teammates, and packaging all know where to look. The ├── and └── lines are just a drawing of the folder tree (indentation = "inside this folder").
README.mdexplains the project;.gitignorelists files Git should ignore (like.venv/).pyproject.tomlis the modern control file: it holds the project's name, version, dependencies, and how to build it (covered in the next lab).src/my_project/holds your actual code.__init__.pymarks the folder as an importable package;core.pyis your module. Putting code undersrc/prevents accidentally importing it before it's properly installed.tests/holds your tests, kept separate from the code they check.
Try this: Compare this to your own scripts folder. The single biggest upgrade is moving loose .py files into src/my_project/ and adding a pyproject.toml — that's what turns "some scripts" into "a package".
6 · Professional — package with pyproject.toml professional
There's a quiet line every project eventually needs to cross: from "a folder of scripts I run" to "a thing other people install." The moment a teammate asks "how do I use your tool?" and your honest answer is "clone the repo, cd into it, activate the venv, then run python scripts/thing.py from this specific directory" — you've hit the wall. That's not a product, it's a set of instructions people will get wrong. Turning the folder into a real package is what lets them type pip install once and then just run your command from anywhere, like they run black or pytest.
The everyday analogy is the difference between a home-cooked meal and a boxed product on a shelf. Both are food, but the boxed one has a label listing exactly what's inside, how much, what it needs, and how to prepare it — so a total stranger can use it without phoning you. pyproject.toml is that label for your code. In one declarative file it states the package's name, its version, which Python it needs, what it depends on, and even what command it should expose once installed. It's written in TOML, a deliberately boring key = value format grouped into [sections] so both humans and tools can read it.
Two ideas in this file repay understanding. First, [project.scripts] is the magic that makes your-tool a real terminal command: it maps a name to a function, and after install, typing that name runs that function — that's literally how the CLI tools you use every day get their commands. Second, pip install -e . (an editable install) installs the package while pointing back at your source files, so your edits take effect immediately without reinstalling — the sweet spot for developing a package you're also using. The common mistake is reaching for the old setup.py you saw in a five-year-old tutorial; the modern standard (PEP 621) is this single declarative pyproject.toml, and it's both simpler and what current tooling expects.
pyproject.toml feeds the build, which produces a pip install-able wheel and — via [project.scripts] — a real mytool command you can run from anywhere.
Read it left to right as a transform: the config file on the left is the input, the build in the middle is the transform, and the two boxes on the right are what comes out.
- The blue box on the left is
pyproject.toml— the single "label" listing the package'sname,version, the Python it needs, itsdependencies, and a[project.scripts]entry. - The amber box in the middle is the build/install step that reads that label. The blue arrows show it producing two outputs.
- The two green boxes are the payoff: a wheel (
.whl) that anyone canpip install, and a realmytoolterminal command — created purely by the[project.scripts]line mapping a name to a function, exactly howblackorpytestbecome commands.
In short: declare once in pyproject.toml; the build hands back an installable package and a runnable command.
pyproject.toml[project]
name = "my-project"
version = "0.1.0"
requires-python = ">=3.10"
dependencies = ["requests>=2.30,<3"]
[project.scripts]
mytool = "my_project.core:main" # pip install . -> `mytool` command
[build-system]
requires = ["hatchling"]
build-backend = "hatchling.build"
# build a wheel: python -m build ; editable install: pip install -e .
pyproject.toml is the one file that describes your whole package: its name, version, what Python it needs, its dependencies, and how to build it. It's written in TOML — a simple key = value config format grouped into [sections]. This replaces the older setup.py.
[project]holds the basics:name,version, the minimum Python (requires-python), anddependencies— here a rangerequests>=2.30,<3meaning "any 2.x from 2.30 up."[project.scripts]creates a command-line command.mytool = "my_project.core:main"means "after install, typingmytoolin a terminal runs themainfunction incore.py."[build-system]tells pip which tool builds the package (hatchlinghere). You rarely change this — it's boilerplate.- The comments show the two commands you'll actually run:
python -m buildmakes a shippable file (a wheel), andpip install -e .does an editable install — installs the project while pointing at your source so edits take effect without reinstalling.
Try this: Run pip install -e . in a folder with this file, then type mytool. The command exists because of the [project.scripts] line — that's how CLI tools like black or pytest become terminal commands.
7 · Professional — inspect the environment professional
The most maddening bug reports are the ones that end with "…but it works fine here." A user's agent crashes on a version mismatch you can't reproduce, and the whole conversation is two people describing invisible environments to each other. The way out is to stop guessing and have the program tell you what's actually installed on the machine where it's running. That single habit — logging your real environment — collapses a day of back-and-forth into one glance at a log line.
The analogy is the label inside a device that reads "model X, firmware 2.1." When something misbehaves, a technician doesn't interrogate you about what you think you have — they read the label. Your running Python process has exactly such a label, and you don't need to shell out to pip list or parse text to read it: the standard library's importlib.metadata asks the environment directly, in code, with no extra packages to install. It can hand you the version of any single package or enumerate everything installed.
Why do this from inside your program rather than at a terminal? Because the terminal only tells you, once, right now. Reading metadata in code means your app can print its own package versions into its startup logs, an agent can check that a critical dependency meets a minimum before it does anything risky, and a diagnostic endpoint can report its environment on demand. The common mistake is scraping pip freeze output as text and pattern-matching it — brittle, slow, and it breaks the moment the format shifts. Query the metadata API instead; it's the same source of truth, structured and reliable. This lab reads it, and it's the exact building block the tech-lead CI check in §8 turns into an automated guardrail.
pip freeze output is brittle; importlib.metadata hands your program structured version data in code — no extra packages, no fragile string-matching.
Start at the single box on the left (what's actually installed) and follow the two arrows that leave it — each is a different way your program can find that out.
- The blue box on the left is the environment: the real set of packages and versions on the machine your code is running on.
- The dashed grey arrow leads to the red box — the brittle approach: shelling out to
pip freezeand pattern-matching its text output, which breaks the moment the format shifts. - The solid blue arrow leads to the green box — the reliable approach: calling
importlib.metadata(version(pkg)for one package,distributions()to list them all) directly in code. Same source of truth, but structured and stable.
In short: ask the environment in code via importlib.metadata rather than scraping pip freeze — reliable, and the building block for the §8 CI drift check.
metadata.pyfrom importlib.metadata import version, distributions
print("pip:", version("pip"))
some = sorted(d.metadata["Name"] for d in distributions())[:5]
print("installed (sample):", some)
pip: 24.0
installed (sample): ['pip', 'requests', 'setuptools', ...]
Sometimes you need your program to ask "what's actually installed here, and which version?" — for logging, debugging, or compatibility checks. Python's built-in importlib.metadata reads that information straight from the environment, no extra packages needed.
from importlib.metadata import version, distributionspulls in two helpers:version(get one package's version) anddistributions(list everything installed).version("pip")returns pip's own version as a string and prints it.distributions()yields every installed package; the code reads each one's"Name", sorts them, and keeps the first five ([:5]) as a sample.- The final
printshows that short sample list.
What the output means: Two lines: pip's version (e.g. pip: 24.0) and the first few installed package names alphabetically — a quick snapshot of what lives in this venv.
Try this: Change [:5] to [:20] to see more, or call version("requests"). If you ask for a package that isn't installed, you'll get an error — which is exactly what the next lab turns into a useful CI check.
8 · Tech-lead — reproducibility across the team tech-lead
Every "works on my machine" story ends the same way, and as a lead it eventually becomes your story to end. A deploy fails at 6pm, three engineers pile onto a call, and forty minutes later someone finds it: one machine had a dependency that had floated to a newer version between two installs. No one changed the code. No one changed the requirements on purpose. A single unpinned line drifted, and the whole team paid for it. The junior-engineer reaction is to fix that one version and move on; the tech-lead reaction is to make the whole class of bug impossible to merge again.
The analogy is the shift from "we're all careful" to "the door won't open unless it's safe." Verbal agreements — "everyone pin your deps, okay?" — fail exactly when it matters, because they rely on every person remembering under deadline pressure. What scales instead is turning reproducibility from a hope into a checkable property: a pinned lockfile committed to the repo, a documented one-command setup, and an automated gate in CI that reads the environment and refuses to merge anything that has drifted. The rule stops living in people's heads and starts living in the pipeline, where it can't be forgotten.
Concretely, "reproducible" means: same lockfile plus same Python version yields the same bytes installed, on a laptop, a CI runner, or inside a container — no exceptions. That's why apps commit a fully pinned lock (exact versions, ideally with hashes) while libraries stay loose and let the installing app resolve. The CI check in the lab below is the enforcement arm: it audits what's actually installed against what's required and exits non-zero on any mismatch, failing the build before the mismatch reaches production. The common mistake is trusting a requirements.txt full of >= ranges to be reproducible — it isn't; two installs a week apart can land on different versions. Ranges express intent; a pinned lock plus this gate is what delivers reproducibility, and enforcing that distinction is squarely the lead's job.
A lead guarantees everyone runs the same environment: pinned lockfiles, a documented setup, and a CI check that catches drift before it causes "works on my machine" bugs.
Follow the arrows left to right: a commit flows into the audit, and the audit branches to one of two outcomes.
- The blue box on the left is a commit opening a pull request. Its arrow feeds the central "CI env-audit" box — the script from the lab below that reads installed versions and compares them to the pinned lockfile.
- The audit forks into two ends: the green box (top) is the match case — the versions agree, the audit exits 0, and the build merges. The red box (bottom, reached by the dashed "mismatch" arrow) is drift — the audit exits non-zero and the merge is blocked.
- The payoff is the line underneath: the rule no longer lives in people's heads ("everyone pin your deps, okay?") but in the pipeline, where a deadline can't make anyone forget it.
In short: the audit is a gate — match passes, drift fails the build — so a floated dependency can never quietly reach production.
env_audit.pyfrom importlib.metadata import version, PackageNotFoundError
REQUIRED = {"pip": None} # in real use: {"requests":"2","pytest":"8", ...} pinned majors
def audit(required):
problems = []
for pkg, want_major in required.items():
try: have = version(pkg)
except PackageNotFoundError: problems.append(f"MISSING: {pkg}"); continue
if want_major and have.split(".")[0] != want_major:
problems.append(f"{pkg}: want major {want_major}, have {have}")
return problems
issues = audit(REQUIRED)
print("environment OK" if not issues else "DRIFT:\n" + "\n".join(issues))
environment OK
A team lead's job is to make sure everyone runs the same versions. This script is a drift check: it compares what's installed against what's required and reports any mismatch. Wired into CI, it fails the build before a version mismatch causes a "works on my machine" bug.
REQUIREDis a dictionary ofpackage: expected-major-version. Here it only checks thatpipexists (None= don't check the version); the comment shows a real one pinning majors like{"requests":"2", "pytest":"8"}.audit(required)loops over each required package and collectsproblemsinto a list.try: have = version(pkg)attempts to read the installed version. If the package is missing it raisesPackageNotFoundError, which theexceptcatches and records asMISSINGinstead of crashing.have.split(".")[0]grabs the installed major number. If it doesn't match the wanted major, that's flagged as drift.- Finally it prints
environment OKwhen the problems list is empty, or lists every problem it found.
What the output means: environment OK — the list of problems was empty, so the environment matches requirements. In CI you'd have the script exit non-zero on problems to fail the build.
Try this: Add a fake package: REQUIRED = {"pip": None, "nonexistent-pkg": "1"} and re-run. You'll see DRIFT: MISSING: nonexistent-pkg — the exact signal that stops a broken environment from shipping.
Exercise DF5.1 — Package & guard a project
Context: This capstone folds the whole lesson into one shippable artifact: a src/-layout package that installs cleanly, exposes a command, and guards its own reproducibility in CI.
Your task: Restructure a script into a src/ layout, write a pyproject.toml with a [project.scripts] entry, pip install -e . it, and add the env_audit pinned-dependency check to CI.
Requirements:
- Move importable code under
src/and package it correctly pyproject.tomldeclares metadata, dependencies, and a console entry pointpip install -e .succeeds and the command runs- CI runs the
env_auditcheck and fails on any floating dependency - Bonus: deliberately introduce a dependency conflict, then resolve it by dropping a pin or upgrading
💡 Hint: Reuse the reproducibility check from the Industry rung as the CI step; the src/ layout keeps import paths honest once installed editable.
🪜 Practice ladder beginner → industry
Six graded exercises, easy to real-world. Try each before opening its solution.
Context: Every serious Python project runs in its own isolated environment so one project's dependencies never leak into another's. A virtual environment is the first thing you create before installing anything.
Your task: Create an isolated virtual environment, activate it, install requests into it, and prove the install is local to that env and not your global Python.
Requirements:
- Create the env with the stdlib
venvmodule into a.venv/folder - Show the macOS/Linux activation command (and note the Windows variant)
- Install
requestsonly after activating - Verify with
pip list/which pythonthat the package and interpreter live inside.venv, not the system Python - Deactivate and confirm the package is no longer importable globally
💡 Hint: python3 -m venv .venv then source .venv/bin/activate; a quick which python before and after activation makes the isolation obvious.
Show solution
python3 -m venv .venv # create the env in ./.venv
source .venv/bin/activate # activate (Windows: .venv\Scripts\activate)
python -m pip install requests
python -c "import requests; print(requests.__version__)"
which python # points inside .venv, not /usr/bin
deactivate # leave the env
A venv isolates one project's dependencies so upgrading a package here can't break another project.
Context: A colleague cloning your repo must be able to recreate the exact set of packages you tested against. That is what a pinned requirements.txt buys you, and the pin operator you choose decides how reproducible it really is.
Your task: Freeze your current environment into a requirements.txt, then recreate the identical package set in a brand-new env from that file, and explain the difference between >= and == pins.
Requirements:
- Generate the file with
pip freeze(exactpackage==versionlines) - Recreate into a fresh env with
pip install -r requirements.txt - State that
==locks one version while>=allows any newer one — trading reproducibility for freshness - Confirm the two environments hold the same versions
💡 Hint: Freeze in the working env, delete/recreate .venv, install from the file, and diff the two pip freeze outputs — they should match line-for-line.
Show solution
pip freeze > requirements.txt # exact versions of everything installed
# recreate elsewhere:
python3 -m venv .venv && source .venv/bin/activate
pip install -r requirements.txt
Example lines and what they mean:
requests==2.32.3 # exact -- reproducible, what freeze writes
flask>=3.0 # floor -- may pull a newer minor, less reproducible
Use loose ranges in your abstract deps (a library) and exact pins in a lock for apps, so deploys are byte-for-byte repeatable.
Context: Semantic versioning encodes a promise: patch and minor bumps are backward compatible, a major bump is allowed to break you. Reading that promise programmatically is how tooling decides whether an upgrade is safe to take.
Your task: Write a function that parses a MAJOR.MINOR.PATCH string and, given an old and new version, classifies the upgrade as a safe minor/patch bump or a breaking major bump. Test 1.4.0→1.5.2 and 1.4.0→2.0.0.
Requirements:
- Parse the three numeric components into comparable integers
- A change in
MAJORis flagged breaking; changes only below it are safe - Return a clear verdict (e.g. "safe" vs "breaking") per comparison
1.4.0→1.5.2is safe;1.4.0→2.0.0is breaking — assert both
💡 Hint: Split on . and compare the tuples; the major component alone decides the breaking-vs-safe verdict.
Show solution
Pure Python — the rule that >= pins rely on:
def parse(v):
return tuple(int(x) for x in v.split("."))
def upgrade_kind(old, new):
(a1, b1, c1), (a2, b2, c2) = parse(old), parse(new)
if a2 > a1: return "major -- may break, read changelog"
if a2 == a1 and b2 > b1: return "minor -- new features, backward-compatible"
if (a2, b2) == (a1, b1) and c2 > c1: return "patch -- bug fixes only"
return "same or downgrade"
print(upgrade_kind("1.4.0", "1.5.2")) # minor -- backward-compatible
print(upgrade_kind("1.4.0", "2.0.0")) # major -- may break
Semver's contract: within a major version, upgrades should not break your code. A major bump is a signal to test.
Context: Diamond dependency conflicts are a fact of life: two libraries you both need pin the same transitive dependency to ranges that don't overlap, and pip cannot satisfy both at once.
Your task: Explain why pip can't install an app that needs library A (urllib3>=2) and library B (urllib3<2) together, then write a resolver check that detects the incompatibility directly from the two version ranges.
Requirements:
- State plainly that the two ranges have no version in common, so no single
urllib3satisfies both requirers - Represent each requirement as a comparable bound (operator + version)
- The check returns that the ranges are incompatible (empty intersection)
- It would return compatible if the ranges did overlap — test both cases
💡 Hint: Reduce each constraint to a numeric lower/upper bound and ask whether the intervals intersect; >=2 and <2 share no point.
Show solution
Only one version of urllib3 can be installed, and no single version satisfies both >=2 and <2 — that is a hard conflict pip reports as "ResolutionImpossible". Detecting it from ranges:
def satisfies(version, lower=None, upper=None):
v = tuple(int(x) for x in version.split("."))
if lower and v < tuple(int(x) for x in lower.split(".")): return False
if upper and v >= tuple(int(x) for x in upper.split(".")): return False
return True
# is there ANY version both accept? check candidate versions
candidates = ["1.26.0", "2.0.0", "2.2.0"]
a_ok = [v for v in candidates if satisfies(v, lower="2.0.0")] # A: >=2
b_ok = [v for v in candidates if satisfies(v, upper="2.0.0")] # B: <2
common = set(a_ok) & set(b_ok)
print("compatible versions:", common or "NONE -- conflict")
Real fix: upgrade B to a release that supports urllib3 2.x, or pin the app to the older urllib3 and hold A back. The empty intersection is the mathematical statement of "no solution".
Context: A folder of loose scripts is not shippable. Turning it into a proper package with pyproject.toml is what lets teammates pip install it, run it as a command, and depend on it reproducibly.
Your task: Convert a loose script folder into an installable package: write a pyproject.toml declaring the name, version, dependencies, and a console entry point, then install it editable and run the command.
Requirements:
pyproject.tomldeclaresname,version, and adependencieslist- A
[project.scripts]entry maps a command name to a module function - Install with
pip install -e .so edits take effect without reinstalling - Running the declared command actually invokes your entry-point function
💡 Hint: The [project.scripts] table's cmd = "pkg.module:func" form is what creates the runnable command after an editable install.
Show solution
# pyproject.toml
[build-system]
requires = ["setuptools>=61"]
build-backend = "setuptools.build_meta"
[project]
name = "reportkit"
version = "0.1.0"
dependencies = ["requests>=2.31"]
[project.scripts]
report = "reportkit.cli:main" # `report` on the CLI calls cli.main()
pip install -e . # editable install: code changes take effect immediately
report --help # the entry point now works as a command
pyproject.toml is the modern standard (PEP 621): one declarative file for build config, metadata, and dependencies — no setup.py boilerplate.
Context: "Works on my machine" is almost always an un-pinned dependency drifting to a new version between two installs. As tech lead you make reproducibility a mergeable, checkable property instead of a hope.
Your task: Define what makes an environment reproducible, then write a Python check that scans a requirements file and flags any unpinned (floating) dependency before it is allowed to merge.
Requirements:
- Articulate reproducibility: same file + same Python must yield the same versions, i.e. every line is exactly pinned
- Parse each requirement line and detect whether it is exactly pinned (
==) or floating (>=,~=, bare name) - Return the list of offending (floating) lines, empty when all are pinned
- Wire it as a gate: non-empty result should fail the check / block the merge
- Handle comments and blank lines without false positives
💡 Hint: A line is safe only if it contains an == version pin; treat everything else — bare names, >=, ranges — as a floating dependency to report.
Show solution
Reproducible = same inputs, same bytes: a pinned lock (exact versions and hashes), a pinned Python version, and a clean env built in CI — not the developer's laptop. A gate that fails on floating pins:
import re, sys
def audit(requirements_text):
floating = []
for line in requirements_text.splitlines():
line = line.split("#")[0].strip()
if not line:
continue
# a reproducible line has an exact == pin
if "==" not in line:
floating.append(line)
return floating
reqs = """
requests==2.32.3
flask>=3.0
numpy
pydantic==2.7.1
"""
bad = audit(reqs)
print("unpinned:", bad) # ['flask>=3.0', 'numpy']
sys.exit(1 if bad else 0) # non-zero fails the CI gate
Layered fix: developers edit loose top-level deps, a tool (pip-tools/uv/poetry) compiles a fully pinned lock with hashes, CI builds from the lock only, and this check refuses to merge a lock that still floats.
✓ Checkpoint — you can move on when you can…
- Use venvs and pinned dependencies.
- Choose specs by semver; understand resolution + conflicts + lockfiles.
- Structure and package a project with pyproject.
- Enforce team-wide reproducibility in CI.
Knowledge check check yourself
Why does every project get its own virtual environment (venv) instead of installing packages globally?
Show answer
python/pip point into .venv/ — that path change is the isolation.Under semantic versioning (MAJOR.MINOR.PATCH), which part changing signals a breaking change, and what does a spec like >=2.3,<3 allow?
Show answer
>=2.3,<3 allows any 2.x at or above 2.3.0 but forbids 3.0.0, since crossing the major is unsafe.