"""Command line for ``python build/run.py release``. Commands: ``preflight`` (local gate), ``audit`` (single wheel), ``auth`` (PyPI credentials in ~/.pypirc), ``github`` (sync the public repo + publish a GitHub Release), ``pypi`` (TestPyPI then PyPI). The publish commands are dry-run unless ``--apply`` is passed, and tokens are never accepted as command-line arguments. """ from __future__ import annotations import argparse import sys 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 common.verify.wheel import ( # noqa: E402 DEFAULT_RUNTIME_CONFIG, default_verification_video, verify_wheel, verify_wheel_assets, verify_wheel_runtime, ) from common.verify.wheel_contents import inspect_wheel # noqa: E402 from release import auth, github, pypi # noqa: E402 from release.preflight import PreflightOptions, run_preflight # noqa: E402 def _cmd_preflight(argv: list[str]) -> int: parser = argparse.ArgumentParser( prog="build/run.py release preflight", description="Gate a release locally: version, tests, wheel content, runtime.", ) parser.add_argument("--version", help="release version (default: pyproject's dynamic version)") parser.add_argument("--wheel-dir", type=Path, help="directory to inspect (default: SDK/)") parser.add_argument("--video", type=Path, help="clip for the end-to-end runtime check") parser.add_argument("--config", default=DEFAULT_RUNTIME_CONFIG, help="config name for that check") parser.add_argument("--frames", type=int, default=40, help="frames to process (default: 40)") parser.add_argument("--skip-tests", action="store_true", help="skip pytest (recorded in the report)") parser.add_argument("--longrun", action="store_true", help="include the slow long-run stability test") parser.add_argument("--allow-dirty", action="store_true", help="allow a dirty working tree") parser.add_argument( "--force", action="store_true", help="accept a version that a git tag already marks as released", ) parser.add_argument("--check-pypi", action="store_true", help="read PyPI and fail if the version exists") parser.add_argument( "--all-wheel-pythons", action="store_true", help="verify cp310/cp311/cp312 wheels, each in its own conda env", ) parser.add_argument("--offline", action="store_true", help="reuse parent-env deps instead of PyPI") parser.add_argument("--no-runtime", action="store_true", help="skip the isolated runtime check") parser.add_argument("--no-assets", action="store_true", help="skip the bundled-asset check") parser.add_argument("--keep-going", action="store_true", help="collect every failure instead of stopping") parser.add_argument("--report-dir", type=Path, help="override build/release/reports") args = parser.parse_args(argv) options = PreflightOptions( version=args.version, wheel_dir=args.wheel_dir, video=args.video, config_name=args.config, frames=args.frames, skip_tests=args.skip_tests, longrun=args.longrun, allow_dirty=args.allow_dirty, force=args.force, check_pypi=args.check_pypi, all_wheel_pythons=args.all_wheel_pythons, offline=args.offline, keep_going=args.keep_going, report_dir=args.report_dir, no_runtime=args.no_runtime, no_assets=args.no_assets, ) return run_preflight(options, repo_root=REPO_ROOT) def _cmd_audit(argv: list[str]) -> int: parser = argparse.ArgumentParser( prog="build/run.py release audit", description="Inspect one built wheel without a release context.", ) parser.add_argument("wheel", type=Path, help="path to an .whl") parser.add_argument("--expected-version", help="assert the wheel reports this version") parser.add_argument( "--strict-bundle", action="store_true", help="treat bundled-file drift from the current source tree as a failure", ) parser.add_argument("--runtime", action="store_true", help="also install and run the wheel") parser.add_argument("--assets", action="store_true", help="also assert bundled assets load") parser.add_argument("--video", type=Path, help="clip for --runtime") parser.add_argument("--config", default=DEFAULT_RUNTIME_CONFIG) parser.add_argument("--frames", type=int, default=40) parser.add_argument("--offline", action="store_true") parser.add_argument( "--python", type=Path, default=None, help="interpreter to build the verification venv from (default: the running one)", ) args = parser.parse_args(argv) wheel = args.wheel if not wheel.is_file(): print(f"[ERROR] wheel not found: {wheel}", file=sys.stderr) return 2 exit_code = 0 result = inspect_wheel( wheel, expected_version=args.expected_version, repo_root=REPO_ROOT, strict_bundle=args.strict_bundle, ) print(result.render()) if not result.ok: exit_code = 1 if args.assets and args.runtime: # One venv for both checks: building it (and downloading dependencies in # online mode) is the expensive part. video = args.video or default_verification_video(REPO_ROOT) if video is None: print("[FAIL] runtime: no verification clip; pass --video or set ORISYS_VERIFY_VIDEO") return 1 try: record = verify_wheel( wheel, video=video, config_name=args.config, frames=args.frames, python_executable=args.python, offline=args.offline, ) print(f"[ok ] {record['imports']}") print(f"[ok ] {record['assets']}") print(f"[ok ] runtime: frames={record['frames']} flow={record['flow_shape']}") except Exception as exc: print(f"[FAIL] isolated verification: {type(exc).__name__}: {exc}") exit_code = 1 return exit_code if args.assets: try: print(verify_wheel_assets(wheel, python_executable=args.python)) except Exception as exc: print(f"[FAIL] bundled assets: {type(exc).__name__}: {exc}") exit_code = 1 if args.runtime: video = args.video or default_verification_video(REPO_ROOT) if video is None: print("[FAIL] runtime: no verification clip; pass --video or set ORISYS_VERIFY_VIDEO") return 1 try: info = verify_wheel_runtime( wheel, video=video, config_name=args.config, frames=args.frames, python_executable=args.python, offline=args.offline, ) print(f"[ok ] runtime: {info}") except Exception as exc: print(f"[FAIL] runtime: {type(exc).__name__}: {exc}") exit_code = 1 return exit_code def _cmd_github(argv: list[str]) -> int: parser = argparse.ArgumentParser( prog="build/run.py release github", description="Sync orisys-sdk-release, tag, and publish a GitHub Release.", ) parser.add_argument("--version", required=True, help="release version, e.g. 0.5.2") parser.add_argument( "--apply", action="store_true", help="actually mutate the release repo, push, and create the release", ) parser.add_argument("--notes-file", type=Path, default=None, help="release notes markdown") parser.add_argument( "--attach-preflight", action="store_true", help="also upload the preflight report as a release asset (contains local paths)", ) parser.add_argument( "--allow-dirty-release", action="store_true", help="proceed even if the release repo has uncommitted changes", ) parser.add_argument("--skip-fetch", action="store_true", help="skip `git fetch --tags`") parser.add_argument( "--skip-checks", action="store_true", help="skip the manifest's downstream checks" ) parser.add_argument( "--skip-release-upload", action="store_true", help="stop after pushing the tag and print the `gh release create` command", ) args = parser.parse_args(argv) return github.publish( github.Options( version=args.version, apply=args.apply, notes_file=args.notes_file, attach_preflight=args.attach_preflight, allow_dirty_release=args.allow_dirty_release, skip_fetch=args.skip_fetch, skip_checks=args.skip_checks, skip_release_upload=args.skip_release_upload, ) ) def _cmd_auth(argv: list[str]) -> int: parser = argparse.ArgumentParser( prog="build/run.py release auth", description=( "Inspect or set up PyPI credentials in ~/.pypirc. " "Tokens are read from a hidden prompt or stdin, never from argv." ), ) parser.add_argument( "--set-token", choices=sorted(auth.INDEXES), default=None, help="store a token for this index (prompts unless --token-stdin)", ) parser.add_argument( "--token-stdin", action="store_true", help="read the token from stdin instead of prompting (for scripts/CI)", ) parser.add_argument( "--repair", action="store_true", help=( "add existing sections to [distutils] index-servers; twine ignores " "sections that are not listed there" ), ) parser.add_argument( "--index", choices=sorted(auth.INDEXES), default="pypi", help="index to report on when not setting a token (default: pypi)", ) args = parser.parse_args(argv) if args.repair: path = auth.pypirc_path() added = auth.repair_index_servers(path) if added: print(f"[ok ] added {added} to [distutils] index-servers in {path}") else: print(f"[ok ] nothing to repair in {path}") for index in sorted(auth.INDEXES): state = auth.credential_state(index) flag = "[ok ]" if state.configured else "[-- ]" detail = state.source if state.configured else state.problem print(f"{flag} [{index}] {detail}") return 0 if auth.credential_state(args.index).configured else 1 if args.set_token: if args.token_stdin: token = auth.read_token_from_stdin() else: token = auth.prompt_for_token(args.set_token) if not token: print("[FAIL] no token entered") return 1 for problem in auth.validate_token(token): print(f"[FAIL] {problem.removeprefix('ERROR ')}") return 1 for warning in auth.token_warnings(token): print(f"[warn] {warning.removeprefix('WARN ')}") path = auth.write_token(args.set_token, token) print(f"[ok ] wrote [{args.set_token}] credentials to {path}") state = auth.credential_state(args.set_token) if not state.configured: print(f"[FAIL] the file still does not validate: {state.problem}") return 1 print(f"[ok ] [{args.set_token}] credentials verified ({state.source})") return 0 path = auth.pypirc_path() print(f"config file: {path} ({'exists' if path.is_file() else 'missing'})") for index in sorted(auth.INDEXES): state = auth.credential_state(index) if state.configured: print(f"[ok ] [{index}] configured ({state.source})") else: print(f"[-- ] [{index}] not usable: {state.problem}") unlisted = auth.unlisted_sections(path) if unlisted: print() print( f"[warn] {unlisted} have sections that [distutils] index-servers does not " "list; twine ignores those entirely" ) print(" fix with: python build/run.py release auth --repair") print() print("set a token with:") for index in sorted(auth.INDEXES): if not auth.credential_state(index).configured: print(f" python build/run.py release auth --set-token {index}") print(" (TestPyPI needs its own account; if you only hold a production token,") print(" skip it and publish with `release pypi --pypi-only`)") return 0 if auth.credential_state(args.index).configured else 1 def _cmd_pypi(argv: list[str]) -> int: parser = argparse.ArgumentParser( prog="build/run.py release pypi", description="Upload the certified wheels to TestPyPI and then PyPI.", ) parser.add_argument("--version", required=True, help="release version, e.g. 0.5.2") parser.add_argument( "--apply", action="store_true", help="actually upload (uploads are permanent and cannot be overwritten)", ) parser.add_argument( "--confirm-pypi", action="store_true", help="after TestPyPI, also publish to the real PyPI", ) parser.add_argument( "--pypi-only", action="store_true", help=( "upload straight to PyPI, skipping the TestPyPI rehearsal " "(for when you only hold a production token)" ), ) parser.add_argument( "--prompt-credentials", action="store_true", help="if a token is missing, prompt for it and store it in ~/.pypirc", ) parser.add_argument( "--token-stdin", action="store_true", help="read a missing token from stdin instead of prompting (scripts/CI)", ) parser.add_argument( "--skip-install-verify", action="store_true", help="skip fetching the artifact back out of the index", ) parser.add_argument("--skip-check", action="store_true", help="skip `twine check`") args = parser.parse_args(argv) if args.pypi_only and args.confirm_pypi: parser.error("--pypi-only and --confirm-pypi are mutually exclusive") if args.token_stdin and not args.prompt_credentials: parser.error("--token-stdin requires --prompt-credentials") token = auth.read_token_from_stdin() if args.token_stdin else None return pypi.publish( pypi.Options( version=args.version, apply=args.apply, confirm_pypi=args.confirm_pypi, verify_install=not args.skip_install_verify, skip_check=args.skip_check, pypi_only=args.pypi_only, prompt_credentials=args.prompt_credentials, token=token, ) ) def main(argv: list[str] | None = None) -> int: argv = list(sys.argv[1:] if argv is None else argv) if not argv or argv[0] in ("-h", "--help"): print(__doc__) print("Commands: preflight | audit | auth | github | pypi") return 0 command, rest = argv[0], argv[1:] if command == "preflight": return _cmd_preflight(rest) if command == "audit": return _cmd_audit(rest) if command == "auth": return _cmd_auth(rest) if command == "github": return _cmd_github(rest) if command == "pypi": return _cmd_pypi(rest) print(f"Unknown release command: {command!r}", file=sys.stderr) print("Choose from: preflight, audit, auth, github, pypi", file=sys.stderr) return 2 if __name__ == "__main__": raise SystemExit(main())