fix pypi cannot check after upload

This commit is contained in:
ykcheungab
2026-09-12 17:43:40 +08:00
parent 488c8b0bc1
commit 5f78115429
3 changed files with 237 additions and 16 deletions
+113 -14
View File
@@ -22,6 +22,7 @@ import shutil
import subprocess import subprocess
import sys import sys
import tempfile import tempfile
import time
import urllib.error import urllib.error
import urllib.request import urllib.request
from dataclasses import dataclass, field 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: def index_files(
"""Filenames published for ``version`` on ``index``, or ``None`` if absent.""" 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] url = INDEX_JSON[index]
request = urllib.request.Request( request = urllib.request.Request(
url, headers={"User-Agent": "orisys-release-publish", "Accept": "application/json"} 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: try:
with urllib.request.urlopen(request, timeout=timeout) as response: with urllib.request.urlopen(request, timeout=timeout) as response:
payload = json.load(response) payload = json.load(response)
except Exception: except urllib.error.HTTPError as exc:
return None 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) files = (payload.get("releases") or {}).get(version)
if not isinstance(files, list): if not isinstance(files, list) or not files:
return None return None, None
return sorted(item.get("filename", "") for item in files) 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: def _upload(wheels: list[Path], repository: str | None) -> int:
@@ -288,6 +364,16 @@ def publish(options: Options) -> int:
print("[ok ] twine check passed") print("[ok ] twine check passed")
# --- 3. phase plan ------------------------------------------------- # --- 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() print()
for index in phases: for index in phases:
state = index_state(index) state = index_state(index)
@@ -303,6 +389,11 @@ def publish(options: Options) -> int:
return 1 return 1
print(f"[ok ] {index}: {version} not present (published: {', '.join(state.versions) or 'none'})") 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() print()
for index in phases: for index in phases:
print(f"[plan] upload {len(wheels)} wheel(s) to {index}") print(f"[plan] upload {len(wheels)} wheel(s) to {index}")
@@ -324,14 +415,18 @@ def publish(options: Options) -> int:
return 1 return 1
print(f"[ok ] uploaded to {index}") print(f"[ok ] uploaded to {index}")
published = index_files(index, version) published, error = wait_for_release(index, version)
if published is None: 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( print(
f"[FAIL] {index} does not serve orisys {version} yet; " f"[warn] uploaded, but could not confirm it on {index} yet: {reason}.\n"
"the upload may still be indexing, verify manually" 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 print(f"\n=== release pypi: {index} upload SUCCEEDED (verification inconclusive) ===")
expected = {wheel.name for wheel in wheels} return 0
if set(published) != expected: if set(published) != expected:
print( print(
f"[FAIL] {index} file set mismatch:\n expected {sorted(expected)}\n" 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 options.verify_install:
if _fetch_back(index, version, wheels[0]) != 0: if _fetch_back(index, version, wheels[0]) != 0:
print(f"[FAIL] {index} artifact is not fetchable") print(
return 1 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"]: if phases == ["testpypi"]:
print() print()
+8 -1
View File
@@ -175,9 +175,16 @@ python build/run.py release pypi --version 0.5.2 --pypi-only --apply
1. 认证书校验(与 `release github` 同一份报告) 1. 认证书校验(与 `release github` 同一份报告)
2. `twine check` 全部 wheel(校验 METADATA/long description,上传前就能发现问题) 2. `twine check` 全部 wheel(校验 METADATA/long description,上传前就能发现问题)
3. 只读查询目标索引,确认该版本不存在(TestPyPI 与 PyPI 各查各的) 3. 只读查询目标索引,确认该版本不存在(TestPyPI 与 PyPI 各查各的)
4. 上传 → 回读索引确认 6 个文件都在 → `pip download` 把刚上传的 wheel 拉回来,证明产物可获取 4. 上传 → **轮询**索引确认 6 个文件都在 → `pip download` 把刚上传的 wheel 拉回来,证明产物可获取
5. 未加 `--confirm-pypi` 时只发 TestPyPI,然后停在这里并打印后续命令 5. 未加 `--confirm-pypi` 时只发 TestPyPI,然后停在这里并打印后续命令
两个针对「索引不即时一致」的设计(TestPyPI 尤其明显,上传成功后 JSON API 可能滞后若干秒):
- **上传后的确认会重试**(默认 6 次 × 5 秒)。确认不了也**不算失败**:twine 上传成功就说明产物已发布,命令会打印警告、给出项目页链接并以 0 退出,不会把一个成功的上传报成失败。真正的失败只有两种:twine 上传本身失败,或索引上的文件集与本地不一致。
- **重复运行是安全的**:若索引上已经存在该版本且文件集完全一致,该阶段会被视为「已完成」而跳过(`[ok] testpypi already serves orisys 0.5.2 with all 6 file(s)`),不会重传、也不报错。这让你在确认不明确时可以直接重跑。如果文件集**不一致**(索引上是另一次构建),才会失败并停下。
所以「上传后确认失败」不等于要重新上传——索引版本号不可覆盖,重跑只会做一次只读核对。
### 3.3 凭证:`release auth` 与 `~/.pypirc` ### 3.3 凭证:`release auth` 与 `~/.pypirc`
```powershell ```powershell
+116 -1
View File
@@ -501,7 +501,122 @@ def test_index_files_returns_none_when_version_absent(monkeypatch):
return False return False
monkeypatch.setattr(pypi.urllib.request, "urlopen", lambda *a, **k: Response()) monkeypatch.setattr(pypi.urllib.request, "urlopen", lambda *a, **k: Response())
assert pypi.index_files("pypi", "1.2.3") is None assert pypi.index_files("pypi", "1.2.3") == (None, None)
def test_index_files_distinguishes_lookup_failure_from_absence(monkeypatch):
"""A network hiccup must not be reported as "not published yet"."""
def _raise(*args, **kwargs):
raise urllib.error.URLError("connection reset")
monkeypatch.setattr(pypi.urllib.request, "urlopen", _raise)
filenames, error = pypi.index_files("testpypi", "1.2.3")
assert filenames is None
assert error is not None and "connection reset" in error
def test_index_files_returns_filenames(monkeypatch):
class Response:
def read(self, *args):
return json.dumps(
{"releases": {"1.2.3": [{"filename": "b.whl"}, {"filename": "a.whl"}]}}
).encode()
def __enter__(self):
return self
def __exit__(self, *exc):
return False
monkeypatch.setattr(pypi.urllib.request, "urlopen", lambda *a, **k: Response())
assert pypi.index_files("pypi", "1.2.3") == (["a.whl", "b.whl"], None)
def test_wait_for_release_retries_until_indexing_finishes(monkeypatch):
"""TestPyPI lags the upload response; the check must poll, not give up."""
calls = {"n": 0}
slept: list[float] = []
def _fake(index, version, *, timeout=20.0):
calls["n"] += 1
if calls["n"] < 3:
return None, None
return ["a.whl"], None
monkeypatch.setattr(pypi, "index_files", _fake)
filenames, error = pypi.wait_for_release(
"testpypi", "1.2.3", attempts=5, delay=1.0, sleep=slept.append
)
assert filenames == ["a.whl"]
assert error is None
assert calls["n"] == 3
assert slept == [1.0, 1.0]
def test_wait_for_release_reports_the_last_error(monkeypatch):
def _fake(index, version, *, timeout=20.0):
return None, "HTTP 503"
monkeypatch.setattr(pypi, "index_files", _fake)
filenames, error = pypi.wait_for_release(
"pypi", "1.2.3", attempts=2, delay=0.0, sleep=lambda _: None
)
assert filenames is None
assert error == "HTTP 503"
def test_existing_release_state_satisfied_conflict_absent(monkeypatch, capsys):
expected = {"orisys-1.2.3-cp310-cp310-win_amd64.whl"}
monkeypatch.setattr(pypi, "index_files", lambda i, v, **k: (None, None))
assert pypi.existing_release_state("pypi", "1.2.3", expected) == "absent"
monkeypatch.setattr(pypi, "index_files", lambda i, v, **k: (sorted(expected), None))
assert pypi.existing_release_state("pypi", "1.2.3", expected) == "satisfied"
monkeypatch.setattr(
pypi, "index_files", lambda i, v, **k: (["orisys-1.2.3-cp311-cp311-win_amd64.whl"], None)
)
assert pypi.existing_release_state("pypi", "1.2.3", expected) == "conflict"
output = capsys.readouterr().out
assert "different file set" in output and "missing" in output
def test_publish_rerun_after_success_is_not_a_failure(tmp_path, monkeypatch, capsys):
"""Re-running a finished phase must succeed, not look like a new error."""
sdk, wheel = _synthetic_report_layout(tmp_path, "1.2.3")
monkeypatch.setattr(pypi, "REPO_ROOT", sdk)
from release import auth
auth.write_token("pypi", "pypi-" + "A" * 30, path=tmp_path / ".pypirc")
monkeypatch.setattr(auth, "pypirc_path", lambda: tmp_path / ".pypirc")
monkeypatch.setattr(
pypi, "index_files", lambda i, v, **k: ([wheel.name], None)
)
exit_code = pypi.publish(
pypi.Options(version="1.2.3", apply=True, pypi_only=True, skip_check=True)
)
output = capsys.readouterr().out
assert exit_code == 0
assert "already serves orisys 1.2.3" in output
assert "every requested index already has this release" in output
def test_publish_aborts_on_a_conflicting_existing_release(tmp_path, monkeypatch, capsys):
sdk, wheel = _synthetic_report_layout(tmp_path, "1.2.3")
monkeypatch.setattr(pypi, "REPO_ROOT", sdk)
from release import auth
auth.write_token("pypi", "pypi-" + "A" * 30, path=tmp_path / ".pypirc")
monkeypatch.setattr(auth, "pypirc_path", lambda: tmp_path / ".pypirc")
monkeypatch.setattr(pypi, "index_files", lambda i, v, **k: (["other.whl"], None))
exit_code = pypi.publish(
pypi.Options(version="1.2.3", apply=True, pypi_only=True, skip_check=True)
)
assert exit_code == 1
assert "different file set" in capsys.readouterr().out
def test_index_json_urls_cover_both_indexes(): def test_index_json_urls_cover_both_indexes():