3434
3535PYPI = "https://pypi.org/pypi"
3636TIMEOUT = 30
37+ QUOTES = "\" '"
3738PRERELEASE = re .compile (r"(a|b|rc|dev)\d" , re .I )
3839
3940
@@ -101,14 +102,16 @@ def wheel_note(files: list[dict], target: str) -> str:
101102
102103def inspect (name : str , installed : str , target : str , now : datetime ) -> dict :
103104 row = {"name" : name , "installed" : installed , "state" : "unknown" ,
104- "candidate" : "" , "days" : 0 , "since" : 0 , "wheels" : "" , "note" : "" }
105+ "candidate" : "" , "days" : 0 , "since" : 0 , "wheels" : "" , "note" : "" ,
106+ "requires" : []}
105107
106108 meta = fetch (f"{ PYPI } /{ name } /json" )
107109 vuln = fetch (f"{ PYPI } /{ name } /{ installed } /json" )
108110 if meta is None :
109111 row ["note" ] = "not on PyPI (or fetch failed)"
110112 return row
111113
114+ row ["requires" ] = ((vuln or {}).get ("info" ) or {}).get ("requires_dist" ) or []
112115 advisories = (vuln or {}).get ("vulnerabilities" ) or []
113116 releases = meta .get ("releases" , {})
114117 usable = {v : f for v , f in releases .items ()
@@ -140,6 +143,42 @@ def inspect(name: str, installed: str, target: str, now: datetime) -> dict:
140143 return row
141144
142145
146+ def caps_from (rows : list [dict ]) -> dict [str , list [tuple ]]:
147+ """Who caps whom, extras included.
148+
149+ A cap hidden behind an extra is the kind that is hardest to see by hand --
150+ it is absent from constraints.txt, from `pip list -o`, and from a reverse
151+ dependency tree that evaluates markers with no extra set. It is exactly the
152+ kind that quietly holds a package down, so record it and say who.
153+ """
154+ try :
155+ from packaging .requirements import InvalidRequirement , Requirement
156+ except ImportError :
157+ return {}
158+ caps : dict [str , list [tuple ]] = {}
159+ for row in rows :
160+ for text in row ["requires" ]:
161+ try :
162+ requirement = Requirement (text )
163+ except InvalidRequirement :
164+ continue
165+ if not requirement .specifier :
166+ continue
167+ marker = str (requirement .marker ) if requirement .marker else ""
168+ extra = ""
169+ if "extra" in marker :
170+ # markers stringify as: extra == "autopep8"
171+ extra = marker .split ("==" )[- 1 ].strip ().strip (QUOTES ) if "==" in marker else "?"
172+ caps .setdefault (normalize (requirement .name ), []).append (
173+ (row ["name" ], row ["installed" ], requirement .specifier , extra ,
174+ set (requirement .extras )))
175+ return caps
176+
177+
178+ def normalize (name : str ) -> str :
179+ return re .sub (r"[-_.]+" , "-" , name ).lower ()
180+
181+
143182def main (argv : list [str ]) -> int :
144183 parser = argparse .ArgumentParser (description = __doc__ ,
145184 formatter_class = argparse .RawDescriptionHelpFormatter )
@@ -171,11 +210,37 @@ def main(argv: list[str]) -> int:
171210 with ThreadPoolExecutor (max_workers = args .jobs ) as pool :
172211 rows = list (pool .map (lambda kv : inspect (* kv , args .target , now ), installed .items ()))
173212
213+ caps = caps_from (rows )
174214 for row in rows :
175215 if row ["state" ] == "ready" and row ["days" ] < args .min_days :
176216 row ["state" ] = "too new"
177-
178- order = {"security" : 0 , "ready" : 1 , "too new" : 2 , "unknown" : 3 , "current" : 4 }
217+ if row ["state" ] in ("ready" , "security" ) and row ["candidate" ]:
218+ blockers = [(who , ver , spec , extra )
219+ for who , ver , spec , extra , _ in caps .get (normalize (row ["name" ]), [])
220+ if not spec .contains (row ["candidate" ], prereleases = True )]
221+ if blockers :
222+ who , ver , spec , extra = blockers [0 ]
223+ row ["state" ] = "blocked"
224+ via = f"[{ extra } ]" if extra else ""
225+ # one hop further: the capping package is rarely the one to argue
226+ # with -- name whoever asked for it, since that is what must change.
227+ # When the cap is gated on an extra, only a dependant that asked
228+ # for that extra actually triggers it.
229+ asked = [w for w , _ , _ , _ , extras in caps .get (normalize (who ), [])
230+ if w .lower () != row ["name" ].lower ()
231+ and (not extra or extra in extras )]
232+ if asked :
233+ origin = f", pulled in by { asked [0 ]} "
234+ elif extra :
235+ # nothing in the set asks for that extra, so the cap is
236+ # declared but probably inert -- worth checking, not obeying
237+ origin = f", but nothing requests [{ extra } ] -- may not bind"
238+ else :
239+ origin = ""
240+ row ["note" ] = (f"capped by { who } { ver } { via } requiring { row ['name' ]} { spec } { origin } "
241+ + (f" -- { row ['note' ]} " if row ["note" ] else "" ))
242+
243+ order = {"security" : 0 , "ready" : 1 , "blocked" : 2 , "too new" : 3 , "unknown" : 4 , "current" : 5 }
179244 rows .sort (key = lambda r : (order [r ["state" ]], - r ["days" ], r ["name" ]))
180245 groups = {s : [r for r in rows if r ["state" ] == s ] for s in order }
181246
@@ -188,6 +253,7 @@ def main(argv: list[str]) -> int:
188253 "" ]
189254 for state , title in (("security" , "SECURITY -- advisory against the installed version" ),
190255 ("ready" , "READY -- aged, unreplaced, still has a wheel" ),
256+ ("blocked" , "BLOCKED -- something in the set caps it" ),
191257 ("too new" , "TOO NEW -- revisit when aged" ),
192258 ("unknown" , "COULD NOT CHECK" )):
193259 entries = groups [state ]
0 commit comments