Managing dependencies
Overview
Every requirements/*.txt file in this repo follows one layout convention and one version-constraint pattern. This keeps a single set of pins working across the whole Python matrix and every diff readable. The mechanism for making one line behave differently per interpreter is PEP 508 environment markers. That spec is the authority for the marker syntax used throughout this skill.
Two facts about this repo drive everything below:
- It supports Python
>=3.7(requires-pythonand the classifiers inpyproject.toml). Its CI matrix in.github/workflows/ci-build.ymltests CPython 3.7 through 3.14 only — there is no PyPy (the classifiers list onlyProgramming Language :: Python :: Implementation :: CPython). - Many popular packages keep raising their minimum Python. A bump that raises a dependency's lower bound to a release requiring a newer Python makes pip's resolver find nothing installable on the old interpreters. The install step then fails there before tests even run.
The convention resolves this without dropping old-Python support: pin each interpreter to the newest release it can actually install, using PEP 508 python_version markers.
File-layout convention
Each file starts with a # pip install -r requirements/<file>.txt header, then lists one dependency per section: the name of the dependency (as a # name header), an extremely optional rationale note (starting with # Note: — see below), then the requirement line(s), separated from the next section by a blank line. This makes every pin self-documenting.
# pip install -r requirements/test.txt
# pytest
pytest<9.2
# pytest-cov
# Note: only needed to evaluate coverage on the latest supported python version
pytest-cov>=7.1.0,<8; python_version >= "3.14"
Keep this layout when adding or editing dependencies. Never leave an empty trailing ; (a fossil of a collapsed split; delete it — the old pytest-asyncio<2; line was exactly this).
On the # Note: — omit it by default. The requirement lines and their python_version markers already state what is pinned and which interpreter gets which release, and this skill already establishes that an old-side ceiling marks a floor jump. A note must never restate any of that. Write one only to record rationale the lines cannot express on their own — a non-obvious cap, or a coupling invisible from the specifier. The archetype is the tracerite pin (see "a coupled companion dependency" below): a companion dependency that breaks with no requires_python metadata, which no reader could infer from the line itself. A routine floor-jump split (like chalice, fastapi, or starlette) needs no note at all. Several older sections still carry notes that merely restate their lines; treat those as legacy, not a template to copy.
Which files need Python-version markers
A marker split is only needed for requirements files installed across the full Python matrix. Which file you are editing decides this. To see where a file is installed, read .github/workflows/ci-build.yml. It is the source of truth for which Python versions install which requirements files. Everything except dev_tools.txt is installed by the unittest matrix job across 3.7–3.14.
| File | Installed on | Needs markers? |
|---|---|---|
requirements/adapter_dev.txt |
full matrix (unittest; also typecheck/codecov @3.14) |
Yes, if a bump raises the floor |
requirements/async_dev.txt |
full matrix (unittest; also typecheck/codecov @3.14) |
Yes, if a bump raises the floor |
requirements/test.txt |
full matrix (unittest) |
Yes, if a bump raises the floor |
requirements/test_adapter.txt |
full matrix (unittest; codecov @3.14) |
Yes, if a bump raises the floor |
requirements/test_async.txt |
full matrix (unittest) |
Yes, if a bump raises the floor |
requirements/dev_tools.txt |
lint + typecheck, latest Python only (3.14) |
No, just take the bump (== pins) |
If the bump lands in a latest-Python-only file, take it as-is: no markers, no ceiling, just the layout convention above.
Unlike some sibling repos, bolt-python has no requirements file that feeds packaged wheel metadata: pyproject.toml has no [project.optional-dependencies], and [tool.setuptools.dynamic] resolves only version and readme. So there is no "special" file whose comments leak into a wheel — every requirements/*.txt file is dev/test-only.
The version-constraint pattern
When a dependency's floor rises to a release that requires a newer Python, do not just take the bump, and do not drop old-Python support to make CI pass. Instead, split the requirement into python_version-marked lines that partition the whole matrix. Every interpreter matches exactly one line. Old interpreters keep the last compatible release (with an explicit ceiling), and the newest line is open-ended so future Pythons stay covered.
# aiohttp
aiohttp>=3,<4; python_version < "3.9"
aiohttp>=3.13.5,<4; python_version >= "3.9"
aiohttp 3.13.5 requires Python >=3.9, so Python 3.7/3.8 stay on the older line. The same 3.9 split appears for falcon, fastapi, Flask, Werkzeug, starlette, tornado, websocket_client, and (in test_async.txt) asgiref. The Django split lands at 3.8 instead — Django 4.x requires >=3.8, so 3.7 keeps the 3.2 line:
# Django
Django>=3.2,<4; python_version < "3.8"
Django>=4.2.30,<6; python_version >= "3.8"
Canonical marker style
Consistency matters because these lines are read and edited often, and a stray style makes diffs noisy. Standardize on this:
- Spaces around every operator in the marker:
python_version >= "3.9", neverpython_version>="3.9". - Double-quoted
major.minorstring:"3.9". (packagingcompares these version-aware, sopython_version >= "3.9"correctly includes 3.10–3.14, no lexicographic surprise.) - Use
>=/<for the Python boundary; avoid>/<=so the boundary version lands on exactly one side. This rule is about thepython_versionmarker, not the version specifier —boto3<=2andcheroot<12are correct as written. - One space after the
;, none before:pkg>=1,<2; python_version >= "3.9". - The old-side line always carries an explicit upper bound (the floor-jump version).
- The marker set must be exhaustive and mutually exclusive across the matrix. The newest line ends open-ended (
>= "X.Y"), never a bare==that leaves future Pythons unmatched.
Deriving the versions to pin
You need two numbers: the floor (which Python the new release requires) and the old-side ceiling (the first release that raised that floor).
Floor. Read the metadata for the exact target version at
https://pypi.org/pypi/<package>/<target-version>/json. Theinfo.requires_pythonfield gives the new minimum (e.g.">=3.9"). Anullthere means the release declares no floor.Ceiling. Walk the release history at
https://pypi.org/pypi/<package>/jsonand find the first version that raised the floor above the oldest matrix Python. The old-side ceiling is< <that version>. For example, if a package jumped to>=3.9at version 4.0.0, the old-side cap is<4even if the target is4.2.0(pinning<4.2.0would wrongly admit 4.0.0–4.1.x, which are also 3.9-only).
Why an explicit ceiling instead of trusting pip to filter by Requires-Python? Because that filtering only holds if every future release keeps its metadata correct; a single mis-tagged release would silently float onto an untested interpreter. An explicit ceiling makes the intent self-documenting and robust.
tracerite is the cautionary case: its releases after 1.1.2 break on Python <= 3.8, yet those releases publish no requires_python metadata at all (verify: https://pypi.org/pypi/tracerite/1.1.3/json shows requires_python: null). So pip filtering offers zero protection on the old interpreters, and the explicit tracerite<1.1.2 ceiling is load-bearing, not decorative.
If you arrived here from a red CI job: the failing install log is ground truth. It names the interpreter that failed and the versions pip was actually offered, e.g.:
ERROR: Ignored the following versions that require a different python version: 4.2.0 Requires-Python >=3.9
ERROR: Could not find a version that satisfies the requirement falcon>=4.2.0 (from versions: ..., 3.1.3)
Cross-check the PyPI value against that log so you are never guessing. (If instead the failure is a real test failure, or hits every Python version, this pattern does not apply, so investigate the bump normally.)
One harder shape: a coupled companion dependency
Sometimes a package drags in another distribution with loose or wildcard versions that you must co-pin on the same boundary. Sanic imports tracerite with wildcard versions, so tracerite is pinned right beside it:
# sanic
# Note: Sanic pulls in tracerite via a wildcard version, so tracerite is co-pinned here.
# Note: tracerite > 1.1.2 is incompatible with Python <= 3.8 and ships no requires_python, so an explicit ceiling is required.
tracerite<1.1.2; python_version < "3.9"
sanic>=21,<24; python_version < "3.9"
sanic>=25.3.0,<26; python_version >= "3.9"
The split boundary here (3.9) is driven by tracerite, not by Sanic itself — Sanic 25.3 supports Python 3.8 (requires_python: >=3.8). Python 3.8 is kept on old Sanic only because tracerite breaks there. When a companion transitive dependency is the thing that breaks, pin it explicitly rather than hoping the parent's resolver picks a compatible version, and keep it in the same section so the coupling stays visible.
Collapse when a Python is dropped
Marker splits are maintenance cost, so remove them when they stop earning their keep. When a Python version is dropped from the CI matrix (and from requires-python / the classifiers), collapse any split whose only reason was that version back into a single unmarked line, and delete the trailing ;. For example, if 3.7 is dropped, the Django>=3.2,<4; python_version < "3.8" line has no interpreter left to serve, and the section collapses to a single Django line. A leaner file is easier for both humans and Dependabot to reason about.
What to leave alone
- Do not touch
requires-pythonor the CI matrix. Keeping 3.7 working on old dependency versions is the entire point; changing the floor is a separate, deliberate decision. - Do not add runtime dependencies to
pyproject.toml. The core package depends only onslack_sdk(see the "Single Runtime Dependency Rule" inAGENTS.md); everything else belongs inrequirements/*.txt. - Prefer markers over a Dependabot
ignore. Anignorerule freezes newer Pythons on the old version too, and hides the version knowledge in config. Reserveignorefor the rare dep that must stay pinned everywhere for reproducible output.