Skip to content
Merged
250 changes: 233 additions & 17 deletions pipeline-scripts/check_dependency_floors.py

Large diffs are not rendered by default.

94 changes: 94 additions & 0 deletions pipeline-scripts/check_dependency_floors_test.py
Original file line number Diff line number Diff line change
Expand Up @@ -455,3 +455,97 @@ class TestLocalVersionSegments:
)
def test_local_segment_does_not_demote_the_release(self, installed, operator, required, expected):
assert checker.satisfies(installed, operator, required) is expected


# --------------------------------------------------------------------------
# #17449 — auditing an interpreter this process is NOT running in, and telling
# "not installed" apart from "could not read it".
# --------------------------------------------------------------------------


class TestAuditingAnotherInterpreter:
def test_resolve_interpreter_accepts_a_venv_directory(self, tmp_path):
(tmp_path / "bin").mkdir()
binary = tmp_path / "bin" / "python"
binary.write_text("", encoding="utf-8")
assert checker.resolve_interpreter(tmp_path) == binary

def test_resolve_interpreter_accepts_a_direct_path(self, tmp_path):
binary = tmp_path / "python3.14"
binary.write_text("", encoding="utf-8")
assert checker.resolve_interpreter(binary) == binary

def test_a_directory_without_an_interpreter_raises(self, tmp_path):
with pytest.raises(checker.InterpreterUnreadable):
checker.resolve_interpreter(tmp_path)

def test_a_missing_interpreter_raises_rather_than_reporting_nothing_installed(self, tmp_path):
"""The load-bearing refusal.

Returning {} would make every declared floor read as 'absent', and
absence is not a shortfall by default -- so an unreachable venv would
report as a clean environment. That is the failure this module exists
to prevent, arriving through the new door.
"""
with pytest.raises(checker.InterpreterUnreadable):
checker.installed_versions(["anything"], tmp_path / "no-such-python")

def test_this_interpreter_still_answers_with_one_argument(self):
"""#17449 is an added capability, not a changed contract.

Existing callers and test stubs bind `installed_versions(names)`. The
second parameter is only ever passed on the --venv path.
"""
assert checker.installed_versions(["pytest"]).get("pytest")


class TestUnreadableIsNotAbsent:
"""Duplicate *.dist-info makes `version()` answer nothing (#15063's append-only venv).

A deployed venv carried two dist-info directories for one distribution. The
old code stored that as `None`, `shortfalls` treated `None` as absent, and
absence is not a shortfall by default -- so the floor was silently never
checked and the environment reported fully satisfied.
"""

def _declaration(self):
return checker.Declaration(name="pkg", operator=">=", required="2.0", source="req.txt:1")

def test_unreadable_is_always_reported_even_without_require_present(self):
found = checker.shortfalls([self._declaration()], {"pkg": checker.UNREADABLE}, require_present=False)
assert [s.installed for s in found] == [checker.UNREADABLE]

def test_absent_is_still_silent_without_require_present(self):
assert checker.shortfalls([self._declaration()], {}, require_present=False) == []

def test_the_two_states_describe_differently(self):
absent = checker.Shortfall(self._declaration(), checker.ABSENT).describe()
unreadable = checker.Shortfall(self._declaration(), checker.UNREADABLE).describe()
assert "NOT INSTALLED" in absent
assert "UNREADABLE" in unreadable and "NOT checked" in unreadable
assert absent != unreadable


class TestTheReportNamesItsEnvironment:
"""A number without its environment is what caused two retractions in one day."""

def _one(self):
return [
checker.Shortfall(
checker.Declaration(source="req.txt:1", name="pkg", operator=">=", required="2.0"),
"1.0",
)
]

def test_the_default_still_names_the_running_interpreter(self):
assert "interpreter running this check" in checker.render(self._one(), 1)[0]

def test_a_named_environment_replaces_it(self):
line = checker.render(self._one(), 1, environment="/opt/x/venv/bin/python (python 3.14.6)")[0]
assert "/opt/x/venv/bin/python (python 3.14.6)" in line
assert "interpreter running this check" not in line

def test_a_deployed_report_does_not_give_ci_parity_advice(self):
lines = checker.render(self._one(), 1, environment="/opt/x/venv/bin/python")
assert not any("setup-ci-parity-env.sh" in line for line in lines)
assert any("DEPLOYED environment" in line for line in lines)
7 changes: 7 additions & 0 deletions repo_tests/dependabot_requirements_coverage_test.py
Original file line number Diff line number Diff line change
Expand Up @@ -68,6 +68,13 @@
"deliberately loose so the guide keeps working on older hosts. Scanning it "
"would produce weekly PRs against documentation prose (#14562)."
),
"repo_tests/requirements_constraint_drift_baseline.txt": (
"a record of `<service file>::<package>` PAIRS, not a manifest — it names the "
"packages whose CI and service planes declare different constraints, "
"for tools/lint/check_requirements_ci_drift.py (#17448). It carries no version "
"specifiers and nothing installs from it; like its sibling below it is in this "
"population only because its filename contains `requirements` (#14562)."
),
"repo_tests/requirements_ci_drift_baseline.txt": (
"an allowlist of PACKAGE NAMES, not a manifest — it records the production "
"packages CI deliberately omits, for tools/lint/check_requirements_ci_drift.py. "
Expand Down
12 changes: 8 additions & 4 deletions repo_tests/requirements_ci_drift_baseline.txt
Original file line number Diff line number Diff line change
Expand Up @@ -70,12 +70,16 @@ typing-extensions
# grep), so no currently-collected test reaches that import. autobot_shared/
# itself imports no opentelemetry module unconditionally (verified directly,
# unlike bcrypt/pydantic-settings above).
opentelemetry-api
opentelemetry-sdk
#
# #17557: four entries LEFT this list. "No test reaches the import" was true
# and irrelevant -- chromadb, langsmith and redis pull api, sdk,
# exporter-otlp-proto-grpc and proto into CI transitively whether a test
# imports them or not. Undeclared meant unconstrained, not absent, so CI took
# 1.45.0 against production's ==1.44.0 and failed shard 1/12 on eight PRs.
# They are now declared in requirements-ci/monitoring.txt at the production
# pin. The three below stay: nothing in CI installs them at all.
opentelemetry-exporter-otlp
opentelemetry-exporter-otlp-proto-grpc
opentelemetry-exporter-otlp-proto-http
opentelemetry-proto
opentelemetry-instrumentation-fastapi
opentelemetry-instrumentation-redis
opentelemetry-instrumentation-aiohttp-client
Expand Down
126 changes: 126 additions & 0 deletions repo_tests/requirements_ci_drift_test.py
Original file line number Diff line number Diff line change
Expand Up @@ -272,3 +272,129 @@ def test_the_checker_needs_no_third_party_import():
and line.split()[1].split(".")[0] not in {"argparse", "logging", "pathlib", "re", "sys"}
]
assert third_party == [], f"the checker imports non-stdlib modules: {third_party}"


# --------------------------------------------------------------------------
# #17448 — the two planes may both declare a package and still install
# different releases. #14551 above checks PRESENCE and says so itself.
# --------------------------------------------------------------------------


def test_constraint_of_strips_extras_and_markers():
assert checker.constraint_of("sqlalchemy>=2.0.54,<2.1") == ">=2.0.54,<2.1"
assert checker.constraint_of("sqlalchemy[asyncio] >= 2.0.54") == ">=2.0.54"
assert checker.constraint_of("uvicorn==0.53.0 ; python_version >= '3.12'") == "==0.53.0"
assert checker.constraint_of("greenlet") == ""


def test_equivalent_spellings_are_not_a_difference():
"""`packaging` normalises versions, so the same constraint spelled two ways agrees.

This was the harmless half of what review found: the textual version called
`==1.0` and `==1.0.0` different. Noise rather than danger, but noise is how
a guard gets ignored.
"""
assert checker.constraints_differ("==1.0", "==1.0.0") is False
assert checker.constraints_differ(">=1.0", ">=1.0") is False


def test_an_exclusion_is_a_difference():
"""The dangerous half, and the reason this is not a text comparison.

`">=1,!=2.1"` and `">=1"` were reported identical. If 2.1 is the newest
release they select different versions, and a missed pair here is silent.
"""
assert checker.constraints_differ(">=1,!=2.1", ">=1") is True


def test_differing_lower_bounds_are_a_difference():
"""No carve-out for "both take latest" -- that was an unstated assumption.

The first version treated two purely lower-bound specifiers as equivalent
because both install whatever published last. That holds only while a
release exists above both floors: with only 2.0.53 published, `>=2.0.52`
installs and `>=2.0.54` does not resolve at all. The guard asserts what it
can check -- that the planes DECLARE the same thing.
"""
assert checker.constraints_differ(">=2.0.52", ">=2.0.54") is True


def test_a_pin_against_an_unbounded_floor_differs():
"""The shape that took SQLAlchemy 2.1.0 into one venv and 2.0.54 into another."""
assert checker.constraints_differ("==2.0.54", ">=2.0.54") is True
assert checker.constraints_differ(">=2.0.54,<2.1", ">=2.0.54") is True
assert checker.constraints_differ("==1.0", "==2.0") is True


def test_an_unparseable_specifier_is_not_proof_of_sameness():
"""Failing to parse must not read as "these agree"."""
assert checker.constraints_differ("not-a-specifier", ">=1") is True


def test_the_comparison_is_per_service_file_not_a_merged_plane():
"""A deliberate per-service split must not be reported as a conflict.

`websockets` is capped `<16` in autobot-backend because langgraph-sdk
requires it, and floored `>=17.1` in autobot-slm-backend, which has had its
own venv since #16394. Both are documented at their own site. A merged
"service plane" would either report that as a conflict -- training people to
ignore this guard -- or silently discard one of the two constraints through
`production_requirement_names`' last-wins update, and then compare the
survivor. Entries are keyed by file for exactly that reason.
"""
drift, compared = checker.compute_constraint_drift(REPO_ROOT)
assert compared > 0
assert all("::" in entry for entry in drift)
files = {entry.split("::", 1)[0] for entry in drift}
assert files <= set(checker._PRODUCTION_REQUIREMENTS), f"unexpected file keys: {files}"


def test_the_constraint_audit_is_clean_on_the_real_tree():
"""The baseline matches what the tree actually declares, both directions."""
compared, problems = checker.audit_constraint_drift(REPO_ROOT)
assert compared > 0, "the constraint check compared nothing"
assert not problems, "\n".join(problems)


def test_the_baseline_never_covers_every_compared_pair() -> None:
"""A ceiling on the record, and deliberately NO floor.

An earlier version also asserted the baseline was non-empty. That punishes
the person who finishes the work: fixing the last divergent pair means
deleting its entry, and the assertion then fails while `audit_constraint_drift`
reports a perfectly clean tree (#17502 review). A ratchet that cannot reach
zero is a ratchet nobody can finish.

The ceiling stays. If every compared pair were baselined the guard would
permit anything and the number would stop meaning improvement.
"""
baseline = checker.load_constraint_baseline(REPO_ROOT)
_, compared = checker.compute_constraint_drift(REPO_ROOT)
assert compared > 0, "nothing was compared, so the ceiling below asserts nothing"
assert len(baseline) < compared, "every shared pair is baselined -- nothing is being held"


def test_both_production_files_are_populated_on_the_real_tree() -> None:
"""Emptiness is a defect HERE, where a synthetic fixture cannot reach.

`unreadable_production_files` flags absence only, because
`_write_tree` empties the SLM file on purpose to isolate a backend-only
case. That leaves one real hazard uncovered: a production file that exists
and declares nothing, which the merged set would hide behind the other
file (#17502 review). This is where that is checked.
"""
for rel in checker._PRODUCTION_REQUIREMENTS:
declared = checker.parse_requirements(REPO_ROOT / rel)
assert declared, f"{rel} parsed to zero packages — the guard covers nothing for it"


def test_a_missing_production_file_is_not_masked_by_the_other(tmp_path) -> None:
"""The reviewer's case: one healthy file must not hide an absent one."""
_write_tree(tmp_path, prod_pkgs=["widget>=1.0"], ci_pkgs=["widget==1.0"], allowlist=[])
(tmp_path / "autobot-slm-backend" / "requirements.txt").unlink()

_, problems = checker.audit_drift(tmp_path)
assert any("autobot-slm-backend/requirements.txt is missing" in p for p in problems)

_, constraint_problems = checker.audit_constraint_drift(tmp_path)
assert any("autobot-slm-backend/requirements.txt is missing" in p for p in constraint_problems)
86 changes: 86 additions & 0 deletions repo_tests/requirements_constraint_drift_baseline.txt
Original file line number Diff line number Diff line change
@@ -0,0 +1,86 @@
# requirements constraint-drift baseline (#17448)
#
# One `<service requirements file>::<package>` per line. Each entry is a package
# declared on BOTH planes where the two planes DECLARE DIFFERENT constraints --
# typically `==X` in requirements-ci/*.txt against an unbounded `>=X` in the
# service file.
#
# It says "declare differently", not "resolve differently", and the distinction
# is the point. Which releases two specifiers actually select depends on the set
# of versions that exist, which no offline check can know. An earlier version of
# this guard claimed resolution and was wrong in both directions (#17502 review):
# it called `>=1,!=2.1` and `>=1` identical, and `==1.0` and `==1.0.0` different.
#
# WHY THIS IS NOT COSMETIC. That is how the SLM test venv took SQLAlchemy 2.1.0
# while CI's own venv stayed on 2.0.54. 2.1 dropped `greenlet` as a hard
# dependency, nothing in the tree declared it directly, and every SLM test
# module failed at import (#17433). The two migration gates reached the same
# 2.1.0 from the same cause a day later (#17499). Two point fixes; the
# mechanism was untouched both times, and these are the remaining candidates.
#
# #14551's guard, directly above this one in the same module, checks only
# whether a package is MIRRORED into CI at all -- it states in its own docstring
# that it does not merge the two dependency sets. A pair listed here passes that
# check and still installs two different releases.
#
# THIS LIST ONLY SHRINKS. An entry leaves when the two constraints are made to
# agree. A pair that stops diverging while its line remains is a hard error, not
# a silent no-op: a stale entry exempts nothing while looking authoritative.
#
# Measured 2026-09-25: 78 pairs appear on both planes, 53 of them declare
# differently. That is a count of DECLARATIONS, not of live version conflicts.
# Adding a line here is a decision to leave one of those 53 in place, and it
# needs a reason in the PR that adds it.
autobot-backend/requirements.txt::aiofiles
autobot-backend/requirements.txt::aiohttp
autobot-backend/requirements.txt::aiosqlite
autobot-backend/requirements.txt::alembic
autobot-backend/requirements.txt::anthropic
autobot-backend/requirements.txt::apscheduler
autobot-backend/requirements.txt::asyncssh
autobot-backend/requirements.txt::beautifulsoup4
autobot-backend/requirements.txt::celery
autobot-backend/requirements.txt::chromadb
autobot-backend/requirements.txt::croniter
autobot-backend/requirements.txt::fastapi
autobot-backend/requirements.txt::greenlet
autobot-backend/requirements.txt::httpx
autobot-backend/requirements.txt::jsonschema
autobot-backend/requirements.txt::langchain
autobot-backend/requirements.txt::langchain-community
autobot-backend/requirements.txt::markdownify
autobot-backend/requirements.txt::openai
autobot-backend/requirements.txt::pillow
autobot-backend/requirements.txt::psutil
autobot-backend/requirements.txt::pydantic
autobot-backend/requirements.txt::pypdf
autobot-backend/requirements.txt::python-docx
autobot-backend/requirements.txt::python-dotenv
autobot-backend/requirements.txt::pyyaml
autobot-backend/requirements.txt::sentence-transformers
autobot-backend/requirements.txt::sqlalchemy
autobot-backend/requirements.txt::structlog
autobot-backend/requirements.txt::torchmetrics
autobot-backend/requirements.txt::uvicorn
autobot-backend/requirements.txt::watchdog
autobot-backend/requirements.txt::weasyprint
autobot-backend/requirements.txt::websockets
autobot-slm-backend/requirements.txt::aiofiles
autobot-slm-backend/requirements.txt::aiohttp
autobot-slm-backend/requirements.txt::aiosqlite
autobot-slm-backend/requirements.txt::croniter
autobot-slm-backend/requirements.txt::cryptography
autobot-slm-backend/requirements.txt::fastapi
autobot-slm-backend/requirements.txt::greenlet
autobot-slm-backend/requirements.txt::httpx
autobot-slm-backend/requirements.txt::jinja2
autobot-slm-backend/requirements.txt::prometheus-client
autobot-slm-backend/requirements.txt::psutil
autobot-slm-backend/requirements.txt::psycopg2-binary
autobot-slm-backend/requirements.txt::pydantic
autobot-slm-backend/requirements.txt::python-multipart
autobot-slm-backend/requirements.txt::redis
autobot-slm-backend/requirements.txt::sqlalchemy
autobot-slm-backend/requirements.txt::starlette
autobot-slm-backend/requirements.txt::uvicorn
autobot-slm-backend/requirements.txt::websockets
27 changes: 27 additions & 0 deletions requirements-ci/storage.txt
Original file line number Diff line number Diff line change
Expand Up @@ -6,3 +6,30 @@ aiosqlite==0.22.1
greenlet==3.3.2 # SQLAlchemy async engine dependency, mirrored from the backends (#17432). Declared here rather than allowlisted in requirements_ci_drift_baseline.txt: this set declares sqlalchemy AND aiosqlite, so a test opening an async engine against it fails exactly as the SLM container did. "Not observed failing" is not "does not need it" -- that invisibility is what hid #17432 for a day.
alembic==1.20.0 # migration-gate static tests import migrations.baseline (#10001)
psycopg2-binary>=2.9.13,<3.0.0 # production dep (autobot-slm-backend/requirements.txt): the sync driver every migrations/*.py uses. migrations/utils.py + migrations/runner.py import it at module level, so migrations/utils_test.py fails collection without it (#13086)

# OpenTelemetry core (#17557) -- pinned HERE, beside chromadb, because chromadb
# is what drags it. Whoever next bumps chromadb needs to see this constraint in
# the same file.
#
# NOT a new CI dependency. chromadb==1.5.9 requires opentelemetry-api,
# -sdk and -exporter-otlp-proto-grpc (all >=1.2.0, unbounded above), which pull
# -proto, -proto-common and -semantic-conventions with them. They were simply
# never DECLARED, so pip took the newest release while root requirements.txt:51-58
# pins ==1.44.0. Upstream published 1.45.0 mid-morning 2026-09-26 and
# check_dependency_floors.py --strict began failing `python-suite shard 1/12` on
# eight PRs, none of which had touched a requirements file.
#
# Safe to pin, checked rather than assumed: chromadb 1.5.9's floors are >=1.2.0,
# and langsmith (>=1.30.0) and redis (>=1.39.1) are lower bounds too, so ==1.44.0
# satisfies every requirer and pip has nothing to backtrack.
#
# Only the four the root plane also declares are pinned. exporter-otlp,
# exporter-otlp-proto-http and propagator-b3 stay production-only and stay in the
# drift allowlist -- nothing in CI installs them, so declaring them would ADD
# packages rather than constrain existing ones. -proto-common and
# -semantic-conventions are left to chromadb: the root plane does not name them,
# so pinning them here would invent a constraint no other plane states.
opentelemetry-api==1.44.0
opentelemetry-sdk==1.44.0
opentelemetry-exporter-otlp-proto-grpc==1.44.0
opentelemetry-proto==1.44.0
Loading
Loading