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 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()
+8 -1
View File
@@ -175,9 +175,16 @@ python build/run.py release pypi --version 0.5.2 --pypi-only --apply
1. 认证书校验(与 `release github` 同一份报告)
2. `twine check` 全部 wheel(校验 METADATA/long description,上传前就能发现问题)
3. 只读查询目标索引,确认该版本不存在(TestPyPI 与 PyPI 各查各的)
4. 上传 → 回读索引确认 6 个文件都在 → `pip download` 把刚上传的 wheel 拉回来,证明产物可获取
4. 上传 → **轮询**索引确认 6 个文件都在 → `pip download` 把刚上传的 wheel 拉回来,证明产物可获取
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`
```powershell
+116 -1
View File
@@ -501,7 +501,122 @@ def test_index_files_returns_none_when_version_absent(monkeypatch):
return False
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():