#!/usr/bin/env python3 """Compare installed package versions against the pinned lockfiles. Editing a lockfile changes what a FUTURE install produces. It does not touch an already-populated site-packages. On 2026-08-29 commit a8f8580 raised eight pins carrying advisories; no install was re-run, so this workstation kept running pyjwt 2.12.1 and idna 3.13 for three weeks while requirements.txt said 2.13.0 and 3.19. Every local test run in that window exercised different libraries than a container build would have, and a vulnerability scan of the environment disagreed with a scan of the lockfile. MISSING is reported separately from MISMATCHED on purpose. The first pass at this incident only compared versions of packages that were installed, which silently skipped pytest-xdist and execnet - absent entirely. That surfaced later as "pytest: error: unrecognized arguments: -n", which reads as a broken pytest.ini rather than a stale environment, and invites deleting "-n auto" instead of installing the plugin. Exit codes: 0 environment matches the lockfiles 1 drift found 2 could not run the comparison (bad arguments, unreadable lockfile) """ import argparse import importlib.metadata import re import sys from pathlib import Path PIN_PATTERN = re.compile(r'^([A-Za-z0-9._-]+)==([^\s\\;]+)') DEFAULT_LOCKFILES = ('requirements.txt', 'requirements-dev.txt') def normalize(name): """PEP 503 normalization, so Pillow/pillow and pytest_cov/pytest-cov agree.""" return re.sub(r'[-_.]+', '-', name).lower() def read_pins(lockfile): """Map normalized package name to pinned version for one lockfile. uv pip compile emits hashes on continuation lines and comments elsewhere; only lines that START with name==version are pins, which is what the regex anchors on. """ pins = {} for line in lockfile.read_text(encoding='utf-8').splitlines(): match = PIN_PATTERN.match(line) if match: pins[normalize(match.group(1))] = match.group(2) return pins def compare(pins): """Split pinned packages into missing, mismatched, and satisfied.""" missing, mismatched, satisfied = [], [], 0 for name, wanted in sorted(pins.items()): try: installed = importlib.metadata.version(name) except importlib.metadata.PackageNotFoundError: missing.append((name, wanted)) continue if normalize_version(installed) != normalize_version(wanted): mismatched.append((name, installed, wanted)) else: satisfied += 1 return missing, mismatched, satisfied def normalize_version(version): """Trim a trailing .0 series so 1.2.0 and 1.2 compare equal.""" trimmed = version.strip() while trimmed.endswith('.0'): trimmed = trimmed[:-2] return trimmed def main(): parser = argparse.ArgumentParser( description='Check installed packages against the pinned lockfiles.') parser.add_argument('lockfiles', nargs='*', default=list(DEFAULT_LOCKFILES), help='lockfiles to check (default: %s)' % ' '.join(DEFAULT_LOCKFILES)) parser.add_argument('--quiet', action='store_true', help='print only drift, not the clean summary') arguments = parser.parse_args() repo_root = Path(__file__).resolve().parent.parent total_missing = total_mismatched = 0 report_lines = [] for name in arguments.lockfiles: lockfile = Path(name) if not lockfile.is_absolute(): lockfile = repo_root / lockfile if not lockfile.is_file(): print('ERROR: lockfile not found: %s' % lockfile, file=sys.stderr) return 2 pins = read_pins(lockfile) if not pins: print('ERROR: no pins parsed from %s' % lockfile, file=sys.stderr) return 2 missing, mismatched, satisfied = compare(pins) total_missing += len(missing) total_mismatched += len(mismatched) for package, wanted in missing: report_lines.append(' MISSING %s (pinned %s) [%s]' % (package, wanted, lockfile.name)) for package, installed, wanted in mismatched: report_lines.append(' MISMATCH %s installed %s, pinned %s [%s]' % (package, installed, wanted, lockfile.name)) if not arguments.quiet and not missing and not mismatched: print('OK: %s - %d pins satisfied' % (lockfile.name, satisfied)) if report_lines: print('FAIL: dependency drift - %d missing, %d mismatched:' % (total_missing, total_mismatched)) for line in report_lines: print(line) print('') print('Interpreter: %s' % sys.executable) print('Fix with:') for name in arguments.lockfiles: print(' python -m pip install --require-hashes -r %s' % name) return 1 return 0 if __name__ == '__main__': sys.exit(main())