Why
views-datafactory declares requires-python = ">=3.12" and is the only repo on the platform above 3.11:
| Repo |
Declared |
| views-datafactory |
>=3.12 ← the outlier |
| views-frames, views-lab00, views-lstm-lab, views-metric-lab |
>=3.10 |
| views-bayesian |
>=3.11 |
| views-baseline |
>=3.11,<3.14 |
| views-crafdapi, views-faoapi, views-hydranet, views-reporting |
>=3.11,<3.15 |
| views-pipeline-core, views-postprocessing, views-evaluation, views-stepshifter, views-r2darts2 |
>=3.11,<3.15 |
The views-models conda envs run 3.11.14 / 3.11.15 (postprocessing, ensemble, baseline, hydranet), so pip install views-datafactory fails in all four — and 28 views-models requirements files reference this package. That is the named consumer justifying this change. Without one, the right action would be to leave the floor alone and simply record that it was inherited.
The floor is soft
docs/ADRs/030_raster_tooling.md (Accepted, 2026-05-18) is the sole document that decided it. Its reason is a fact about a vendor at a moment, not an architectural need:
Python ≥3.12 version bump (tifffile's current releases require it; assessed as safe for this repo)
3.11 is never mentioned in that ADR — it was not a rejected option, it was never on the table. And the ADR's own supporting evidence assesses the wrong version: "no 3.10-specific patterns in codebase" says nothing about 3.11 or 3.12.
Already verified (before opening this)
- Suite green under 3.11.13 —
pytest exit 0, same six pre-existing xfails, no new failures
ruff at py311 clean · mypy src/ clean · compileall clean
- No 3.12-only syntax or runtime API anywhere: no PEP 695,
itertools.batched, @override, Path.walk, sys.monitoring
- Dependencies resolve at 3.11
The cost, stated plainly
uv.lock becomes multi-version. Under 3.11 it resolves tifffile 2026.3.3 / imagecodecs 2026.3.6; under ≥3.12 it keeps 2026.5.15 / 2026.5.10. Both upstream drops are real and permanent:
- tifffile dropped 3.11 at 2026.4.11 and now uses PEP 695 syntax — it would
SyntaxError on 3.11, not degrade
- imagecodecs dropped it at 2026.5.10 and ships
cp312-abi3 wheels only
3.11-compatible releases with real wheels do exist, so the floor is installable — just pinned to the March-2026 raster line for 3.11 consumers.
The finding that reorders the work
imagecodecs is imported by nothing in src/. It is tifffile's LZW decoder, reached implicitly through read_geotiff's page.asarray() call, and every GHS-POP and GHS-BUILT-S GeoTIFF from JRC is LZW-compressed. tifffile has no pure-Python LZW fallback — COMPRESSION 5 dispatches unconditionally to imagecodecs.lzw_decode.
No test in this repository has ever written a compressed TIFF. Every imwrite in the suite is uncompressed — verified. So the load-bearing production decoder has zero coverage on any interpreter, and has since ADR-030.
This change does not create that hole. It makes it visible, because for the first time there are two candidate decoders and a reason to ask which one we tested. The LZW test must land before the 3.13 CI job, or that job compares two uncompressed code paths and proves nothing.
Hard constraint — no matrix
Branch protection on both main and development requires status checks by bare job name (lint, typecheck, test, docs; main also import-enforcement) with enforce_admins: true. A strategy.matrix renames the reported check to test (3.11), so the required test never reports and every PR deadlocks forever. Adding new job names is safe — they are not required, hence non-blocking.
Scope
pyproject.toml — floor, missing 3.11 classifier, ruff py311, add python_version = "3.11" to [tool.mypy] (it has none, so mypy checks against the running 3.13)
uv lock — verify exactly two forks appear, no maximum regresses
tests/test_dependency_floors.py — _locked_versions() collapses duplicate names via dict comprehension. Fix to collect all resolutions; strict semantics (any(v == floor))
tests/test_raster_io.py — the first LZW test
.github/workflows/ci.yml — four pins to the floor; new non-required test-py313 job; interpreter assertions, not echoes
tests/test_ci_gates.py — bind CI pins to requires-python; assert something tests a non-floor interpreter
publish_package.yml (5 sites), publishing_to_pypi.md (2), hetzner_deployment_guide.md (1 — rewritten, not renumbered)
- ADR-030 amendment, following ADR-051's blockquote convention. Amend, do not supersede — the tooling decision is untouched
- Register C-347, C-348
The interesting sub-finding
The _locked_versions() collapse was raised during /code-review on #430, scored 25, and correctly refuted — the changelog records it as "a dict-collision path that this project's single requires-python and marker-free dependencies cannot reach." This change deletes that premise.
A refutation is only as durable as its premise, and nothing was watching the premise.
Follow-up that must not be dropped
test-py313 ships non-required (a required check that has never reported blocks all merges). Once it has reported on both development and main, add it to both required-check lists. That is a GitHub settings action and it is the only thing that closes C-347. A workaround whose enforcement lives in a settings page is weaker than the thing it replaces.
Why
views-datafactorydeclaresrequires-python = ">=3.12"and is the only repo on the platform above 3.11:The views-models conda envs run 3.11.14 / 3.11.15 (postprocessing, ensemble, baseline, hydranet), so
pip install views-datafactoryfails in all four — and 28 views-models requirements files reference this package. That is the named consumer justifying this change. Without one, the right action would be to leave the floor alone and simply record that it was inherited.The floor is soft
docs/ADRs/030_raster_tooling.md(Accepted, 2026-05-18) is the sole document that decided it. Its reason is a fact about a vendor at a moment, not an architectural need:3.11 is never mentioned in that ADR — it was not a rejected option, it was never on the table. And the ADR's own supporting evidence assesses the wrong version: "no 3.10-specific patterns in codebase" says nothing about 3.11 or 3.12.
Already verified (before opening this)
pytestexit 0, same six pre-existing xfails, no new failuresruffatpy311clean ·mypy src/clean ·compileallcleanitertools.batched,@override,Path.walk,sys.monitoringThe cost, stated plainly
uv.lockbecomes multi-version. Under 3.11 it resolvestifffile 2026.3.3/imagecodecs 2026.3.6; under ≥3.12 it keeps2026.5.15/2026.5.10. Both upstream drops are real and permanent:SyntaxErroron 3.11, not degradecp312-abi3wheels only3.11-compatible releases with real wheels do exist, so the floor is installable — just pinned to the March-2026 raster line for 3.11 consumers.
The finding that reorders the work
imagecodecsis imported by nothing insrc/. It is tifffile's LZW decoder, reached implicitly throughread_geotiff'spage.asarray()call, and every GHS-POP and GHS-BUILT-S GeoTIFF from JRC is LZW-compressed. tifffile has no pure-Python LZW fallback — COMPRESSION 5 dispatches unconditionally toimagecodecs.lzw_decode.No test in this repository has ever written a compressed TIFF. Every
imwritein the suite is uncompressed — verified. So the load-bearing production decoder has zero coverage on any interpreter, and has since ADR-030.This change does not create that hole. It makes it visible, because for the first time there are two candidate decoders and a reason to ask which one we tested. The LZW test must land before the 3.13 CI job, or that job compares two uncompressed code paths and proves nothing.
Hard constraint — no matrix
Branch protection on both
mainanddevelopmentrequires status checks by bare job name (lint,typecheck,test,docs;mainalsoimport-enforcement) withenforce_admins: true. Astrategy.matrixrenames the reported check totest (3.11), so the requiredtestnever reports and every PR deadlocks forever. Adding new job names is safe — they are not required, hence non-blocking.Scope
pyproject.toml— floor, missing 3.11 classifier, ruffpy311, addpython_version = "3.11"to[tool.mypy](it has none, so mypy checks against the running 3.13)uv lock— verify exactly two forks appear, no maximum regressestests/test_dependency_floors.py—_locked_versions()collapses duplicate names via dict comprehension. Fix to collect all resolutions; strict semantics (any(v == floor))tests/test_raster_io.py— the first LZW test.github/workflows/ci.yml— four pins to the floor; new non-requiredtest-py313job; interpreter assertions, not echoestests/test_ci_gates.py— bind CI pins torequires-python; assert something tests a non-floor interpreterpublish_package.yml(5 sites),publishing_to_pypi.md(2),hetzner_deployment_guide.md(1 — rewritten, not renumbered)The interesting sub-finding
The
_locked_versions()collapse was raised during/code-reviewon #430, scored 25, and correctly refuted — the changelog records it as "a dict-collision path that this project's singlerequires-pythonand marker-free dependencies cannot reach." This change deletes that premise.A refutation is only as durable as its premise, and nothing was watching the premise.
Follow-up that must not be dropped
test-py313ships non-required (a required check that has never reported blocks all merges). Once it has reported on bothdevelopmentandmain, add it to both required-check lists. That is a GitHub settings action and it is the only thing that closes C-347. A workaround whose enforcement lives in a settings page is weaker than the thing it replaces.