Module 2: `pyproject.toml` and the Build System

4. Dependencies and `requires-python`

Description

By the end of this lesson you'll know how to declare the two things that define the environment your package needs to work: dependencies — the list of other packages yours depends on — and requires-python — the range of Python versions it runs on. You'll learn the syntax of version specifiers (>=, ~=, ==, <) and, more importantly, when to use each one: why you pin a range instead of an exact version, and what problem each extreme causes (pinning too tight, not pinning at all). You'll see, by building a real wheel, how these declarations land in the metadata as Requires-Dist and Requires-Python, which is what pip reads to know what to install alongside your package. And you'll understand why reservo, which only uses the standard library, has dependencies = [].

This matters because dependencies are the contract between your package and the world: "for me to work, you also need these packages, at these versions." If you declare it wrong — missing dependencies, or ranges that are too strict or too loose — your package will install broken on someone else's machine, which is exactly the "works on my machine" problem packaging exists to solve. Declaring dependencies correctly is what makes pip install reservo bring in everything it needs and work on any machine.

Connection to the module: this lesson completes the [project] table you started in lesson 3 (there, identity; here, what it needs to run). With [project] complete, lesson 5 writes [build-system] and lesson 6 builds. There's an important boundary to respect here: here you declare the dependencies; installing and isolating them in a virtual environment is Module 3. This lesson is about the written contract, not about executing it.

The recipe and its ingredient list

Think of a cooking recipe. At the top it has an ingredient list: "2 eggs, 200 g flour, 1 cup milk." That list is a promise: if you have these ingredients, the recipe works. It's not the food, it's the declaration of what's needed to make it. And sometimes the list is specific about quantities or types — "wheat flour, not corn" — because with the wrong ingredient the dish comes out wrong.

dependencies is your package's ingredient list. It says: "for reservo to work, you also need to have these other packages installed." And like in a recipe, sometimes you need to be specific about the version of the ingredient — "I need httpx version 0.27 or higher, because 0.26 doesn't have the function I use." That specificity is what version specifiers are.

And requires-python is like the note "you need an oven" at the bottom of the recipe: it's not an ingredient, it's a requirement of the environment. It says which Python versions your package runs on. Without the right oven, having all the ingredients doesn't help.

The key difference from food: when someone runs pip install reservo, pip reads your ingredient list and goes to fetch them automatically. You don't have to install them one by one by hand; you declare the list and the installer resolves it. That's why declaring it correctly matters so much: it's what pip obeys.

dependencies: the list of what you need

The dependencies field is a list of strings, each one with a package name and, optionally, a version constraint:

dependencies = [
    "httpx>=0.27",
    "rich~=13.0",
]

This says: "my package needs httpx version 0.27 or higher, and rich compatible with 13.x." When someone installs your package, pip will see this list and install httpx and rich (and whatever they in turn need) automatically.

Each entry in the list is a requirement specifier: package_name + an optional version specifier, all in one string. If you don't add a version constraint (bare "httpx"), pip installs the latest available version that's compatible with everything else.

reservo's case: dependencies = []

reservo only uses Python's standard library: argparse for the CLI, and nothing else. argparse isn't an external dependency — it comes with Python — so there's nothing extra to install. That's why its declaration is an empty list:

dependencies = []

This is correct and deliberate, not an oversight. It's a virtue: a package with no external dependencies is easier to install, more stable (nothing external can break), and lighter. Python's stdlib is huge — json, datetime, pathlib, argparse, sqlite3, http — and plenty of useful packages don't need anything more. Declaring dependencies = [] communicates precisely: "I don't depend on anything outside Python." (And yes, writing the empty list is better than omitting the field: it makes explicit that the absence of dependencies is a decision, not an oversight.)

The version specifiers: how to pin versions

When you do depend on something external, you almost always want to restrict the version. These are the operators used (defined in PEP 440):

SpecifierMeansExample
>=this version or greaterhttpx>=0.27 (0.27, 0.28, 1.0, ...)
<less thanhttpx<1.0 (up to before 1.0)
==exactly this onehttpx==0.27.2 (only that one)
!=anything but this onehttpx!=0.28.0 (avoid a broken version)
~="compatible with"rich~=13.0 (13.0 ≤ x < 14.0)
>=,<a combined rangehttpx>=0.27,<1.0

The three you'll use most:

>= (minimum). "I need at least this version, because the function I use didn't exist before." It's the most common. httpx>=0.27 accepts 0.27 and everything after.

~= (compatible, tilde). "I accept patches and minor versions, but not a major jump that might break things." rich~=13.0 means >=13.0, <14.0: it accepts 13.1, 13.5, but not 14.0. It's a short way of saying "trust minor updates, distrust major ones," which fits with semantic versioning (a major version bump can bring incompatible changes).

== (exact). "Exactly this version, no other." httpx==0.27.2. It's rarely used in a library package (it's too rigid), more so in applications where you want full reproducibility. In a library, pinning with == is problematic because it forces everyone using your package into that exact version, and it can clash with other packages.

Why a range and not an exact version

Here's the design judgment that matters. You have two bad extremes and a healthy middle ground:

Pinning too tight (== in a library). If reservo said httpx==0.27.2, then anyone installing reservo would be forced to have exactly httpx 0.27.2. If that person uses another package that needs httpx>=0.28, there's an impossible conflict: the two can't coexist. Pinning exactly in a library spreads rigidity to everyone who depends on you.

Not pinning anything (bare "httpx"). If you don't add any constraint, pip will install the latest version of httpx that exists on the day someone installs your package. If httpx 2.0 comes out tomorrow with incompatible changes, your package — written for 0.27 — will install with 2.0 and break, without you having touched anything. "It worked yesterday" stops being a guarantee.

The healthy middle ground: a range. httpx>=0.27,<2.0 (or httpx~=0.27 if you trust semver) says: "0.27 onward, but don't cross into 2.0 where things might break." You give pip the freedom to choose the best compatible version — which avoids conflicts with other packages — but you set a ceiling where you know there's danger. It's the balance between "works with what you already have installed" and "doesn't break with a future incompatible version."

The practical rule for a library: floor with >= (the oldest version you know works) and, if you know the next major breaks it, ceiling with <. reservo has none of this because it doesn't depend on anything external.

requires-python: which Python versions it supports

requires-python declares which versions of the Python interpreter your package runs on:

requires-python = ">=3.10"

This says: "reservo works on Python 3.10 and higher." It uses the same specifier syntax. Its effect is real and protective: if someone with Python 3.9 tries pip install reservo, pip refuses and tells them their Python is too old, instead of installing it and letting it blow up when using a 3.10+ feature. It's a contract pip enforces.

Why does reservo require >=3.10 and not >=3.14 (the version we developed it with)? Because reservo's code doesn't use anything exclusive to 3.14: it uses syntax (like list[str], X | None) that's existed since 3.10. Setting >=3.10 widens the audience who can install it at no cost. The rule: requires-python should reflect the oldest version your code actually works on, not the one you have installed. If you set >=3.14, you'd needlessly exclude everyone on 3.10–3.13, where your package would run just fine.

Worked example: dependencies in the wheel's metadata

Just like the metadata in lesson 3, declared dependencies end up inside the wheel. Let's see it with a sample reservo that does declare dependencies (even though the real reservo has an empty list, it's useful to see where they go). With this snippet in pyproject.toml:

[project]
name = "reservo"
version = "0.1.0"
requires-python = ">=3.10"
dependencies = [
    "httpx>=0.27",
    "rich~=13.0",
]

You build and inspect the metadata:

uv build --wheel
unzip -p dist/reservo-0.1.0-py3-none-any.whl 'reservo-0.1.0.dist-info/METADATA' | grep -E 'Requires-'

What to expect. Each dependency turns into a Requires-Dist line, and requires-python into a Requires-Python line. Here's the actual output:

Requires-Python: >=3.10
Requires-Dist: httpx>=0.27
Requires-Dist: rich~=13.0

Read what happened: your TOML dependencies list became a Requires-Dist line for each entry, with the specifier intact. Those lines are exactly what pip checks when someone installs your package: it sees Requires-Dist: httpx>=0.27 and knows it has to go fetch and install httpx at a version ≥ 0.27. And Requires-Python: >=3.10 is what pip compares against the machine's Python before installing anything. You write the contract in TOML; the backend records it in the metadata; pip obeys it on install. (For the real reservo, with dependencies = [], there simply are no Requires-Dist lines at all — it depends on nothing — and Requires-Python: >=3.10 does show up.)

A look ahead: optional dependencies

There's a sibling field, [project.optional-dependencies], for dependencies only needed in certain cases — for example, testing tools, which whoever uses reservo doesn't need, but whoever develops it does:

[project.optional-dependencies]
test = ["pytest>=8.0"]

With this, pip install reservo installs only the basics, and pip install reservo[test] also installs pytest. It's the standard way to separate "what the package needs to run" from "what's needed to develop or test it." We mention it because you'll see it; the details of installing these extras belong to Module 3, and testing itself is a different ecosystem.

Common mistakes

Pinning == in a library (judgment). What happens: you write dependencies = ["httpx==0.27.2"] in a package others will use as a library. It works for you, but when someone installs reservo alongside another package that needs a different httpx, pip won't be able to satisfy both and the install will fail with a version conflict. Why it happens: == looks "safest" because it pins everything. How to spot it: a dependency resolution error when installing your package alongside others. How to fix it: in a library, use ranges (>=, ~=), not ==. == is for applications with a lockfile (M3), where total reproducibility is actually what you want.

Setting requires-python to the version you develop with (judgment). What happens: you develop on Python 3.14 and write requires-python = ">=3.14" reflexively, even though your code works perfectly on 3.10. The result: you needlessly exclude everyone on 3.10–3.13. Why it happens: you set the version that's in front of you. How to spot it: ask yourself "does my code actually use something that only exists in this version?" If not, the floor is too high. How to fix it: set the oldest version your code works on. It widens the audience for free.

Forgetting to declare a dependency you actually use (process). What happens: your code does import httpx but you forgot to put it in dependencies. On your machine it works because you already have httpx installed for some other reason. But when someone else installs reservo on a clean machine, pip won't install httpx (it's not declared), and the package will blow up with ModuleNotFoundError on the first import. Why it happens: the dependency is already in your environment, so it "works" and you don't notice. How to spot it: install your package in a clean virtual environment (M3) and try importing it; if something's missing, it shows up there. How to fix it: every external library your code imports must be in dependencies. This is, literally, the cause of the "works on my machine" problem packaging exists to cure.

Exercises

Exercise 1 — Choose the specifier. For each situation, write the appropriate dependencies entry: (a) you need httpx at least 0.27, and you trust later versions; (b) you need django compatible with the 5.0 series but without jumping to 6.0; (c) you're developing an app and want exactly numpy 2.1.0 for total reproducibility.

See solution
  • (a) "httpx>=0.27" — floor with >=, no ceiling, because you trust what comes after.
  • (b) "django~=5.0" — the tilde operator ~=5.0 means >=5.0, <6.0: accepts 5.1, 5.2… but not 6.0. (Equivalent to writing "django>=5.0,<6.0".)
  • (c) "numpy==2.1.0" — exact with ==, appropriate in an app with total reproducibility. (In a library it would be too rigid; here the prompt says "app," so it's fine.)

The lesson behind it: >= for the floor, ~= for "compatible without jumping the major," == only in applications where you want to nail down the version.

Exercise 2 — Why dependencies = []? Explain in two or three sentences why reservo has an empty dependency list, and why that's a virtue and not a shortcoming.

See solution

reservo only uses Python's standard library — mainly argparse for the CLI — which comes included with the interpreter and doesn't get installed separately. Since it doesn't import any external package, there's nothing to declare, so dependencies = []. It's a virtue because a package with no external dependencies is easier to install (nothing else to download), more stable (nothing external can break or conflict), and lighter. The explicit empty list also communicates that the absence of dependencies is a decision, not an oversight.

Exercise 3 — Diagnose the "works on my machine." A colleague tells you: "I packaged my library, installed it on my machine, and it works, but a user says importing it gives ModuleNotFoundError: No module named 'httpx'." Their code does import httpx. What's the bug and how do you fix it?

See solution

The bug: they forgot to declare httpx in dependencies. Their code imports it, but pyproject.toml doesn't list it. On their machine it goes unnoticed, because they already had httpx installed for another project, so the import finds the module. But when the user installs the package on a clean machine, pip reads the declared dependencies — where httpx isn't listed — doesn't install it, and the import httpx fails with ModuleNotFoundError.

The fix: add httpx (with a reasonable range, e.g. "httpx>=0.27") to dependencies in pyproject.toml, rebuild, and reinstall. The way to prevent it is testing the install in a clean virtual environment (Module 3), where it's not "contaminated" by what you already have: the missing dependency shows up immediately there. This is the canonical example of the "works on my machine" problem that packaging and correctly declaring dependencies exist to solve.

Summary and next step

In this lesson you completed the [project] table with what defines the package's environment: dependencies (the list of external packages it needs to run) and requires-python (the Python versions it works on). You learned the version specifiers>= for the floor, ~= for "compatible without jumping the major," == only in applications — and the central judgment call: pin a range, not an exact version, because == in a library spreads rigidity and causes conflicts, while pinning nothing leaves your package at the mercy of a future incompatible version. You saw, by building a wheel, that each dependency gets recorded as a Requires-Dist line and requires-python as Requires-Python — exactly what pip obeys when installing. And you understood why reservo has dependencies = []: it only uses the stdlib, and that's a virtue. The boundary that got marked: here you declare the contract; installing and isolating it in a virtual environment is Module 3.

Before moving on you should be able to: write a dependencies entry with the correct specifier for a given situation; explain why a range is better than == in a library; set requires-python to the oldest supported version (not the one you develop with); and recognize the "works on my machine" problem caused by an undeclared dependency.

What's next is the last table missing before you build: the build backend, [build-system]. In lesson 5 you'll understand what a backend is (the tool that takes your code + pyproject.toml and produces the wheel/sdist), what a frontend is, why they're separated (PEP 517), and you'll write reservo's [build-system] table with hatchling — seeing, for real, what happens if you omit it.

Resources