Skip to content

Commit 379377a

Browse files
authored
docs: test dependency floors strictly on the floor Python only (retire CI6) (#142)
1 parent 2a54538 commit 379377a

2 files changed

Lines changed: 85 additions & 45 deletions

File tree

‎CONTEXT.md‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -65,7 +65,7 @@ and published on the org site. A repo either meets it, or carries an exemption.
6565
_Avoid_: guidelines, conventions, rules. Each reads as advisory, and the standard is not.
6666

6767
**Requirement**:
68-
One numbered statement of the standard, cited by its stable ID (`CI6`). A MUST or MUST NOT
68+
One numbered statement of the standard, cited by its stable ID (`CI5`). A MUST or MUST NOT
6969
requirement is core; a SHOULD is a recommendation; a MAY is a permission.
7070
_Avoid_: rule, check. A check is what verifies a requirement, not the requirement.
7171

‎docs/standard.md‎

Lines changed: 84 additions & 44 deletions
Original file line numberDiff line numberDiff line change
@@ -10,8 +10,8 @@ The standard applies to every repo that publishes a package to PyPI under the or
1010

1111
## Reading a requirement
1212

13-
Each requirement has a stable ID (`CI6`) and an anchor of the same name
14-
(`https://modern-python.org/standard/#CI6`). Cite the ID, not a heading.
13+
Each requirement has a stable ID (`CI5`) and an anchor of the same name
14+
(`https://modern-python.org/standard/#CI5`). Cite the ID, not a heading.
1515

1616
The key words "MUST", "MUST NOT", "SHOULD", "SHOULD NOT", and "MAY" are to be interpreted as
1717
described in [BCP 14](https://www.rfc-editor.org/info/bcp14) ([RFC 2119], [RFC 8174]) when, and
@@ -168,7 +168,23 @@ free-threaded newest, written as a hand-maintained list in the repo's `_checks.y
168168
### PV3 · Python floor { #PV3 }
169169

170170
A repo MAY set `requires-python` to what its code needs, and MAY raise it without a recorded
171-
reason. Dependency floors are a separate obligation ([CI6](#CI6)).
171+
reason. Dependency floors are a separate obligation ([DP1](#DP1), [CI10](#CI10)).
172+
173+
## Dependencies
174+
175+
### DP1 · One floor per dependency { #DP1 }
176+
177+
A dependency SHOULD declare one lower bound: the lowest version the code works with. A repo MAY
178+
add a `python_version` floor where a version at or above that bound installs on that Python and
179+
then fails there, with the reason in a comment. A repo SHOULD NOT declare a floor, or a
180+
dependency it does not import, only to get wheel coverage on some interpreter.
181+
182+
*Why:* the `floors` job ([CI10](#CI10)) skips a version that has no wheel for the interpreter and
183+
tests the lowest one that does, which is the version a floor raised for wheel coverage would name.
184+
Those floors cost a line per dependency for every new Python and test nothing the resolver does
185+
not already pick. A version that installs and then fails is different: the resolver cannot see
186+
the failure, so only a floor keeps users off it. `typing-extensions` 4.7 to 4.13 install on 3.15
187+
and fail at import, because 3.15 removed the `typing.no_type_check_decorator` they read.
172188

173189
## CI
174190

@@ -180,7 +196,7 @@ superseded runs.
180196
### CI2 · `scheduled.yml` { #CI2 }
181197

182198
`scheduled.yml` MUST run daily and on `workflow_dispatch`, running the same checks as `ci.yml`
183-
except `floors` ([CI6](#CI6)). On a scheduled failure it MUST open or update a tracking issue in the
199+
except `floors` ([CI10](#CI10)). On a scheduled failure it MUST open or update a tracking issue in the
184200
repo with this job, byte for byte, and a repo MUST NOT keep its own report script:
185201

186202
```yaml
@@ -203,7 +219,7 @@ once.
203219
### CI3 · Per-repo `_checks.yml` { #CI3 }
204220

205221
`ci.yml` and `scheduled.yml` MUST both call the repo's own reusable `_checks.yml`, which holds the
206-
jobs [CI4](#CI4) to [CI8](#CI8).
222+
jobs [CI4](#CI4), [CI5](#CI5), [CI7](#CI7), [CI8](#CI8) and [CI10](#CI10).
207223

208224
*Why:* a shared workflow in `modern-python/.github` was built and rejected
209225
([#95](https://github.com/modern-python/.github/issues/95)): a `workflow_call` input is a scalar,
@@ -219,46 +235,10 @@ The `lint` job MUST run `just install lint-ci` on the repo's floor Python.
219235
The `pytest` job MUST run `just install` then `just test-ci` on every matrix entry, with
220236
`fail-fast: false`.
221237

222-
### CI6 · `floors` job { #CI6 }
223-
224-
The `floors` job MUST install every direct dependency at its declared floor, wheel-only, on every
225-
matrix entry, then run the suite or an import. It MUST do so in two steps, pinning the floors with
226-
builds allowed and then installing the pins with builds disabled:
238+
### CI6 · Retired { #CI6 }
227239

228-
```yaml
229-
- run: uv pip compile pyproject.toml --all-extras --no-deps --resolution lowest-direct -o floors.txt
230-
- run: uv pip install -r pyproject.toml --all-extras --constraints floors.txt --no-build
231-
```
232-
233-
A repo that runs the suite adds `--group dev` to the install. It MUST gate every pull request and MUST be skipped on the schedule,
234-
with `if: github.event_name != 'schedule'` on the job (a called workflow sees its caller's `github`
235-
context).
236-
237-
A repo MAY run an import smoke test instead of the suite where the floors cannot carry it, and MAY
238-
drop a matrix entry that no upstream wheel covers at the floor (`compose2pod` drops `3.14t` for
239-
PyYAML). A repo with no runtime dependencies and no extras has no floors to test and MAY omit the
240-
job (`modern-di`).
241-
242-
*Why:* `pytest` resolves every dependency at its newest, so without this job the bottom of each
243-
declared range ships untested. It has rotted there before
244-
([compose2pod#126](https://github.com/modern-python/compose2pod/issues/126),
245-
[faststream-outbox#190](https://github.com/modern-python/faststream-outbox/pull/190)).
246-
Wheel-only, because a floor reachable only by compiling an sdist is not one a user installing a
247-
wheel can reach, and without the flag the resolver builds one and reports success. Two steps,
248-
because the flag alone does not fail on such a floor: it makes the wheel-less version ineligible,
249-
and `lowest-direct` climbs to the lowest version that has a wheel, so the job passes on a version
250-
the repo never declared
251-
([compose2pod#135](https://github.com/modern-python/compose2pod/pull/135)). Pinning with builds
252-
allowed fixes the declared floor first, and the wheel-only install then fails on it
253-
([compose2pod#139](https://github.com/modern-python/compose2pod/pull/139)). Every matrix
254-
entry, because wheel coverage is per interpreter. On the pull request, because that is where a floor
255-
breaks: a diff starts using an API newer than the declared floor. `lite-bootstrap` ran its floors
256-
only on the schedule and shipped such a break to PyPI
257-
([lite-bootstrap#245](https://github.com/modern-python/lite-bootstrap/issues/245)). A scheduled
258-
run adds nothing: the direct dependencies sit at their floors, which do not change, and the
259-
scheduled `pytest` already catches the newest releases, which are what users install. Resolving
260-
transitive dependencies at their newest exposes a pull request to an upstream release it did not
261-
cause, but `install` upgrades the lockfile ([JF1](#JF1)), so `pytest` carries the same exposure.
240+
Replaced by [CI10](#CI10) and [DP1](#DP1). CI6 pinned the declared floors on every matrix entry;
241+
CI10 pins them on the floor Python only and tests the lowest version with a wheel elsewhere.
262242

263243
### CI7 · `links` job { #CI7 }
264244

@@ -302,6 +282,66 @@ coverage gate out of a one-file run; the temporary directory sits inside the rep
302282
plugins such as `pytest-alembic` reject collected files outside the root. `adr_check_source` can
303283
be pointed at a `file://` path to run an unmerged version of the check.
304284

285+
### CI10 · `floors` job { #CI10 }
286+
287+
The `floors` job MUST install every direct dependency at the lowest version it declares that has a
288+
wheel for the interpreter, wheel-only, on every matrix entry, then run the suite or an import. It
289+
MUST gate every pull request and MUST be skipped on the schedule, with
290+
`if: github.event_name != 'schedule'` on the job (a called workflow sees its caller's `github`
291+
context).
292+
293+
On the floor Python ([PV3](#PV3)) it MUST pin the declared floors with builds allowed, then install
294+
the pins with builds disabled, so a declared floor with no wheel there fails:
295+
296+
```yaml
297+
- run: uv pip compile pyproject.toml --all-extras --no-deps --resolution lowest-direct -o floors.txt
298+
- run: uv pip install -r pyproject.toml --all-extras --constraints floors.txt --no-build
299+
```
300+
301+
On every other entry it SHOULD (becomes MUST on 2026-12-31) resolve the whole tree with builds
302+
disabled, so a version with no wheel for that interpreter is skipped:
303+
304+
```yaml
305+
- run: uv pip compile pyproject.toml --all-extras --resolution lowest-direct --no-build -o floors.txt
306+
- run: uv pip install -r pyproject.toml --all-extras --constraints floors.txt --no-build
307+
```
308+
309+
A repo that runs the suite adds `--group dev` to the install. A repo MAY run an import smoke test
310+
instead of the suite where the floors cannot carry it, and MAY drop a matrix entry where no release
311+
in a dependency's declared range has a wheel for it (`compose2pod` drops `3.14t` for PyYAML). A
312+
repo with no runtime dependencies and no extras has no floors to test and MAY omit the job
313+
(`modern-di`).
314+
315+
*Why:* `pytest` resolves every dependency at its newest, so without this job the bottom of each
316+
declared range ships untested. It has rotted there before
317+
([compose2pod#126](https://github.com/modern-python/compose2pod/issues/126),
318+
[faststream-outbox#190](https://github.com/modern-python/faststream-outbox/pull/190)).
319+
Wheel-only, because a version reachable only by compiling an sdist is not one a user installing a
320+
wheel can reach, and without the flag the resolver builds one and reports success.
321+
322+
Strict on the floor Python, because that is where old versions have wheels and the declared floor
323+
is a claim about the API that can be checked exactly. The flag alone does not check it:
324+
`lowest-direct` climbs past a version with no wheel and passes on one the repo never declared
325+
([compose2pod#135](https://github.com/modern-python/compose2pod/pull/135)). Pinning with builds
326+
allowed fixes the declared floor first, and the wheel-only install then fails on it
327+
([compose2pod#139](https://github.com/modern-python/compose2pod/pull/139)).
328+
329+
The lowest version with a wheel on every other entry, because a newer interpreter often has no
330+
wheel for the old floor, and installers pick the newest compatible version, so that gap never
331+
reaches a default install. Testing the declared floor there forced a `python_version` floor per
332+
dependency for every new Python ([DP1](#DP1)). The whole tree, without `--no-deps`: with it, uv
333+
pins a version whose own dependencies have no wheel, such as pydantic 2.0 on 3.14, whose
334+
pydantic-core has none. Every matrix entry, because newer interpreters are where a version that
335+
installs then fails shows up.
336+
337+
On the pull request, because that is where a floor breaks: a diff starts using an API newer than
338+
the declared floor. `lite-bootstrap` ran its floors only on the schedule and shipped such a break to
339+
PyPI ([lite-bootstrap#245](https://github.com/modern-python/lite-bootstrap/issues/245)). A
340+
scheduled run adds nothing: the floors do not change, and the scheduled `pytest` already catches
341+
the newest releases, which are what users install. Resolving transitive dependencies at their
342+
newest exposes a pull request to an upstream release it did not cause, but `install` upgrades the
343+
lockfile ([JF1](#JF1)), so `pytest` carries the same exposure.
344+
305345
## Release
306346

307347
### RL1 · Tags { #RL1 }

0 commit comments

Comments
 (0)