Inspiration
A package declares requires-python. Nobody re-derives that number after a refactor adds a match statement.
Pip believes it, installs on the version you claimed, and a stranger gets a SyntaxError that names your file and not the reason. Tests run on the developer's Python. CI runs the versions in the matrix, usually the same set. The floor is a number in a file that nothing in the pipeline checks.
What it does
Reads the code that actually ships and compares it against every floor the project declares — requires-python, python_requires, and the trove classifiers. Four groups:
- breaks its promise — an unguarded construct needs a newer Python, with file, line and version;
- guarded — a newer construct is there, but behind
sys.version_info, atry/except ImportErrorfallback,if TYPE_CHECKING:, orfrom __future__ import annotations; - contradiction — the project states two different floors in two different places;
- not judged — a file that would not parse, or a project with no declared floor, with the reason.
Non-zero exit on a broken promise. No dependencies, no network, and it never imports or executes the package under test.
The probe that came first
Before writing a line of the tool I probed the idea with a throwaway script against thirteen real repositories. It immediately accused two of them:
black: import tomllib needs 3.11, declares 3.10 ← FALSE
poetry: import tomllib needs 3.11, declares 3.10 ← FALSE
Both accusations were wrong. black wraps that import in try/except ImportError; poetry in if sys.version_info < (3, 11). Those two false positives are what the guard analysis was built from, and they are the reason the scope document names the guards before it names the features.
Accomplishments
Thirty maintained Python projects — poetry, rich, requests, black, jinja, urllib3, werkzeug, typer, sqlmodel, httpx and twenty more — 1,060 shipped files, zero broken, five guarded.
Zero is the result that matters: these projects keep their promises, so a tool with an opinion here would simply be wrong. The five guarded findings include the same two lines that fooled the probe — now found, understood, and not accused. Reproduce with ./demo/scan.sh.
And the other direction, removing poetry's guard exactly as a tidy-up commit would:
$ sed -i '/version_info < (3, 11)/,+4c\import tomllib' src/poetry/utils/_compat.py
$ runs-on --repo poetry --quiet
BREAKS ITS PROMISE 1
src/poetry/utils/_compat.py:11 import tomllib
needs 3.11, this package promises 3.10
exit 1
--floor answers the question a maintainer actually has, without editing any metadata: could requests still claim 3.8? No — six things break, each with its line. Could rich? At 3.8 yes, at 3.7 seven walrus operators say no.
Challenges I ran into
The tool broke its own promise, and could not tell me. The first time it ran under the 3.9 its own pyproject.toml declares, it crashed on ast.Match — which does not exist before 3.10. Its own check had been silent, because a missing attribute is not a syntax error. That limit is written into the README rather than quietly fixed.
Then CI caught what I had not run. Two test fixtures contain a match statement, and ast.parse refuses it on 3.9, so the suite was green on my Python and red on the floor. The tool's behaviour was already right — such a file is reported not judged, with the reason, rather than as clean — so the fix was in the tests, plus a check.sh that runs the whole gate on every interpreter CI uses. Running a suite on one version proves nothing about another.
What I learned
Write down the false positives before writing the tool. This one had its two real failure cases in hand on day zero, and the scope document was built around them instead of around the features.
Prior art
vermin has done the harder half of this for years: it detects the minimum Python version a body of code needs, takes --target values, has a lint mode that prints only target violations, and exits non-zero when a target is not met. Its feature database encodes far more of Python's history than this does.
What this does differently is where the number comes from. Runs On takes nobody's target: it reads the floor the package already promises out of pyproject.toml, setup.cfg, setup.py and the trove classifiers, reports when those sources disagree with each other, and compares against the lowest, because that is the one pip enforces. The question is not "what does this code need" but "is the promise already on disk still true" — the one that goes stale without anyone noticing, and the one a CI job can ask with no argument to keep up to date.
The guarded category is the second difference: a newer construct behind a version check, an import fallback or TYPE_CHECKING is printed as found-and-understood rather than suppressed, because a reader can only trust the silence if they can see what was looked at.
Honest limits
- It reads what ships, not what runs: code reached through
exec, a plugin or a generated file is invisible. - It reads syntax and imports, not attribute access — see
ast.Matchabove. - It can only recognise what its own interpreter can parse. On 3.9 it cannot read a
matchstatement at all, and reports that file as not judged rather than clean. - It says nothing about whether your dependencies support your floor.
Built with
Python 3, ast, difflib, pytest, GitHub Actions. No runtime dependencies. 30 tests, each writing the package it is about.
Built With
- ast
- github-actions
- pytest
- python
Log in or sign up for Devpost to join the conversation.