@@ -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
1616The key words "MUST", "MUST NOT", "SHOULD", "SHOULD NOT", and "MAY" are to be interpreted as
1717described 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
170170A 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
184200repo 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.
219235The `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
302282plugins such as `pytest-alembic` reject collected files outside the root. `adr_check_source` can
303283be 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