300 lines
10 KiB
Python
300 lines
10 KiB
Python
"""Upload the certified wheels to TestPyPI, then (on confirmation) to PyPI.
|
|
|
|
``python build/run.py release pypi`` runs:
|
|
|
|
1. certified-wheels gate (same contract as ``release github``)
|
|
2. ``twine check`` on every wheel - the wheel metadata is generated, so its
|
|
long description and classifiers are worth validating before an upload that
|
|
cannot be undone
|
|
3. refuse if the version already exists on the target index (read-only probe)
|
|
4. upload to TestPyPI, then verify by fetching it back from the index
|
|
5. stop there unless ``--confirm-pypi`` is given; then repeat 3-4 against PyPI
|
|
|
|
PyPI uploads are permanent: a version number can never be reused, and a bad
|
|
artifact cannot be replaced. Hence the TestPyPI stage, the read-only existence
|
|
probe, and ``--apply`` being required even to reach TestPyPI.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import json
|
|
import shutil
|
|
import subprocess
|
|
import sys
|
|
import tempfile
|
|
import urllib.error
|
|
import urllib.request
|
|
from dataclasses import dataclass, field
|
|
from pathlib import Path
|
|
|
|
_BUILD_ROOT = Path(__file__).resolve().parents[1]
|
|
if str(_BUILD_ROOT) not in sys.path:
|
|
sys.path.insert(0, str(_BUILD_ROOT))
|
|
|
|
from common.paths import REPO_ROOT # noqa: E402
|
|
from release.publish import GateResult, PublishGateError, load_gate # noqa: E402
|
|
|
|
INDEX_JSON = {
|
|
"testpypi": "https://test.pypi.org/pypi/orisys/json",
|
|
"pypi": "https://pypi.org/pypi/orisys/json",
|
|
}
|
|
INDEX_SIMPLE = {
|
|
"testpypi": "https://test.pypi.org/simple/",
|
|
"pypi": "https://pypi.org/simple/",
|
|
}
|
|
|
|
|
|
@dataclass
|
|
class Options:
|
|
version: str
|
|
apply: bool = False
|
|
confirm_pypi: bool = False
|
|
repository: str = "testpypi"
|
|
verify_install: bool = True
|
|
skip_check: bool = False
|
|
|
|
|
|
@dataclass
|
|
class IndexState:
|
|
versions: list[str] = field(default_factory=list)
|
|
error: str | None = None
|
|
|
|
@property
|
|
def known(self) -> bool:
|
|
return self.error is None
|
|
|
|
|
|
def twine_check_argv(wheels: list[Path]) -> list[str]:
|
|
return [sys.executable, "-m", "twine", "check", *[str(w) for w in wheels]]
|
|
|
|
|
|
def twine_upload_argv(wheels: list[Path], repository: str | None) -> list[str]:
|
|
argv = [sys.executable, "-m", "twine", "upload"]
|
|
if repository:
|
|
argv += ["--repository", repository]
|
|
argv += [str(wheel) for wheel in wheels]
|
|
return argv
|
|
|
|
|
|
def credentials_hint() -> str | None:
|
|
"""Return guidance when twine has no obvious credentials, else ``None``.
|
|
|
|
twine itself reads ``~/.pypirc`` or ``TWINE_USERNAME``/``TWINE_PASSWORD``;
|
|
failing here is much better than failing halfway through an upload.
|
|
"""
|
|
import os
|
|
|
|
if os.environ.get("TWINE_PASSWORD") or os.environ.get("TWINE_USERNAME"):
|
|
return None
|
|
pypirc = Path.home() / ".pypirc"
|
|
if pypirc.is_file():
|
|
return None
|
|
return (
|
|
"no PyPI credentials found: create ~/.pypirc with [testpypi] and [pypi] "
|
|
"sections, or set TWINE_USERNAME=__token__ and TWINE_PASSWORD=<token>. "
|
|
"Never commit the token."
|
|
)
|
|
|
|
|
|
def index_state(index: str, *, timeout: float = 20.0) -> IndexState:
|
|
"""Read the published versions of orisys from an index (read-only)."""
|
|
url = INDEX_JSON[index]
|
|
request = urllib.request.Request(
|
|
url, headers={"User-Agent": "orisys-release-publish", "Accept": "application/json"}
|
|
)
|
|
try:
|
|
with urllib.request.urlopen(request, timeout=timeout) as response:
|
|
payload = json.load(response)
|
|
except urllib.error.HTTPError as exc:
|
|
if exc.code == 404:
|
|
return IndexState(versions=[])
|
|
return IndexState(error=f"{index} returned HTTP {exc.code}")
|
|
except Exception as exc: # network, DNS, TLS, JSON
|
|
return IndexState(error=f"{index} lookup failed: {type(exc).__name__}: {exc}")
|
|
|
|
releases = payload.get("releases")
|
|
if not isinstance(releases, dict):
|
|
return IndexState(error=f"unexpected {index} response (no 'releases' mapping)")
|
|
return IndexState(
|
|
versions=sorted(name for name, files in releases.items() if files)
|
|
)
|
|
|
|
|
|
def index_files(index: str, version: str, *, timeout: float = 20.0) -> list[str] | None:
|
|
"""Filenames published for ``version`` on ``index``, or ``None`` if absent."""
|
|
url = INDEX_JSON[index]
|
|
request = urllib.request.Request(
|
|
url, headers={"User-Agent": "orisys-release-publish", "Accept": "application/json"}
|
|
)
|
|
try:
|
|
with urllib.request.urlopen(request, timeout=timeout) as response:
|
|
payload = json.load(response)
|
|
except Exception:
|
|
return None
|
|
files = (payload.get("releases") or {}).get(version)
|
|
if not isinstance(files, list):
|
|
return None
|
|
return sorted(item.get("filename", "") for item in files)
|
|
|
|
|
|
def _upload(wheels: list[Path], repository: str | None) -> int:
|
|
argv = twine_upload_argv(wheels, repository)
|
|
print(f"[run ] {' '.join(str(part) for part in argv)}")
|
|
completed = subprocess.run(argv, check=False)
|
|
return completed.returncode
|
|
|
|
|
|
def _fetch_back(index: str, version: str, wheel: Path) -> int:
|
|
"""Download the freshly uploaded wheel back out of the index."""
|
|
with tempfile.TemporaryDirectory(prefix="orisys-fetch-") as tmp:
|
|
target = Path(tmp) / "out"
|
|
argv = [
|
|
sys.executable,
|
|
"-m",
|
|
"pip",
|
|
"download",
|
|
"--no-deps",
|
|
"--no-cache-dir",
|
|
"--index-url",
|
|
INDEX_SIMPLE[index],
|
|
"--dest",
|
|
str(target),
|
|
f"orisys=={version}",
|
|
]
|
|
if index == "testpypi":
|
|
# TestPyPI mirrors only what was uploaded there; deps live on PyPI.
|
|
# --no-deps keeps this a pure "is the artifact fetchable" probe.
|
|
argv += ["--extra-index-url", INDEX_SIMPLE["pypi"]]
|
|
print(f"[run ] {' '.join(argv)}")
|
|
completed = subprocess.run(argv, check=False)
|
|
if completed.returncode != 0:
|
|
return completed.returncode
|
|
|
|
fetched = sorted(target.glob(f"orisys-{version}*"))
|
|
if not fetched:
|
|
print(f"[FAIL] pip download produced nothing for orisys=={version}")
|
|
return 1
|
|
print(f"[ok ] fetched {[p.name for p in fetched]} from {index}")
|
|
if wheel.name not in {path.name for path in fetched}:
|
|
print(
|
|
f"[warn] {wheel.name} was not the file fetched "
|
|
"(the index may serve a different platform first)"
|
|
)
|
|
return 0
|
|
|
|
|
|
def publish(options: Options) -> int:
|
|
version = options.version
|
|
mode = "APPLY" if options.apply else "DRY RUN"
|
|
print(f"=== release pypi: orisys {version} ({mode}) ===")
|
|
|
|
# --- 1. gate -------------------------------------------------------
|
|
try:
|
|
gate: GateResult = load_gate(REPO_ROOT, version)
|
|
except PublishGateError as exc:
|
|
print(f"[FAIL] {exc}")
|
|
return 1
|
|
print(f"[ok ] preflight report: {gate.report_path}")
|
|
for wheel in gate.wheels:
|
|
print(f" [ok] {wheel.name} sha256={wheel.sha256[:12]}")
|
|
for warning in gate.warnings:
|
|
print(f"[warn] {warning}")
|
|
|
|
wheels = [REPO_ROOT / "SDK" / version / record.name for record in gate.wheels]
|
|
missing = [str(wheel) for wheel in wheels if not wheel.is_file()]
|
|
if missing:
|
|
print(f"[FAIL] wheels missing from disk: {missing}")
|
|
return 1
|
|
|
|
# --- 2. credentials + twine check (before anything irreversible) ----
|
|
# Dry runs are previews, so missing credentials warn there and stop only when
|
|
# an upload is actually about to happen.
|
|
hint = credentials_hint()
|
|
if hint:
|
|
print(f"[{'FAIL' if options.apply else 'warn'}] {hint}")
|
|
if options.apply:
|
|
return 1
|
|
else:
|
|
print("[ok ] PyPI credentials are configured (twine will read them)")
|
|
|
|
if not options.skip_check:
|
|
argv = twine_check_argv(wheels)
|
|
print(f"[run ] {' '.join(str(part) for part in argv)}")
|
|
completed = subprocess.run(argv, check=False)
|
|
if completed.returncode != 0:
|
|
print("[FAIL] twine check reported problems; fix the wheel metadata first")
|
|
return 1
|
|
print("[ok ] twine check passed")
|
|
|
|
# --- 3. phase plan -------------------------------------------------
|
|
phases = ["testpypi"]
|
|
if options.confirm_pypi:
|
|
phases.append("pypi")
|
|
|
|
print()
|
|
for index in phases:
|
|
state = index_state(index)
|
|
if not state.known:
|
|
print(f"[FAIL] {state.error}; cannot safely check for duplicate uploads")
|
|
return 1
|
|
if version in state.versions:
|
|
print(
|
|
f"[FAIL] orisys {version} already exists on {index} "
|
|
f"(published: {', '.join(state.versions) or 'none'}) - "
|
|
"an index version cannot be reused"
|
|
)
|
|
return 1
|
|
print(f"[ok ] {index}: {version} not present (published: {', '.join(state.versions) or 'none'})")
|
|
|
|
print()
|
|
for index in phases:
|
|
print(f"[plan] upload {len(wheels)} wheel(s) to {index}")
|
|
if "pypi" not in phases:
|
|
print(
|
|
"[plan] stop after TestPyPI. Re-run with "
|
|
"`--confirm-pypi --apply` to publish to the real PyPI."
|
|
)
|
|
print()
|
|
|
|
if not options.apply:
|
|
print("dry run: nothing was uploaded. Re-run with --apply to execute.")
|
|
return 0
|
|
|
|
# --- 4. upload ----------------------------------------------------
|
|
for index in phases:
|
|
if _upload(wheels, index) != 0:
|
|
print(f"[FAIL] twine upload to {index} failed")
|
|
return 1
|
|
print(f"[ok ] uploaded to {index}")
|
|
|
|
published = index_files(index, version)
|
|
if published is None:
|
|
print(
|
|
f"[FAIL] {index} does not serve orisys {version} yet; "
|
|
"the upload may still be indexing, verify manually"
|
|
)
|
|
return 1
|
|
expected = {wheel.name for wheel in wheels}
|
|
if set(published) != expected:
|
|
print(
|
|
f"[FAIL] {index} file set mismatch:\n expected {sorted(expected)}\n"
|
|
f" got {sorted(published)}"
|
|
)
|
|
return 1
|
|
print(f"[ok ] {index} serves all {len(published)} file(s) for {version}")
|
|
|
|
if options.verify_install:
|
|
if _fetch_back(index, version, wheels[0]) != 0:
|
|
print(f"[FAIL] {index} artifact is not fetchable")
|
|
return 1
|
|
|
|
if "pypi" not in phases:
|
|
print()
|
|
print("=== release pypi: TestPyPI stage PASSED; PyPI untouched ===")
|
|
print("next: python build/run.py release pypi --confirm-pypi --apply")
|
|
return 0
|
|
|
|
print()
|
|
print("=== release pypi PASSED (TestPyPI + PyPI) ===")
|
|
return 0
|