4242import re
4343import subprocess
4444import sys
45+ from importlib .metadata import PackageNotFoundError , version
4546from pathlib import Path
4647
48+ from packaging .requirements import Requirement
49+ from packaging .utils import canonicalize_name
50+
4751LOCKFILE_PATH = Path ("requirements.lock" )
4852REQUIREMENTS_PATH = Path ("requirements.txt" )
4953
@@ -78,8 +82,11 @@ def generate_lockfile(lockfile: Path = LOCKFILE_PATH) -> int:
7882 return 0
7983
8084
81- def check_installed (lockfile : Path = LOCKFILE_PATH ) -> int :
82- """Compare installed packages against *lockfile*."""
85+ def check_installed (
86+ lockfile : Path = LOCKFILE_PATH ,
87+ requirements : Path = REQUIREMENTS_PATH ,
88+ ) -> int :
89+ """Verify direct dependencies against portable locked versions."""
8390 if not lockfile .exists ():
8491 print (
8592 f"[ERROR] { lockfile } does not exist. "
@@ -88,35 +95,42 @@ def check_installed(lockfile: Path = LOCKFILE_PATH) -> int:
8895 )
8996 return 2
9097
91- result = subprocess .run (
92- [sys .executable , "-m" , "pip" , "freeze" ],
93- capture_output = True ,
94- text = True ,
95- )
96- if result .returncode != 0 :
97- print (f"[ERROR] pip freeze failed:\n { result .stderr } " , file = sys .stderr )
98- return 1
99-
100- installed = set (result .stdout .strip ().splitlines ())
101- locked = set (lockfile .read_text ("utf-8" ).strip ().splitlines ())
98+ locked_versions : dict [str , str ] = {}
99+ for raw in lockfile .read_text ("utf-8" ).splitlines ():
100+ if "==" in raw and not raw .lstrip ().startswith ("#" ):
101+ name , pinned_version = raw .split ("==" , 1 )
102+ locked_versions [canonicalize_name (name )] = pinned_version
102103
103- only_installed = installed - locked
104- only_locked = locked - installed
104+ direct : set [str ] = set ()
105+ for raw in requirements .read_text ("utf-8" ).splitlines ():
106+ line = raw .strip ()
107+ if not line or line .startswith (("#" , "-" )):
108+ continue
109+ requirement = Requirement (line )
110+ if requirement .marker is None or requirement .marker .evaluate ():
111+ direct .add (canonicalize_name (requirement .name ))
112+
113+ problems : list [str ] = []
114+ for name in sorted (direct ):
115+ pinned = locked_versions .get (name )
116+ if pinned is None :
117+ problems .append (f"{ name } : missing from requirements.lock" )
118+ continue
119+ try :
120+ installed = version (name )
121+ except PackageNotFoundError :
122+ problems .append (f"{ name } =={ pinned } : not installed" )
123+ continue
124+ if installed != pinned :
125+ problems .append (f"{ name } : installed { installed } , locked { pinned } " )
105126
106- if not only_locked :
107- print (f"[OK] All { len (locked )} locked packages are installed at pinned versions." )
108- if only_installed :
109- print (
110- f"[INFO] Ignoring { len (only_installed )} additional package(s); "
111- "CI installs lint and notebook tooling after application dependencies."
112- )
127+ if not problems :
128+ print (f"[OK] { len (direct )} direct dependencies match requirements.lock." )
113129 return 0
114130
115- print ("[FAIL] Environment diverges from requirements.lock:" )
116- if only_locked :
117- print ("\n In requirements.lock but NOT installed (missing packages):" )
118- for pkg in sorted (only_locked ):
119- print (f" - { pkg } " )
131+ print ("[FAIL] Direct dependencies diverge from requirements.lock:" )
132+ for problem in problems :
133+ print (f" - { problem } " )
120134
121135 print (
122136 "\n Diagnostic: run 'python scripts/verify_lockfile.py --generate' after "
@@ -211,7 +225,7 @@ def main(argv: list[str] | None = None) -> int:
211225 if args .generate :
212226 return generate_lockfile (lockfile )
213227
214- rc = check_installed (lockfile )
228+ rc = check_installed (lockfile , requirements )
215229
216230 if args .check_unpinned :
217231 # Advisory — never overrides the main check exit code
0 commit comments