"""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 import auth # noqa: E402 from release.publish import GateResult, PublishGateError, load_gate # noqa: E402 INDEX_JSON = {name: str(meta["json_url"]) for name, meta in auth.INDEXES.items()} INDEX_SIMPLE = {name: str(meta["simple_url"]) for name, meta in auth.INDEXES.items()} @dataclass class Options: version: str apply: bool = False confirm_pypi: bool = False verify_install: bool = True skip_check: bool = False # Upload straight to PyPI, skipping the TestPyPI rehearsal. Needed when you # only hold a production token, since the two indexes reject each other's. pypi_only: bool = False # Offer to enter a missing token now instead of just failing. prompt_credentials: bool = False # Token supplied non-interactively (e.g. --token-stdin), used once. token: str | None = None def upload_phases(options: Options) -> list[str]: """The indexes to upload to, in order.""" if options.pypi_only: return ["pypi"] return ["testpypi", "pypi"] if options.confirm_pypi else ["testpypi"] @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(index: str) -> str | None: """Guidance when ``index`` has no usable credentials, else ``None``. More than "does the file exist": a placeholder or the wrong ``username`` is treated as missing, because twine would only find out at upload time. """ state = auth.credential_state(index) if state.configured: return None lines = [f"no usable credentials for [{index}]: {state.problem}"] lines.append(f" set it up with: python build/run.py release auth --set-token {index}") if index == "testpypi": lines.append( " (TestPyPI needs its own account and token; if you only hold a " "production token, use --pypi-only to skip the rehearsal)" ) lines.append( " or set TWINE_USERNAME=__token__ and TWINE_PASSWORD for this session only" ) return "\n".join(lines) def _ensure_credentials( indexes: list[str], options: Options, *, interactive: bool | None = None ) -> bool: """Make sure every index that will be uploaded to has credentials. Prompts for a missing token when there is a terminal to prompt on; a piped or CI run fails fast instead of hanging on a hidden-input prompt. """ missing = [index for index in indexes if not auth.credential_state(index).configured] if not missing: for index in indexes: state = auth.credential_state(index) print(f"[ok ] credentials for [{index}] ({state.source})") return True if interactive is None: interactive = sys.stdin.isatty() allow_prompt = options.prompt_credentials or (interactive and options.apply) if not allow_prompt: for index in missing: print(f"[{'FAIL' if options.apply else 'warn'}] {credentials_hint(index)}") if not options.apply: print( "[info] dry run continues so you can review the plan; " "--apply would stop here until the credentials exist" ) return True if not interactive: print( "[info] stdin is not a terminal, so nothing was prompted; run " "`release auth --set-token ` or pass --prompt-credentials" ) return False for index in missing: print(f"[warn] {credentials_hint(index)}") token = options.token or auth.prompt_for_token(index) if not token: print(f"[FAIL] no token entered for [{index}]") return False for problem in auth.validate_token(token): print(f"[FAIL] {problem.removeprefix('ERROR ')}") return False for warning in auth.token_warnings(token): print(f"[warn] {warning.removeprefix('WARN ')}") path = auth.write_token(index, token) print(f"[ok ] wrote [{index}] credentials to {path}") options.token = None # only the first index may consume a piped token return True 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) ---- phases = upload_phases(options) if options.pypi_only: print( "[warn] --pypi-only: uploading straight to PyPI with no TestPyPI " "rehearsal. An index version can never be reused or replaced." ) if not _ensure_credentials(phases, options): return 1 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 ------------------------------------------------- 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 phases == ["testpypi"]: 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 phases == ["testpypi"]: print() print("=== release pypi: TestPyPI stage PASSED; PyPI untouched ===") print("next: python build/run.py release pypi --confirm-pypi --apply") return 0 print() if options.pypi_only: print(f"=== release pypi PASSED (PyPI only, no TestPyPI rehearsal) ===") else: print("=== release pypi PASSED (TestPyPI + PyPI) ===") return 0