|
| 1 | +--- |
| 2 | +name: managing-dependencies |
| 3 | +description: >- |
| 4 | + Use when adding, updating, pinning, or reviewing any dependency in this repo's requirements/*.txt files, including taking a Dependabot bump or writing a requirement line by hand. Also use when a pip install fails on the older Python jobs while newer ones pass, or when CI errors with "Could not find a version that satisfies", "No matching distribution found", "Requires-Python >=3.x", or "ResolutionImpossible". Triggers include: "add <package> to requirements", "bump/update <package>", "pin <package>", "fix this dependabot PR", "CI can't install <package> on Python 3.7/3.8/3.9", "requires-python error in the install step". This skill defines the layout and version-constraint conventions to follow; reach for it before hand-editing any requirements/*.txt file. |
| 5 | +--- |
| 6 | + |
| 7 | +# Managing dependencies |
| 8 | + |
| 9 | +## Overview |
| 10 | + |
| 11 | +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](https://peps.python.org/pep-0508/) environment markers. That spec is the authority for the marker syntax used throughout this skill. |
| 12 | + |
| 13 | +Two facts about this repo drive everything below: |
| 14 | + |
| 15 | +- It supports **Python `>=3.7`** (`requires-python` and the classifiers in `pyproject.toml`). Its CI matrix in `.github/workflows/ci-build.yml` tests every version from **3.7 through 3.14 plus PyPy** (`pypy3.10`, `pypy3.11`). |
| 16 | +- 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. |
| 17 | + |
| 18 | +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. |
| 19 | + |
| 20 | +## File-layout convention |
| 21 | + |
| 22 | +Each file lists **one dependency per section**: Name of the dependency, an optional rationale note (starting with `Note:`, explaining why a version is pinned or split), then the requirement line(s), separated from the next section by a blank line. This makes every pin self-documenting. |
| 23 | + |
| 24 | +``` |
| 25 | +# pytest |
| 26 | +pytest>=7.0.1,<9 |
| 27 | +
|
| 28 | +# pytest-cov |
| 29 | +# Note: pytest-cov 7.1+ requires Python >=3.9; cap older interpreters below it. |
| 30 | +pytest-cov>=4,<7.1.0; python_version < "3.9" |
| 31 | +pytest-cov>=7.1.0,<8; python_version >= "3.9" |
| 32 | +``` |
| 33 | + |
| 34 | +Keep this layout when adding or editing dependencies. Never leave an empty trailing `;` (a fossil of a collapsed split; delete it). |
| 35 | + |
| 36 | +## Which files need Python-version markers |
| 37 | + |
| 38 | +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. |
| 39 | + |
| 40 | +| File | Installed on | Needs markers? | |
| 41 | +| -------------------------------- | --------------------------------------------------- | ----------------------------------- | |
| 42 | +| `requirements/testing.txt` | full matrix (3.7 → 3.14, pypy) | **Yes, if a bump raises the floor** | |
| 43 | +| `requirements/optional.txt` | full matrix (also packaged as the `optional` extra) | **Yes, if a bump raises the floor** | |
| 44 | +| `requirements/tools.txt` | latest supported Python only | No, just take the bump | |
| 45 | +| `requirements/databases.txt` | latest supported Python only (databases job) | No, just take the bump | |
| 46 | +| `requirements/documentation.txt` | not installed in CI (manual docs script only) | No, just take the bump | |
| 47 | + |
| 48 | +If the bump lands in a latest-Python-only file, take it as-is. No markers, no ceiling, just the layout convention above. |
| 49 | + |
| 50 | +**`optional.txt` is special:** it is read into wheel metadata via `[tool.setuptools.dynamic]` in `pyproject.toml` (`optional-dependencies.optional`). Use **full-line comments only** there, never trailing inline comments, so nothing leaks into the packaged metadata. |
| 51 | + |
| 52 | +## The version-constraint pattern |
| 53 | + |
| 54 | +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. |
| 55 | + |
| 56 | +``` |
| 57 | +# pytest |
| 58 | +# Note: pytest 9 requires Python >=3.10; cap older interpreters below it. |
| 59 | +pytest>=7.0.1,<9; python_version < "3.10" |
| 60 | +pytest>=9.1.1,<10; python_version >= "3.10" |
| 61 | +``` |
| 62 | + |
| 63 | +## Canonical marker style |
| 64 | + |
| 65 | +Consistency matters because these lines are read and edited often, and a stray style makes diffs noisy. Standardize on this: |
| 66 | + |
| 67 | +- Spaces around every operator: `python_version >= "3.10"`, never `python_version>="3.10"`. |
| 68 | +- Double-quoted `major.minor` string: `"3.10"`. (`packaging` compares these version-aware, so |
| 69 | + `"3.10" > "3.9"` is correctly true, no lexicographic surprise.) |
| 70 | +- Use `>=` / `<` for the Python boundary; avoid `>` / `<=` so the boundary version lands on exactly one side. |
| 71 | +- One space after the `;`, none before: `pkg>=1,<2; python_version >= "3.10"`. |
| 72 | +- The old-side line always carries an explicit upper bound (the floor-jump version). |
| 73 | +- 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. |
| 74 | + |
| 75 | +## Deriving the versions to pin |
| 76 | + |
| 77 | +You need two numbers: the **floor** (which Python the new release requires) and the old-side **ceiling** (the first release that raised that floor). |
| 78 | + |
| 79 | +1. **Floor.** Read the metadata for the _exact target version_ at `https://pypi.org/pypi/<package>/<target-version>/json`. The `info.requires_python` field gives the new minimum (e.g. `">=3.10"`). A `null` there means the release declares no floor. |
| 80 | + |
| 81 | +2. **Ceiling.** Walk the release history at `https://pypi.org/pypi/<package>/json` and find the **first version that raised the floor above the oldest matrix Python**. The old-side ceiling is `< <that version>`. For example, if `aiodns` jumped to `>=3.10` at version **4.0.0**, the old-side cap is `<4` even if the target is `4.0.4` (pinning `<4.0.4` would wrongly admit 4.0.0–4.0.3, which are also 3.10-only). |
| 82 | + |
| 83 | +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. |
| 84 | + |
| 85 | +**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.: |
| 86 | + |
| 87 | +``` |
| 88 | +ERROR: Ignored the following versions that require a different python version: 4.0.4 Requires-Python >=3.10 |
| 89 | +ERROR: Could not find a version that satisfies the requirement aiodns>=4.0.4 (from versions: ..., 3.6.0, 3.6.1) |
| 90 | +``` |
| 91 | + |
| 92 | +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.) |
| 93 | + |
| 94 | +## Two harder shapes |
| 95 | + |
| 96 | +**Different old Pythons need different ceilings.** When several old interpreters each cap at a different release, emit one line per divergent interpreter with `== "X.Y"`, then a final open-ended line. This is the `aiohttp` pattern already in `testing.txt` / `optional.txt`: |
| 97 | + |
| 98 | +``` |
| 99 | +# aiohttp |
| 100 | +# Note: aiohttp's minimum Python rose in stages; cap each old interpreter at its last compatible release. |
| 101 | +aiohttp>=3.7.3,<3.9; python_version == "3.7" |
| 102 | +aiohttp>=3.7.3,<3.11; python_version == "3.8" |
| 103 | +aiohttp>=3.13.5,<4; python_version >= "3.9" |
| 104 | +``` |
| 105 | + |
| 106 | +**Only PyPy breaks.** Sometimes a bump drops PyPy _wheels_ while CPython at the same version is fine (the failing jobs are `pypy*`, not `3.7`/`3.8`/`3.9`). A `python_version` split would wrongly downgrade CPython too. Gate on the implementation instead, as in the `cryptography` pattern in `testing.txt`. Note the exact marker name and casing: **`implementation_name == "pypy"`** (lowercase `pypy`), not `platform_python_implementation`. A single gated line is enough. You do **not** need a second negated line for the other interpreters, because an unmarked line already applies everywhere the marked one is skipped. |
| 107 | + |
| 108 | +``` |
| 109 | +# cryptography |
| 110 | +# Note: cryptography 46+ dropped PyPy 3.10 wheels; pin to <46 for PyPy 3.10 only. |
| 111 | +cryptography<46; implementation_name == "pypy" and python_version == "3.10" |
| 112 | +``` |
| 113 | + |
| 114 | +## Collapse when a Python is dropped |
| 115 | + |
| 116 | +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 `;`. A leaner file is easier for both humans and Dependabot to reason about. |
| 117 | + |
| 118 | +## What to leave alone |
| 119 | + |
| 120 | +- **Do not touch `requires-python` or the CI matrix.** Keeping 3.7 working on old dependency versions is the entire point; changing the floor is a separate, deliberate decision. |
| 121 | +- **Prefer markers over a Dependabot `ignore`.** An `ignore` rule freezes newer Pythons on the old version too, and hides the version knowledge in config. Reserve `ignore` for the rare dep that must stay pinned everywhere for reproducible output. |
0 commit comments