2929- **UNKNOWN** — the library isn't checked out / carries no release tags, so the
3030 newest release can't be resolved; surfaced as caution, never a hard block.
3131
32- Not covered here (deeper, non-tick checks own it): whether the floor names a
33- release that was later *yanked* on PyPI — that needs the PyPI API, not git tags.
32+ The yank gap is owned by the **deep PyPI leg** (``--pypi``): git tags cannot see
33+ a release being *yanked* on PyPI after the fact, so ``--pypi`` asks the PyPI
34+ JSON API whether each floor still names an installable (non-yanked) release and
35+ whether *any* installable release satisfies it. Network — never part of the
36+ tick; run it on demand or from a nightly. Its statuses:
37+
38+ - **UNSATISFIABLE** — no installable release >= floor exists on PyPI (every
39+ candidate yanked): same defect class as the tag-based UNSATISFIABLE.
40+ - **FLOOR_YANKED** — the floor version itself is yanked/absent but a newer
41+ installable release satisfies it; floors are ``>=`` so installs still
42+ resolve — warn, fix by bumping the floor.
43+ - **OK** / **BAD** / **UNKNOWN** — as above; UNKNOWN covers PyPI unreachable
44+ (offline is caution, never a false hard block).
45+
3446An informational "floor lags far behind newest" signal is a possible future add.
3547
36- The result lands at ``$HEART_STATE_DIR/version_skew.json``.
48+ The tick result lands at ``$HEART_STATE_DIR/version_skew.json``; the ``--pypi``
49+ leg at ``version_skew_pypi.json`` (a sibling file, so the tick never clobbers
50+ on-demand evidence).
3751"""
3852
3953from __future__ import annotations
@@ -149,20 +163,103 @@ def run(root: Path = PYAUTO_ROOT) -> dict[str, Any]:
149163 return {"workspaces" : workspaces }
150164
151165
166+ # --------------------------------------------------------------------------- #
167+ # Deep PyPI leg (``--pypi``) — yank-awareness. Network; never run from the tick.
168+ # --------------------------------------------------------------------------- #
169+
170+ PYPI_URL = "https://pypi.org/pypi/{package}/json"
171+ PYPI_TIMEOUT_S = 10
172+
173+
174+ def fetch_pypi_releases (package : str ) -> dict [str , list ] | None :
175+ """The package's ``releases`` map from the PyPI JSON API, or None when the
176+ API is unreachable/unparseable — offline must degrade to UNKNOWN, never a
177+ false hard block."""
178+ import urllib .request
179+
180+ try :
181+ with urllib .request .urlopen (
182+ PYPI_URL .format (package = package ), timeout = PYPI_TIMEOUT_S
183+ ) as resp :
184+ data = json .load (resp )
185+ except Exception :
186+ return None
187+ releases = data .get ("releases" )
188+ return releases if isinstance (releases , dict ) else None
189+
190+
191+ def _installable (files : list ) -> bool :
192+ """A release is installable iff at least one of its files is not yanked
193+ (PyPI marks yank per file; a fileless release installs nothing)."""
194+ return any (isinstance (f , dict ) and not f .get ("yanked" ) for f in files or [])
195+
196+
197+ def pypi_floor_status (floor : str | None , releases : dict [str , list ] | None ) -> str :
198+ """OK / FLOOR_YANKED / UNSATISFIABLE / UNKNOWN / BAD for one floor vs PyPI.
199+
200+ Floors are ``>=`` bounds, so a yanked floor with a newer installable
201+ release still resolves at install time — that is FLOOR_YANKED (fix by
202+ bumping the floor to an installable release), not a hard block. No
203+ installable release >= floor at all is the same defect class as the
204+ tag-based UNSATISFIABLE.
205+ """
206+ if releases is None :
207+ return "UNKNOWN"
208+ ft = _tuple (floor or "" )
209+ if ft is None :
210+ return "BAD"
211+ installable = {
212+ v .strip ()
213+ for v , files in releases .items ()
214+ if _TAG_RE .match (v .strip ()) and _installable (files )
215+ }
216+ if not any (_tuple (v ) >= ft for v in installable ):
217+ return "UNSATISFIABLE"
218+ return "OK" if (floor or "" ).strip () in installable else "FLOOR_YANKED"
219+
220+
221+ def run_pypi (root : Path = PYAUTO_ROOT ) -> dict [str , Any ]:
222+ """Side-effect-free like run(): one PyPI fetch per distinct package, one
223+ entry per floored workspace."""
224+ releases_by_package : dict [str , dict [str , list ] | None ] = {}
225+ workspaces = []
226+ for workspace , (repo , pkg ) in workspace_library ().items ():
227+ floor = read_workspace_floor (workspace , root )
228+ if floor is None :
229+ continue # no floor recorded → not a candidate
230+ if pkg not in releases_by_package :
231+ releases_by_package [pkg ] = fetch_pypi_releases (pkg )
232+ workspaces .append (
233+ {
234+ "workspace" : workspace ,
235+ "library" : repo ,
236+ "package" : pkg ,
237+ "floor" : floor ,
238+ "status" : pypi_floor_status (floor , releases_by_package [pkg ]),
239+ }
240+ )
241+ return {"workspaces" : workspaces }
242+
243+
152244def main (argv : list [str ]) -> int :
153- result = run ()
245+ pypi = "--pypi" in argv
246+ result = run_pypi () if pypi else run ()
154247 sys .path .insert (0 , str (HEART_HOME ))
155248 from heart import state
156249
157- # Persist only here, at the tick/CLI entrypoint — run() is side-effect-free
158- # so library callers (and the test suite) can never clobber live state.
159- state .atomic_write_json (HEART_STATE_DIR / "version_skew.json" , result )
250+ # Persist only here, at the tick/CLI entrypoint — run()/run_pypi() are
251+ # side-effect-free so library callers (and the test suite) can never
252+ # clobber live state. The --pypi leg gets its own sidecar so the tick's
253+ # version_skew.json rewrite never clobbers on-demand PyPI evidence.
254+ name = "version_skew_pypi.json" if pypi else "version_skew.json"
255+ state .atomic_write_json (HEART_STATE_DIR / name , result )
160256
161257 from heart .heart_color import c_ok , c_warn , c_fail , c_info , c_meta , glyph_ok , glyph_warn , glyph_fail
162258
163259 workspaces = result ["workspaces" ]
164260 unsatisfiable = [w for w in workspaces if w ["status" ] == "UNSATISFIABLE" ]
165261 bad = [w for w in workspaces if w ["status" ] == "BAD" ]
262+ yanked = [w for w in workspaces if w ["status" ] == "FLOOR_YANKED" ]
166263 unknown = [w for w in workspaces if w ["status" ] == "UNKNOWN" ]
167264 blocking = unsatisfiable + bad # release-blocking statuses
168265 if blocking :
@@ -173,13 +270,19 @@ def main(argv: list[str]) -> int:
173270 if bad :
174271 parts .append (c_warn (f"{ len (bad )} bad" ))
175272 label = " " .join (parts )
176- elif unknown :
273+ elif yanked or unknown :
177274 glyph = glyph_warn ()
178- label = c_warn (f"{ len (unknown )} unknown" )
275+ parts = []
276+ if yanked :
277+ parts .append (c_warn (f"{ len (yanked )} floor yanked" ))
278+ if unknown :
279+ parts .append (c_warn (f"{ len (unknown )} unknown" ))
280+ label = " " .join (parts )
179281 else :
180282 glyph = glyph_ok ()
181283 label = c_ok (f"{ len (workspaces )} floors satisfiable" )
182- print (f"{ glyph } { c_info ('version_skew' )} { label } { c_meta (f'({ len (workspaces )} floors)' )} " )
284+ check_name = "version_skew --pypi" if pypi else "version_skew"
285+ print (f"{ glyph } { c_info (check_name )} { label } { c_meta (f'({ len (workspaces )} floors)' )} " )
183286 return 0
184287
185288
0 commit comments