fix pypi cannot check after upload
This commit is contained in:
+113
-14
@@ -22,6 +22,7 @@ import shutil
|
||||
import subprocess
|
||||
import sys
|
||||
import tempfile
|
||||
import time
|
||||
import urllib.error
|
||||
import urllib.request
|
||||
from dataclasses import dataclass, field
|
||||
@@ -182,8 +183,18 @@ def index_state(index: str, *, timeout: float = 20.0) -> IndexState:
|
||||
)
|
||||
|
||||
|
||||
def index_files(index: str, version: str, *, timeout: float = 20.0) -> list[str] | None:
|
||||
"""Filenames published for ``version`` on ``index``, or ``None`` if absent."""
|
||||
def index_files(
|
||||
index: str, version: str, *, timeout: float = 20.0
|
||||
) -> tuple[list[str] | None, str | None]:
|
||||
"""Filenames published for ``version``, as ``(filenames, error)``.
|
||||
|
||||
``(None, None)`` - the index does not serve this version (yet)
|
||||
``(None, "why")`` - the lookup itself failed, which is NOT evidence of absence
|
||||
``([...], None)`` - the version is published with those files
|
||||
|
||||
Keeping those apart matters: reporting a network hiccup as "not published
|
||||
yet" is how a successful upload gets mistaken for a failure.
|
||||
"""
|
||||
url = INDEX_JSON[index]
|
||||
request = urllib.request.Request(
|
||||
url, headers={"User-Agent": "orisys-release-publish", "Accept": "application/json"}
|
||||
@@ -191,12 +202,77 @@ def index_files(index: str, version: str, *, timeout: float = 20.0) -> list[str]
|
||||
try:
|
||||
with urllib.request.urlopen(request, timeout=timeout) as response:
|
||||
payload = json.load(response)
|
||||
except Exception:
|
||||
return None
|
||||
except urllib.error.HTTPError as exc:
|
||||
if exc.code == 404:
|
||||
return None, None
|
||||
return None, f"{index} returned HTTP {exc.code}"
|
||||
except Exception as exc:
|
||||
return None, f"{index} lookup failed: {type(exc).__name__}: {exc}"
|
||||
|
||||
files = (payload.get("releases") or {}).get(version)
|
||||
if not isinstance(files, list):
|
||||
return None
|
||||
return sorted(item.get("filename", "") for item in files)
|
||||
if not isinstance(files, list) or not files:
|
||||
return None, None
|
||||
return sorted(str(item.get("filename", "")) for item in files), None
|
||||
|
||||
|
||||
def wait_for_release(
|
||||
index: str,
|
||||
version: str,
|
||||
*,
|
||||
attempts: int = 6,
|
||||
delay: float = 5.0,
|
||||
sleep=None,
|
||||
timeout: float = 20.0,
|
||||
) -> tuple[list[str] | None, str | None]:
|
||||
"""Poll until the index serves ``version`` (uploads are not instantly visible).
|
||||
|
||||
TestPyPI in particular lags the upload response by seconds. Returns the last
|
||||
``(filenames, error)`` observed.
|
||||
"""
|
||||
sleep = sleep or time.sleep
|
||||
last_error: str | None = None
|
||||
for attempt in range(1, max(1, attempts) + 1):
|
||||
filenames, error = index_files(index, version, timeout=timeout)
|
||||
if filenames is not None:
|
||||
return filenames, None
|
||||
last_error = error
|
||||
if attempt < attempts:
|
||||
print(
|
||||
f"[info] {index} does not serve {version} yet "
|
||||
f"({'lookup failed' if error else 'indexing'}), retry {attempt}/{attempts - 1}",
|
||||
flush=True,
|
||||
)
|
||||
sleep(delay)
|
||||
return None, last_error
|
||||
|
||||
|
||||
def existing_release_state(index: str, version: str, expected: set[str]) -> str:
|
||||
"""``"satisfied"`` | ``"conflict"`` | ``"absent"`` for this index and version.
|
||||
|
||||
An index version can never be replaced, so "already published with exactly
|
||||
these files" is the only successful terminal state; treating it as an error
|
||||
would make a re-run after a partial failure look like a new failure. A
|
||||
different file set is a genuine conflict and must stop the run.
|
||||
"""
|
||||
published, error = index_files(index, version)
|
||||
if published is None:
|
||||
if error:
|
||||
print(f"[warn] could not check {index} for an existing release: {error}")
|
||||
return "absent"
|
||||
if set(published) == expected:
|
||||
print(
|
||||
f"[ok ] {index} already serves orisys {version} with all "
|
||||
f"{len(published)} file(s)"
|
||||
)
|
||||
return "satisfied"
|
||||
extra = sorted(set(published) - expected)
|
||||
missing = sorted(expected - set(published))
|
||||
print(
|
||||
f"[FAIL] {index} already serves orisys {version} with a different file set"
|
||||
+ (f"; unexpected: {extra}" if extra else "")
|
||||
+ (f"; missing: {missing}" if missing else "")
|
||||
)
|
||||
return "conflict"
|
||||
|
||||
|
||||
def _upload(wheels: list[Path], repository: str | None) -> int:
|
||||
@@ -288,6 +364,16 @@ def publish(options: Options) -> int:
|
||||
print("[ok ] twine check passed")
|
||||
|
||||
# --- 3. phase plan -------------------------------------------------
|
||||
expected = {wheel.name for wheel in wheels}
|
||||
remaining: list[str] = []
|
||||
for index in phases:
|
||||
state = existing_release_state(index, version, expected)
|
||||
if state == "conflict":
|
||||
return 1
|
||||
if state == "absent":
|
||||
remaining.append(index)
|
||||
phases = remaining
|
||||
|
||||
print()
|
||||
for index in phases:
|
||||
state = index_state(index)
|
||||
@@ -303,6 +389,11 @@ def publish(options: Options) -> int:
|
||||
return 1
|
||||
print(f"[ok ] {index}: {version} not present (published: {', '.join(state.versions) or 'none'})")
|
||||
|
||||
if not phases:
|
||||
print()
|
||||
print("=== release pypi: every requested index already has this release ===")
|
||||
return 0
|
||||
|
||||
print()
|
||||
for index in phases:
|
||||
print(f"[plan] upload {len(wheels)} wheel(s) to {index}")
|
||||
@@ -324,14 +415,18 @@ def publish(options: Options) -> int:
|
||||
return 1
|
||||
print(f"[ok ] uploaded to {index}")
|
||||
|
||||
published = index_files(index, version)
|
||||
published, error = wait_for_release(index, version)
|
||||
if published is None:
|
||||
# The upload itself succeeded, so this is a confirmation problem, not
|
||||
# a publishing failure. Say so plainly and do not imply data loss.
|
||||
reason = f"lookup kept failing ({error})" if error else "the index has not indexed it yet"
|
||||
print(
|
||||
f"[FAIL] {index} does not serve orisys {version} yet; "
|
||||
"the upload may still be indexing, verify manually"
|
||||
f"[warn] uploaded, but could not confirm it on {index} yet: {reason}.\n"
|
||||
f" check https://{'test.' if index == 'testpypi' else ''}pypi.org/project/orisys/{version}/"
|
||||
"\n re-running this command is safe: an already-published version is skipped"
|
||||
)
|
||||
return 1
|
||||
expected = {wheel.name for wheel in wheels}
|
||||
print(f"\n=== release pypi: {index} upload SUCCEEDED (verification inconclusive) ===")
|
||||
return 0
|
||||
if set(published) != expected:
|
||||
print(
|
||||
f"[FAIL] {index} file set mismatch:\n expected {sorted(expected)}\n"
|
||||
@@ -342,8 +437,12 @@ def publish(options: Options) -> int:
|
||||
|
||||
if options.verify_install:
|
||||
if _fetch_back(index, version, wheels[0]) != 0:
|
||||
print(f"[FAIL] {index} artifact is not fetchable")
|
||||
return 1
|
||||
print(
|
||||
f"[warn] {index} artifact is not fetchable yet; the upload is "
|
||||
"published, so this is a propagation delay rather than a failure"
|
||||
)
|
||||
else:
|
||||
print(f"[ok ] fetched the published artifact back from {index}")
|
||||
|
||||
if phases == ["testpypi"]:
|
||||
print()
|
||||
|
||||
Reference in New Issue
Block a user