Skip to content

Commit eddc476

Browse files
WilliamBergaminClaude
andauthored
docs(deps): simplify the managing-dependencies skill (#1578)
Co-authored-by: Claude <svc-devxp-claude@slack-corp.com>
1 parent c793795 commit eddc476

1 file changed

Lines changed: 47 additions & 95 deletions

File tree

  • .claude/skills/managing-dependencies
Lines changed: 47 additions & 95 deletions
Original file line numberDiff line numberDiff line change
@@ -1,129 +1,81 @@
11
---
22
name: managing-dependencies
33
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.
4+
Use when adding, bumping, pinning, or reviewing a dependency in requirements.txt or requirements/*.txt, including taking or fixing a Dependabot PR. Also use when pip install fails on some Python versions in CI but not others ("Could not find a version that satisfies the requirement", "No matching distribution found", "Requires-Python", "ResolutionImpossible"), including PyPy-only failures, and when dropping support for an old Python version.
55
---
66

77
# Managing dependencies
88

9-
## Overview
9+
This repo supports a range of Python versions, and one set of requirements files has to install on all of them. Dependabot proposes the newest release, so its bumps often raise a lower bound past what the oldest Pythons can install, and CI fails at the install step on those jobs only. The fix is to split the requirement with PEP 508 `python_version` markers so each Python gets the newest release it can install. Dropping an old Python to turn CI green is not the fix.
1010

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.
11+
## Where the facts live
1212

13-
Two facts about this repo drive everything below:
13+
Read these instead of trusting memory; they change often.
1414

15-
- It supports **Python `>=3.7`** (`requires-python` and the classifiers in `pyproject.toml`). Its CI matrix in `.github/workflows/ci-build.yml` tests **CPython 3.7 through 3.14 only** — there is **no PyPy** (the classifiers list only `Programming Language :: Python :: Implementation :: CPython`).
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.
15+
- **Supported Pythons:** `requires-python` and the `Programming Language :: Python :: 3.X` classifiers in `pyproject.toml`, and the CI matrix.
16+
- **Who installs a file, on which Python:** `grep -rn requirements .github/workflows scripts requirements pyproject.toml`. This also catches `-r` includes. For a hit in a script, find the CI job that runs it and read its `python-version`.
17+
- **Whether a file ships to users:** a file that `pyproject.toml` reads into package metadata (`dependencies` or `optional-dependencies` under `[tool.setuptools.dynamic]`) ends up in the published wheel.
1718

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+
## Workflow
1920

20-
## File-layout convention
21+
- [ ] 1. Find which Pythons install the file. A file installed only on the newest Python takes the bump as-is.
22+
- [ ] 2. Find the newest release each affected Python can install (see "Finding the ceiling").
23+
- [ ] 3. If every Python the line covers can install the new lower bound, take the bump as one line. Done.
24+
- [ ] 4. Otherwise split the line (see "Splitting a line").
25+
- [ ] 5. Re-read the section: no Python matches two lines, and the newest line is open-ended.
2126

22-
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.
27+
Change only the section you are fixing; don't reformat other sections in the same change.
2328

24-
```
25-
# pip install -r requirements/test.txt
26-
27-
# pytest
28-
pytest<9.2
29-
30-
# pytest-cov
31-
# Note: only needed to evaluate coverage on the latest supported python version
32-
pytest-cov>=7.1.0,<8; python_version >= "3.14"
33-
```
34-
35-
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).
36-
37-
**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.
38-
39-
## Which files need Python-version markers
40-
41-
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.
42-
43-
| File | Installed on | Needs markers? |
44-
| ----------------------------- | ----------------------------------------------------- | ----------------------------------- |
45-
| `requirements/adapter_dev.txt` | full matrix (`unittest`; also `typecheck`/`codecov` @3.14) | **Yes, if a bump raises the floor** |
46-
| `requirements/async_dev.txt` | full matrix (`unittest`; also `typecheck`/`codecov` @3.14) | **Yes, if a bump raises the floor** |
47-
| `requirements/test.txt` | full matrix (`unittest`) | **Yes, if a bump raises the floor** |
48-
| `requirements/test_adapter.txt` | full matrix (`unittest`; `codecov` @3.14) | **Yes, if a bump raises the floor** |
49-
| `requirements/test_async.txt` | full matrix (`unittest`) | **Yes, if a bump raises the floor** |
50-
| `requirements/dev_tools.txt` | `lint` + `typecheck`, **latest Python only** (3.14) | No, just take the bump (`==` pins) |
51-
52-
If the bump lands in a latest-Python-only file, take it as-is: no markers, no ceiling, just the layout convention above.
53-
54-
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.
55-
56-
## The version-constraint pattern
29+
## File layout
5730

58-
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.
31+
Each file starts with a `# pip install -r <path>` header, then one section per dependency: a `# <name>` line, optional `# Note:` lines, the requirement line(s), and a blank line.
5932

6033
```
61-
# aiohttp
62-
aiohttp>=3,<4; python_version < "3.9"
63-
aiohttp>=3.13.5,<4; python_version >= "3.9"
64-
```
34+
# pip install -r requirements/testing.txt
6535
66-
`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:
36+
# pkg
37+
pkg>=1.4,<2; python_version < "3.9"
38+
pkg>=2.0,<2.3; python_version == "3.9"
39+
pkg>=2.5.1,<3; python_version >= "3.10"
6740
41+
# other-pkg
42+
# Note: other-pkg 4.x breaks on Python 3.8 but declares no requires_python, so pip can't filter it.
43+
other-pkg>=3,<4
6844
```
69-
# Django
70-
Django>=3.2,<4; python_version < "3.8"
71-
Django>=4.2.30,<6; python_version >= "3.8"
72-
```
73-
74-
## Canonical marker style
75-
76-
Consistency matters because these lines are read and edited often, and a stray style makes diffs noisy. Standardize on this:
77-
78-
- Spaces around every operator in the marker: `python_version >= "3.9"`, never `python_version>="3.9"`.
79-
- Double-quoted `major.minor` string: `"3.9"`. (`packaging` compares these version-aware, so `python_version >= "3.9"` correctly includes 3.10–3.14, no lexicographic surprise.)
80-
- Use `>=` / `<` for the Python boundary; avoid `>` / `<=` so the boundary version lands on exactly one side. **This rule is about the `python_version` marker, not the version specifier** — `boto3<=2` and `cheroot<12` are correct as written.
81-
- One space after the `;`, none before: `pkg>=1,<2; python_version >= "3.9"`.
82-
- The old-side line always carries an explicit upper bound (the floor-jump version).
83-
- 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.
84-
85-
## Deriving the versions to pin
8645

87-
You need two numbers: the **floor** (which Python the new release requires) and the old-side **ceiling** (the first release that raised that floor).
46+
**Notes:** write one only for what the lines can't say: a cap that isn't obvious, a coupling with another package, or a release with wrong metadata. Don't restate versions or which Python gets which release; a routine split needs no note. Existing notes that only restate their lines are legacy: drop one when you edit its section, and don't copy it.
8847

89-
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.9"`). A `null` there means the release declares no floor.
48+
## Finding the ceiling
9049

91-
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 a package jumped to `>=3.9` at version **4.0.0**, the old-side cap is `<4` even if the target is `4.2.0` (pinning `<4.2.0` would wrongly admit 4.0.0–4.1.x, which are also 3.9-only).
50+
An old Python's ceiling is the first release that dropped it, not the release Dependabot proposes. If `pkg` dropped 3.9 at 2.3 and the target is 2.5.1, the 3.9 line is `<2.3`, since 2.3 through 2.5.0 are 3.10-only too.
9251

93-
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.
52+
- **From a red CI job:** the failing install log is ground truth. The lowest version on its `Ignored the following versions that require a different python version` line is that Python's ceiling.
53+
- **For any Python:** `pip index versions <pkg> --python-version 3.9` lists what 3.9 can install, newest first. Run it again for the next Python up; the first release newer than 3.9's newest is 3.9's ceiling.
54+
- **The pre-bump lower bound** is in the Dependabot PR title (`update pkg requirement from <3,>=2.0 to >=2.5.1,<3`) or `git diff`.
9455

95-
`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.
56+
## Splitting a line
9657

97-
**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.:
58+
Split only the Pythons that the bumped line covers and the new lower bound excludes. Leave existing bands for older Pythons alone, even when they are coarser than they could be.
9859

99-
```
100-
ERROR: Ignored the following versions that require a different python version: 4.2.0 Requires-Python >=3.9
101-
ERROR: Could not find a version that satisfies the requirement falcon>=4.2.0 (from versions: ..., 3.1.3)
102-
```
103-
104-
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.)
60+
- Lines run oldest Python to newest. The newest line takes the bump and stays open-ended (`>= "X.Y"`).
61+
- Each band that splits off keeps the line's pre-bump lower bound and gets its ceiling from "Finding the ceiling". Pythons that share a ceiling share a band.
62+
- Marker style: `pkg>=1,<2; python_version == "3.9"`. Spaces around operators, double-quoted `"X.Y"`, one space after `;`. A one-Python band is `== "X.Y"`; a band spanning several is `>= "X.Y" and < "X.Z"`, or `< "X.Z"` when it is the oldest. Boundaries use `>=` and `<` so each Python lands on exactly one side. `python_version` is major.minor only (3.9.5 reports `"3.9"`), so `== "3.9"` covers every 3.9 patch; don't use `python_full_version` here, where `== "3.9"` matches only 3.9.0.
10563

106-
## One harder shape: a coupled companion dependency
64+
A section with a single gated line (installed only on the newest Python, or only on PyPy) is intentional; don't fill in the other Pythons.
10765

108-
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:
109-
110-
```
111-
# sanic
112-
# Note: Sanic pulls in tracerite via a wildcard version, so tracerite is co-pinned here.
113-
# Note: tracerite > 1.1.2 is incompatible with Python <= 3.8 and ships no requires_python, so an explicit ceiling is required.
114-
tracerite<1.1.2; python_version < "3.9"
115-
sanic>=21,<24; python_version < "3.9"
116-
sanic>=25.3.0,<26; python_version >= "3.9"
117-
```
66+
## Gotchas
11867

119-
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.
68+
- **Dependabot rewrites only the last line** of a split section, the open-ended one. When it bumps a split package, check which Pythons that line covers; the new lower bound may exclude the oldest of them, which then splits off.
69+
- **Releases with no `requires_python`** can't be filtered by pip, so they install on Pythons they don't support and fail at import or test time rather than at install. `pip index` lists them too. Pin an explicit ceiling with a note saying why. When the culprit comes in loosely through another package, pin it in that package's section so the coupling stays visible.
70+
- **Only PyPy jobs fail:** the release usually dropped PyPy wheels. Gate on the implementation instead of downgrading CPython: `pkg<46; implementation_name == "pypy" and python_version == "3.10"`. Lowercase `pypy`, and `implementation_name`, not `platform_python_implementation`. No unmarked companion line is needed.
71+
- **Files that ship:** full-line comments only; an inline comment breaks the package build. Their lower bounds are the minimum users must install, so raise one only for a reason.
72+
- **Not this pattern:** a real test failure, or an install failure on every Python. Investigate the bump normally.
12073

121-
## Collapse when a Python is dropped
74+
## Dropping a Python version
12275

123-
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.
76+
When a Python leaves the classifiers and the CI matrix, merge any band that only served it into its neighbor. If a section is left with one line, remove its marker and the `;`.
12477

125-
## What to leave alone
78+
## Leave alone
12679

127-
- **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.
128-
- **Do not add runtime dependencies to `pyproject.toml`.** The core package depends only on `slack_sdk` (see the "Single Runtime Dependency Rule" in `AGENTS.md`); everything else belongs in `requirements/*.txt`.
129-
- **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.
80+
- `requires-python`, the classifiers, and the CI matrix. Changing which Pythons are supported is its own decision.
81+
- Packages under `ignore` in `.github/dependabot.yml`: maintainers bump those by hand on purpose. Prefer a marker split over adding a new `ignore`, which freezes the package on every Python.

0 commit comments

Comments
 (0)