AI EngineeringZero to ProductionHome·About·What’s new·Contact
Developer Foundations · Chapter DF5

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.

⏱️ ~3 hours🧪 8 labs🎯 Beginner→Tech-lead

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.
▶ Runnable companionCode saved under 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.

without a venv — one shared box with a venv — one box per project project A project B system Python one shared copy of each liblast install wins upgrade for B silently breaks A project A project B .venv (A) old lib .venv (B) new lib activate rewires python + pip → inside .venv The venv is just a private box plus a path change. Share one system Python and the two projects fight over a single library copy; give each its own .venv and activation points python/pip inside it, so A keeps its old version while B pins a new one.
🗺️ How to read this diagram

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 .venv box: 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 python and pip so 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.

Example code for learning — review, test, and adapt it before running against real or production systems. Commands can create, change, or delete resources. See the Terms & Disclaimer.
shell · create, activate, freeze
venv.shpython3 -m venv .venv
source .venv/bin/activate           # prompt shows (.venv)
pip install requests
pip freeze > requirements.txt       # snapshot exact versions
deactivate
▶ How this works

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.

  1. python3 -m venv .venv creates the environment in a hidden folder named .venv inside your project. It's just a directory of files — nothing is installed system-wide yet.
  2. source .venv/bin/activate switches 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.
  3. pip install requests downloads the requests package into the active venv only. Other projects never see it.
  4. pip freeze > requirements.txt writes 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.
  5. deactivate turns 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.

The #1 'works on my machine' causeInstalling globally mixes every project's dependencies. Always create a venv per project. Containers (CD) take this isolation further; a venv is the local foundation.

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.

you name two — freeze captures the whole tree they drag in pip install fastapi uvicorn fastapi you named this uvicorn you named this starlette pydantic click h11 pip freeze → every box, pinned == → requirements.txt Freeze records the whole tree, not just what you typed. The two blue-labelled boxes are what you installed; the amber boxes are transitive dependencies they pulled in — and pip freeze pins all of them, which is why the rebuild is complete instead of "top names, latest rest."
🗺️ How to read this diagram

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 — fastapi and uvicorn.
  • 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 freeze snapshots 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.

shell · pin & reproduce
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 ...
▶ How this works

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

  1. pip install fastapi uvicorn installs two packages (a web framework and the server that runs it) into the active venv.
  2. pip freeze > requirements.txt snapshots every installed package and its exact version into the file — not just the two you named, but everything they pulled in too.
  3. pip install -r requirements.txt is the reverse: the -r flag 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.
  4. cat requirements.txt just 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.

the leftmost number is the one that can break you 2 MAJOR break 3 MINOR compat 1 PATCH bugfix 2.2 2.3 2.9 3.0 ==2.3.1 → one exact point (apps) >=2.3,<3 → 2.x (libs) ~=2.3.0 → only 2.3.x (conservative) One number, three promises; one spec, a different slice of the line. Only a MAJOR bump (the red digit) is allowed to break you — and the spec you write picks how much of the version line you'll accept, from a single exact point to any 2.x below 3.0.
🗺️ How to read this diagram

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.1 into 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.2 up past 3.0. Each bar below shows what a spec accepts: ==2.3.1 is a single dot; >=2.3,<3 is the wide blue bar covering every 2.x from 2.3 up to (but not including) 3.0; ~=2.3.0 is 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.

SpecMeansUse for
x==2.3.1exactly thisapps (reproducible)
x>=2.3,<3compatible rangelibraries
x~=2.3.02.3.* onlyconservative pins
Python · compare versions safely (runs)
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
▶ How this works

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.

  1. 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.
  2. is_compatible(...) returns True only 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.
  3. The first print checks 2.3.1 against >=2.3,<3 — it fits, so it prints True.
  4. The second checks 3.0.0 against the same range. Its major is 3, which is not below 3, so it prints False — 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.

A and B both point down at one shared package (the diamond) your app library A library B urllib3 (shared) only ONE copy can install >=2 <2 version line — a solution lives only where the ranges overlap B: <2 A: >=2 — no overlap → ResolutionImpossible A conflict is an empty intersection. Both libraries point down at one shared 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.
🗺️ How to read this diagram

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 urllib3 box — labelled >=2 from A and <2 from B.
  • Because only one copy of urllib3 can 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 <2 bar ends exactly where A's >=2 bar begins. They touch but never overlap, so their intersection is empty — the resolver reports ResolutionImpossible. 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.

Python · a tiny version resolver (runs)
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
▶ How this works

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.

  1. 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.
  2. ok(v, c) is true when a version v sits inside one constraint's window (at or above min, below lt_major).
  3. The loop walks versions highest-first (reverse=True) and returns the first that passes all(...) the constraints — so you always get the newest version that fits.
  4. First run: pkgA needs >=1.2,<2 and pkgB needs >=1.5,<2. The newest version satisfying both is 1.9.
  5. Then a conflict is added: pkgC demands >=2.0, but the others cap at below 2.0. No single version can satisfy all three, so resolve returns None.

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.

Lockfiles freeze the resolutionA lockfile (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.

one control file on top · code under src · tests kept separate my_project/ pyproject.toml the control file src/my_project/ importable code tests/ checks, kept apart __init__.py · core.py code under src → the only way to import it is to install it A shared floor plan tools already understand. The single 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.
🗺️ How to read this diagram

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.toml control file, the green src/my_project/ code folder, and the tests/ folder.
  • The dashed grey arrow from src/ down to __init__.py · core.py shows where your actual importable code lives — __init__.py marks it as a package, core.py is 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 under src/ 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 · the standard shape
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
▶ How this works

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

  1. README.md explains the project; .gitignore lists files Git should ignore (like .venv/).
  2. pyproject.toml is the modern control file: it holds the project's name, version, dependencies, and how to build it (covered in the next lab).
  3. src/my_project/ holds your actual code. __init__.py marks the folder as an importable package; core.py is your module. Putting code under src/ prevents accidentally importing it before it's properly installed.
  4. 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.

one declarative label in → an installable, runnable tool out pyproject.toml name · version requires-python dependencies [project.scripts] build / install wheel (.whl) pip install-able mytool terminal command [project.scripts] maps a name → a function → a real command The label turns a folder into a product. One declarative 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.
🗺️ How to read this diagram

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's name, version, the Python it needs, its dependencies, 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 can pip install, and a real mytool terminal command — created purely by the [project.scripts] line mapping a name to a function, exactly how black or pytest become commands.

In short: declare once in pyproject.toml; the build hands back an installable package and a runnable command.

config · pyproject.toml
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 .
▶ How this works

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.

  1. [project] holds the basics: name, version, the minimum Python (requires-python), and dependencies — here a range requests>=2.30,<3 meaning "any 2.x from 2.30 up."
  2. [project.scripts] creates a command-line command. mytool = "my_project.core:main" means "after install, typing mytool in a terminal runs the main function in core.py."
  3. [build-system] tells pip which tool builds the package (hatchling here). You rarely change this — it's boilerplate.
  4. The comments show the two commands you'll actually run: python -m build makes a shippable file (a wheel), and pip 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.

two ways to ask what's installed — one brittle, one reliable the environment scrape `pip freeze` text then string-match the lines brittle · breaks on format change importlib.metadata version(pkg) · distributions() structured · same source of truth what's really installed Query the metadata, don't scrape the text. Both paths read the same installed environment, but parsing pip freeze output is brittle; importlib.metadata hands your program structured version data in code — no extra packages, no fragile string-matching.
🗺️ How to read this diagram

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 freeze and 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.

Python · read installed metadata (runs)
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', ...]
▶ How this works

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.

  1. from importlib.metadata import version, distributions pulls in two helpers: version (get one package's version) and distributions (list everything installed).
  2. version("pip") returns pip's own version as a string and prints it.
  3. distributions() yields every installed package; the code reads each one's "Name", sorts them, and keeps the first five ([:5]) as a sample.
  4. The final print shows 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.

the gate reads the real environment and decides pass or block commit opens a PR CI env-audit installed vs pinned lockfile match → exit 0 build passes · merge drift → exit non-zero merge blocked OK mismatch reproducibility becomes a checkable property, not a verbal agreement The gate turns "please pin your deps" into a rule the pipeline enforces. Every commit runs an audit that compares what's actually installed against the pinned lockfile; match and it merges, drift and it exits non-zero — blocking the mismatch before it reaches production.
🗺️ How to read this diagram

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.

Python · fail CI on dependency drift (runs)
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
▶ How this works

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.

  1. REQUIRED is a dictionary of package: expected-major-version. Here it only checks that pip exists (None = don't check the version); the comment shows a real one pinning majors like {"requests":"2", "pytest":"8"}.
  2. audit(required) loops over each required package and collects problems into a list.
  3. try: have = version(pkg) attempts to read the installed version. If the package is missing it raises PackageNotFoundError, which the except catches and records as MISSING instead of crashing.
  4. have.split(".")[0] grabs the installed major number. If it doesn't match the wanted major, that's flagged as drift.
  5. Finally it prints environment OK when 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.

Reproducibility is the foundation for TQ + CDPinned deps + a lockfile + a CI env-audit means every laptop, CI runner, and container runs identical versions. That reproducibility is exactly what the Testing and Containers sections build on — and a lead's job to enforce.

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.toml declares metadata, dependencies, and a console entry point
  • pip install -e . succeeds and the command runs
  • CI runs the env_audit check 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.

Exercise 1 · Create and use a venvBeginner

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 venv module into a .venv/ folder
  • Show the macOS/Linux activation command (and note the Windows variant)
  • Install requests only after activating
  • Verify with pip list / which python that 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.

Exercise 2 · Pin dependenciesIntermediate

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 (exact package==version lines)
  • 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.

Exercise 3 · Compare versions by semverAdvanced

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 MAJOR is flagged breaking; changes only below it are safe
  • Return a clear verdict (e.g. "safe" vs "breaking") per comparison
  • 1.4.0→1.5.2 is safe; 1.4.0→2.0.0 is 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.

Exercise 4 · Reason about a dependency conflictExpert

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

Exercise 5 · Package with pyproject.tomlProfessional

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.toml declares name, version, and a dependencies list
  • 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.

Exercise 6 · Reproducibility across the teamIndustry scenario

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

✓ Knowledge check

Why does every project get its own virtual environment (venv) instead of installing packages globally?

Show answer
A venv is a private, per-project package folder, so one project's pinned versions can't collide with another's. Activating it makes python/pip point into .venv/ — that path change is the isolation.
✓ Knowledge check

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
A MAJOR bump signals breaking changes; MINOR is new-but-compatible and PATCH is a bugfix. >=2.3,<3 allows any 2.x at or above 2.3.0 but forbids 3.0.0, since crossing the major is unsafe.
© 2026 studybydoing.in · AI Engineering: Zero to Production · All rights reserved. · About · Privacy Policy · Terms · Contact
Educational content, provided as-is and without warranty. Code samples are examples — review, test, and adapt them before using in production. See the Terms of Use & Disclaimer. Use at your own risk.
© studybydoing.in