Skip to content

Commit ff21b23

Browse files
committed
check_updates: do not propose a bump the set already forbids
Each package's own metadata carries the caps, extras included, and that is the kind hardest to see by hand: absent from constraints.txt, from pip list -o, and from a reverse tree evaluated with no extra set. Report them as blocked, naming the capping package, whoever asked for it, and whether the gating extra is requested at all.
1 parent 9868c5f commit ff21b23

1 file changed

Lines changed: 69 additions & 3 deletions

File tree

‎check_updates.py‎

Lines changed: 69 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,7 @@
3434

3535
PYPI = "https://pypi.org/pypi"
3636
TIMEOUT = 30
37+
QUOTES = "\"'"
3738
PRERELEASE = 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

102103
def 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+
143182
def 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

Comments
 (0)