Skip to content

Commit fe61248

Browse files
authored
chore(python): relax the floor to >=3.11, measured, with a CI leg to keep it true (#301)
* chore(python): relax the floor to >=3.11, measured, with a CI leg to keep it true requires-python was >=3.14.4 — the interpreter the author develops on, not one the code needs, and new enough to silently bar contributors (#283). Measured by running the full suite down the versions: 3.13, 3.12, 3.11 pass identically (2,788/1); 3.10 fails collection on typing.assert_never (3.11+), the one concrete binding constraint. Floor set to >=3.11, dev pin stays 3.14.4, ci.yml's test job becomes a 3.11+3.14 matrix (same status context, so both legs gate the merge), mypy targets the floor, and tests/test_python_floor.py pins the decision: one floor everywhere, the binding feature still imported, the CI leg present, the docs stating it. * ci: restore the setup-uv action reference the matrix edit typo'd
1 parent db7a4d6 commit fe61248

11 files changed

Lines changed: 451 additions & 17 deletions

File tree

‎.github/workflows/ci.yml‎

Lines changed: 19 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -43,6 +43,19 @@ jobs:
4343
test:
4444
runs-on: ubuntu-latest
4545
timeout-minutes: 15
46+
# Two legs, the two ends of `requires-python = ">=3.11"` (#283):
47+
# - "3.11" is the FLOOR, stated explicitly because nothing else states it: the suite
48+
# passing there is what keeps the declared floor a measured claim instead of a hope.
49+
# 3.10 fails collection on `typing.assert_never` (3.11+), which is the one concrete
50+
# feature binding the floor.
51+
# - "3.14" is the leg local development actually runs (`.python-version` pins 3.14.4);
52+
# it is named here by major only, since the matrix needs SOME explicit version and
53+
# "whatever .python-version says" is not addressable per-leg.
54+
# Both legs stay inside THIS job so the required status context remains `test` -- the
55+
# matrix fans out the context, it does not rename it, so every leg gates the merge.
56+
strategy:
57+
matrix:
58+
python: ["3.11", "3.14"]
4659
steps:
4760
- uses: actions/checkout@v4
4861

@@ -52,13 +65,13 @@ jobs:
5265
enable-cache: true
5366

5467
- name: Set up Python
55-
run: uv python install # version comes from .python-version; never pin it here twice
68+
run: uv python install "${{ matrix.python }}"
5669

5770
- name: Sync dependencies
58-
run: uv sync --all-extras --dev
71+
run: uv sync --all-extras --dev --python "${{ matrix.python }}"
5972

6073
- name: Lint
61-
run: uv run ruff check keel tests packages
74+
run: uv run --python "${{ matrix.python }}" ruff check keel tests packages
6275

6376
# A STEP in this job, deliberately not a job of its own. The `main` ruleset requires the
6477
# status context `test`, which this job produces; a separate `typecheck:` job would report
@@ -70,12 +83,12 @@ jobs:
7083
# step is that it cannot drift back silently. A package moving in or out of the checked
7184
# set is then one edit there, not one edit mirrored across two workflows.
7285
- name: Type-check
73-
run: uv run mypy
86+
run: uv run --python "${{ matrix.python }}" mypy
7487

7588
- name: Test
76-
run: uv run pytest -q
89+
run: uv run --python "${{ matrix.python }}" pytest -q
7790

7891
# A build that cannot identify itself must not reach a release. This also catches an
7992
# import-time break in the CLI, which `pytest` alone would not surface as sharply.
8093
- name: Build identity
81-
run: uv run keel --version
94+
run: uv run --python "${{ matrix.python }}" keel --version

‎CONTRIBUTING.md‎

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -39,7 +39,11 @@ exactly who said what.
3939

4040
## Development setup and the gates a PR must pass
4141

42-
Python **3.14+** is the floor (see `.python-version`; `uv python install 3.14` gets you one).
42+
Python **3.11+** is the floor, and it is measured, not aspirational (#283): the full suite
43+
passes identically on 3.11–3.14, and the one feature binding the floor is 3.11's
44+
`typing.assert_never` (3.10 fails collection on it). The repo itself develops on 3.14
45+
(`.python-version`; `uv python install 3.14`), and CI runs the whole gate on 3.11 too, so the
46+
floor cannot silently rot.
4347
Then:
4448

4549
```bash

‎README.md‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -34,7 +34,7 @@ so upfront rather than let step 4 be a surprise.
3434

3535
```bash
3636
git clone https://github.com/CodeGateSoftware/keel.git && cd keel
37-
uv sync --all-extras --dev # Python 3.14+ (uv python install 3.14)
37+
uv sync --all-extras --dev # any Python 3.11+ (the repo develops on 3.14)
3838
cp .env.example .env # put the CDP key/secret in it — market data only
3939
uv run keel rules seed # register the rule families as candidates
4040
uv run keel fetch # pull candle history for the default allowlist

‎packages/keel-broker-api/pyproject.toml‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@ name = "keel-broker-api"
33
version = "0.7.1"
44
description = "Broker port, domain types, capability model, and conformance suite for keel"
55
license = "Apache-2.0"
6-
requires-python = ">=3.14.4"
6+
requires-python = ">=3.11"
77
# Pinned `==`: see the note in the root `pyproject.toml`. An unpinned sibling is satisfied by
88
# whatever is already installed, which is how a deployment ends up running mixed versions.
99
dependencies = ["keel-core==0.7.1"]

‎packages/keel-broker-coinbase/pyproject.toml‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@ name = "keel-broker-coinbase"
33
version = "0.7.1"
44
description = "Coinbase Advanced Trade adapter for keel"
55
license = "Apache-2.0"
6-
requires-python = ">=3.14.4"
6+
requires-python = ">=3.11"
77
# Siblings pinned `==` (see the root `pyproject.toml`); the third-party SDK is not, because it
88
# has its own release cycle and is not cut from this repo.
99
dependencies = ["keel-core==0.7.1", "keel-broker-api==0.7.1", "coinbase-advanced-py>=1.8.4"]

‎packages/keel-broker-fake/pyproject.toml‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@ name = "keel-broker-fake"
33
version = "0.7.1"
44
description = "A deliberately divergent second broker adapter, to keep the port honest"
55
license = "Apache-2.0"
6-
requires-python = ">=3.14.4"
6+
requires-python = ">=3.11"
77
# Pinned `==`: see the note in the root `pyproject.toml`.
88
dependencies = ["keel-core==0.7.1", "keel-broker-api==0.7.1"]
99

‎packages/keel-broker-robinhood/pyproject.toml‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@ name = "keel-broker-robinhood"
33
version = "0.7.1"
44
description = "Robinhood Crypto Trading API v2 adapter for keel"
55
license = "Apache-2.0"
6-
requires-python = ">=3.14.4"
6+
requires-python = ">=3.11"
77
# `pynacl` is here and nowhere else in the workspace: Robinhood signs every request with an
88
# Ed25519 key, which no other venue keel talks to requires. Keeping it a dependency of this
99
# package alone means an engine that never installs this adapter never installs the crypto stack

‎packages/keel-core/pyproject.toml‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@ name = "keel-core"
33
version = "0.7.1"
44
description = "Shared domain types, configuration, and logging for keel"
55
license = "Apache-2.0"
6-
requires-python = ">=3.14.4"
6+
requires-python = ">=3.11"
77
dependencies = ["pyyaml>=6.0.3", "python-dotenv>=1.2.2"]
88

99
[build-system]

‎pyproject.toml‎

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,7 @@ readme = "README.md"
77
authors = [
88
{ name = "CodeGate Software" }
99
]
10-
requires-python = ">=3.14.4"
10+
requires-python = ">=3.11"
1111
dependencies = [
1212
"click>=8.4.2",
1313
# pyyaml/python-dotenv are deliberately NOT listed here: nothing under `keel/` imports
@@ -87,7 +87,7 @@ dev = [
8787
# [tool.ruff] here, so keeping a copy in both places would leave silently-dead settings.
8888

8989
[tool.mypy]
90-
python_version = "3.14"
90+
python_version = "3.11"
9191
files = ["packages", "keel", "tests"]
9292
# Implied by `--strict`, but mypy rejects it in a per-module section ("Per-module sections
9393
# should only specify per-module flags"), so it is stated here rather than in the broker block

‎tests/test_python_floor.py‎

Lines changed: 106 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,106 @@
1+
"""The Python floor: the lowest version the suite actually runs on, kept honest in CI.
2+
3+
`requires-python` was `>=3.14.4` from the project's first day, which said "the interpreter I
4+
develop on" rather than "the interpreter the code needs" -- and Python 3.14 is new enough that
5+
many contributors do not have it, several distributions do not package it, and some CI images
6+
lag. A floor that bars contributors silently costs them: they try, fail to build, and leave
7+
without opening an issue (#283).
8+
9+
So the floor was measured, not guessed. The full suite was run at each step down:
10+
11+
- **3.13, 3.12, 3.11**: 2,788 passed / 1 skipped -- identical to 3.14, nothing requires it.
12+
- **3.10**: collection fails, `ImportError: cannot import name 'assert_never' from 'typing'` --
13+
the one concrete binding constraint found. `assert_never` is 3.11+.
14+
15+
The decision: the floor is **3.11**, the lowest passing version; development stays pinned to
16+
3.14.4 via `.python-version`; CI runs the suite on 3.11 so the floor stays true instead of
17+
rotting back into an untested claim. These tests pin all three halves of that decision -- the
18+
declared floor in every distribution, the binding-feature evidence that justifies it, and the
19+
CI leg that verifies it -- because each is a way the decision can silently come undone: a
20+
seventh distribution added with a different floor, the `assert_never` imports disappearing
21+
(which means the floor COULD drop and the recorded reason is stale), or the CI matrix losing
22+
its floor leg.
23+
"""
24+
25+
from __future__ import annotations
26+
27+
import re
28+
import tomllib
29+
from pathlib import Path
30+
31+
_ROOT = Path(__file__).resolve().parents[1]
32+
33+
#: The floor, as decided in #283. One value in every distribution, like the version pins.
34+
_FLOOR = ">=3.11"
35+
36+
37+
def _pyprojects() -> dict[str, dict]:
38+
"""distribution name -> parsed `pyproject.toml`, for the root and every workspace member."""
39+
found = {}
40+
for path in [_ROOT / "pyproject.toml", *sorted((_ROOT / "packages").glob("*/pyproject.toml"))]:
41+
data = tomllib.loads(path.read_text())
42+
found[data["project"]["name"]] = data
43+
return found
44+
45+
46+
def test_every_distribution_declares_the_same_floor():
47+
"""`requires-python = ">=3.11"` everywhere -- a mixed floor is a mixed install waiting.
48+
49+
The workspace is cut in one build; a member declaring a different floor than its siblings
50+
means an environment that satisfies one requirement and rejects another, which is the
51+
version-pin failure mode `test_packaging.py` guards, one field over.
52+
"""
53+
floors = {name: data["project"]["requires-python"] for name, data in _pyprojects().items()}
54+
wrong = {name: floor for name, floor in floors.items() if floor != _FLOOR}
55+
assert not wrong, (
56+
f"these distributions declare a Python floor other than {_FLOOR!r}: {wrong}. The floor "
57+
"is one decision for the whole workspace (see tests/test_python_floor.py's docstring "
58+
"for the measured reasoning)"
59+
)
60+
61+
62+
def test_the_binding_feature_that_sets_the_floor_still_exists():
63+
"""The floor's REASON must stay true: `assert_never` (3.11+) must still be imported.
64+
65+
3.10 fails collection on exactly this import. If it ever disappears from the codebase, the
66+
floor could drop further and this test's recorded justification is stale -- re-measure
67+
downward rather than leaving a floor pinned to a feature nobody uses.
68+
"""
69+
importers: list[str] = []
70+
for base in (_ROOT / "keel", _ROOT / "packages", _ROOT / "tests"):
71+
for path in base.rglob("*.py"):
72+
if re.search(r"import\s+.*\bassert_never\b", path.read_text()):
73+
importers.append(str(path.relative_to(_ROOT)))
74+
assert importers, (
75+
"nothing imports `assert_never` any more -- the 3.11 floor's binding constraint is "
76+
"gone; re-run the suite on 3.10 and lower the floor if it now passes"
77+
)
78+
79+
80+
def test_ci_runs_the_suite_on_the_floor_so_it_stays_true():
81+
"""The `test` job must have a 3.11 leg, or the floor is an untested claim.
82+
83+
Measured floors rot: a dependency or a line of code starts needing something newer and
84+
`>=3.11` becomes a lie that only manifests on a contributor's machine. The CI leg is what
85+
converts the floor from a declaration into an invariant. It lives in the `test` job's
86+
matrix on purpose -- a job of its own would report a status context nothing requires,
87+
which is exactly how #268's mypy gate came undone before it existed.
88+
"""
89+
ci = (_ROOT / ".github" / "workflows" / "ci.yml").read_text()
90+
assert "3.11" in ci, (
91+
"ci.yml no longer runs on Python 3.11 -- the declared floor has lost its CI leg; "
92+
"restore the matrix entry or re-measure the floor and update it everywhere"
93+
)
94+
95+
96+
def test_the_floor_is_stated_where_a_contributor_reads_it():
97+
"""README and CONTRIBUTING must say 3.11, so the floor is found in docs, not the resolver.
98+
99+
'A contributor hitting the floor gets a clear message rather than a confusing resolver
100+
error' (#283) starts with the floor being low enough to be rare and visible enough to be
101+
self-diagnosing when hit.
102+
"""
103+
readme = (_ROOT / "README.md").read_text()
104+
contributing = (_ROOT / "CONTRIBUTING.md").read_text()
105+
assert "3.11" in readme, "the README's setup section must state the 3.11 floor"
106+
assert "3.11" in contributing, "CONTRIBUTING.md must state the 3.11 floor"

0 commit comments

Comments
 (0)