Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
25 changes: 19 additions & 6 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,19 @@ jobs:
test:
runs-on: ubuntu-latest
timeout-minutes: 15
# Two legs, the two ends of `requires-python = ">=3.11"` (#283):
# - "3.11" is the FLOOR, stated explicitly because nothing else states it: the suite
# passing there is what keeps the declared floor a measured claim instead of a hope.
# 3.10 fails collection on `typing.assert_never` (3.11+), which is the one concrete
# feature binding the floor.
# - "3.14" is the leg local development actually runs (`.python-version` pins 3.14.4);
# it is named here by major only, since the matrix needs SOME explicit version and
# "whatever .python-version says" is not addressable per-leg.
# Both legs stay inside THIS job so the required status context remains `test` -- the
# matrix fans out the context, it does not rename it, so every leg gates the merge.
strategy:
matrix:
python: ["3.11", "3.14"]
steps:
- uses: actions/checkout@v4

Expand All @@ -52,13 +65,13 @@ jobs:
enable-cache: true

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

- name: Sync dependencies
run: uv sync --all-extras --dev
run: uv sync --all-extras --dev --python "${{ matrix.python }}"

- name: Lint
run: uv run ruff check keel tests packages
run: uv run --python "${{ matrix.python }}" ruff check keel tests packages

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

- name: Test
run: uv run pytest -q
run: uv run --python "${{ matrix.python }}" pytest -q

# A build that cannot identify itself must not reach a release. This also catches an
# import-time break in the CLI, which `pytest` alone would not surface as sharply.
- name: Build identity
run: uv run keel --version
run: uv run --python "${{ matrix.python }}" keel --version
6 changes: 5 additions & 1 deletion CONTRIBUTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -39,7 +39,11 @@ exactly who said what.

## Development setup and the gates a PR must pass

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

```bash
Expand Down
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -34,7 +34,7 @@ so upfront rather than let step 4 be a surprise.

```bash
git clone https://github.com/CodeGateSoftware/keel.git && cd keel
uv sync --all-extras --dev # Python 3.14+ (uv python install 3.14)
uv sync --all-extras --dev # any Python 3.11+ (the repo develops on 3.14)
cp .env.example .env # put the CDP key/secret in it — market data only
uv run keel rules seed # register the rule families as candidates
uv run keel fetch # pull candle history for the default allowlist
Expand Down
2 changes: 1 addition & 1 deletion packages/keel-broker-api/pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ name = "keel-broker-api"
version = "0.7.1"
description = "Broker port, domain types, capability model, and conformance suite for keel"
license = "Apache-2.0"
requires-python = ">=3.14.4"
requires-python = ">=3.11"
# Pinned `==`: see the note in the root `pyproject.toml`. An unpinned sibling is satisfied by
# whatever is already installed, which is how a deployment ends up running mixed versions.
dependencies = ["keel-core==0.7.1"]
Expand Down
2 changes: 1 addition & 1 deletion packages/keel-broker-coinbase/pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ name = "keel-broker-coinbase"
version = "0.7.1"
description = "Coinbase Advanced Trade adapter for keel"
license = "Apache-2.0"
requires-python = ">=3.14.4"
requires-python = ">=3.11"
# Siblings pinned `==` (see the root `pyproject.toml`); the third-party SDK is not, because it
# has its own release cycle and is not cut from this repo.
dependencies = ["keel-core==0.7.1", "keel-broker-api==0.7.1", "coinbase-advanced-py>=1.8.4"]
Expand Down
2 changes: 1 addition & 1 deletion packages/keel-broker-fake/pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ name = "keel-broker-fake"
version = "0.7.1"
description = "A deliberately divergent second broker adapter, to keep the port honest"
license = "Apache-2.0"
requires-python = ">=3.14.4"
requires-python = ">=3.11"
# Pinned `==`: see the note in the root `pyproject.toml`.
dependencies = ["keel-core==0.7.1", "keel-broker-api==0.7.1"]

Expand Down
2 changes: 1 addition & 1 deletion packages/keel-broker-robinhood/pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ name = "keel-broker-robinhood"
version = "0.7.1"
description = "Robinhood Crypto Trading API v2 adapter for keel"
license = "Apache-2.0"
requires-python = ">=3.14.4"
requires-python = ">=3.11"
# `pynacl` is here and nowhere else in the workspace: Robinhood signs every request with an
# Ed25519 key, which no other venue keel talks to requires. Keeping it a dependency of this
# package alone means an engine that never installs this adapter never installs the crypto stack
Expand Down
2 changes: 1 addition & 1 deletion packages/keel-core/pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ name = "keel-core"
version = "0.7.1"
description = "Shared domain types, configuration, and logging for keel"
license = "Apache-2.0"
requires-python = ">=3.14.4"
requires-python = ">=3.11"
dependencies = ["pyyaml>=6.0.3", "python-dotenv>=1.2.2"]

[build-system]
Expand Down
4 changes: 2 additions & 2 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@ readme = "README.md"
authors = [
{ name = "CodeGate Software" }
]
requires-python = ">=3.14.4"
requires-python = ">=3.11"
dependencies = [
"click>=8.4.2",
# pyyaml/python-dotenv are deliberately NOT listed here: nothing under `keel/` imports
Expand Down Expand Up @@ -87,7 +87,7 @@ dev = [
# [tool.ruff] here, so keeping a copy in both places would leave silently-dead settings.

[tool.mypy]
python_version = "3.14"
python_version = "3.11"
files = ["packages", "keel", "tests"]
# Implied by `--strict`, but mypy rejects it in a per-module section ("Per-module sections
# should only specify per-module flags"), so it is stated here rather than in the broker block
Expand Down
106 changes: 106 additions & 0 deletions tests/test_python_floor.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,106 @@
"""The Python floor: the lowest version the suite actually runs on, kept honest in CI.

`requires-python` was `>=3.14.4` from the project's first day, which said "the interpreter I
develop on" rather than "the interpreter the code needs" -- and Python 3.14 is new enough that
many contributors do not have it, several distributions do not package it, and some CI images
lag. A floor that bars contributors silently costs them: they try, fail to build, and leave
without opening an issue (#283).

So the floor was measured, not guessed. The full suite was run at each step down:

- **3.13, 3.12, 3.11**: 2,788 passed / 1 skipped -- identical to 3.14, nothing requires it.
- **3.10**: collection fails, `ImportError: cannot import name 'assert_never' from 'typing'` --
the one concrete binding constraint found. `assert_never` is 3.11+.

The decision: the floor is **3.11**, the lowest passing version; development stays pinned to
3.14.4 via `.python-version`; CI runs the suite on 3.11 so the floor stays true instead of
rotting back into an untested claim. These tests pin all three halves of that decision -- the
declared floor in every distribution, the binding-feature evidence that justifies it, and the
CI leg that verifies it -- because each is a way the decision can silently come undone: a
seventh distribution added with a different floor, the `assert_never` imports disappearing
(which means the floor COULD drop and the recorded reason is stale), or the CI matrix losing
its floor leg.
"""

from __future__ import annotations

import re
import tomllib
from pathlib import Path

_ROOT = Path(__file__).resolve().parents[1]

#: The floor, as decided in #283. One value in every distribution, like the version pins.
_FLOOR = ">=3.11"


def _pyprojects() -> dict[str, dict]:
"""distribution name -> parsed `pyproject.toml`, for the root and every workspace member."""
found = {}
for path in [_ROOT / "pyproject.toml", *sorted((_ROOT / "packages").glob("*/pyproject.toml"))]:
data = tomllib.loads(path.read_text())
found[data["project"]["name"]] = data
return found


def test_every_distribution_declares_the_same_floor():
"""`requires-python = ">=3.11"` everywhere -- a mixed floor is a mixed install waiting.

The workspace is cut in one build; a member declaring a different floor than its siblings
means an environment that satisfies one requirement and rejects another, which is the
version-pin failure mode `test_packaging.py` guards, one field over.
"""
floors = {name: data["project"]["requires-python"] for name, data in _pyprojects().items()}
wrong = {name: floor for name, floor in floors.items() if floor != _FLOOR}
assert not wrong, (
f"these distributions declare a Python floor other than {_FLOOR!r}: {wrong}. The floor "
"is one decision for the whole workspace (see tests/test_python_floor.py's docstring "
"for the measured reasoning)"
)


def test_the_binding_feature_that_sets_the_floor_still_exists():
"""The floor's REASON must stay true: `assert_never` (3.11+) must still be imported.

3.10 fails collection on exactly this import. If it ever disappears from the codebase, the
floor could drop further and this test's recorded justification is stale -- re-measure
downward rather than leaving a floor pinned to a feature nobody uses.
"""
importers: list[str] = []
for base in (_ROOT / "keel", _ROOT / "packages", _ROOT / "tests"):
for path in base.rglob("*.py"):
if re.search(r"import\s+.*\bassert_never\b", path.read_text()):
importers.append(str(path.relative_to(_ROOT)))
assert importers, (
"nothing imports `assert_never` any more -- the 3.11 floor's binding constraint is "
"gone; re-run the suite on 3.10 and lower the floor if it now passes"
)


def test_ci_runs_the_suite_on_the_floor_so_it_stays_true():
"""The `test` job must have a 3.11 leg, or the floor is an untested claim.

Measured floors rot: a dependency or a line of code starts needing something newer and
`>=3.11` becomes a lie that only manifests on a contributor's machine. The CI leg is what
converts the floor from a declaration into an invariant. It lives in the `test` job's
matrix on purpose -- a job of its own would report a status context nothing requires,
which is exactly how #268's mypy gate came undone before it existed.
"""
ci = (_ROOT / ".github" / "workflows" / "ci.yml").read_text()
assert "3.11" in ci, (
"ci.yml no longer runs on Python 3.11 -- the declared floor has lost its CI leg; "
"restore the matrix entry or re-measure the floor and update it everywhere"
)


def test_the_floor_is_stated_where_a_contributor_reads_it():
"""README and CONTRIBUTING must say 3.11, so the floor is found in docs, not the resolver.

'A contributor hitting the floor gets a clear message rather than a confusing resolver
error' (#283) starts with the floor being low enough to be rare and visible enough to be
self-diagnosing when hit.
"""
readme = (_ROOT / "README.md").read_text()
contributing = (_ROOT / "CONTRIBUTING.md").read_text()
assert "3.11" in readme, "the README's setup section must state the 3.11 floor"
assert "3.11" in contributing, "CONTRIBUTING.md must state the 3.11 floor"
Loading
Loading