diff --git a/sdk/python-runtime/README.md b/sdk/python-runtime/README.md index 0e7b26d5fc..62e45413a0 100644 --- a/sdk/python-runtime/README.md +++ b/sdk/python-runtime/README.md @@ -1,9 +1,17 @@ # Codex CLI Runtime for Python SDK -Platform-specific runtime package consumed by the published `codex-app-server-sdk`. +Platform-specific runtime package consumed by the published `openai-codex` SDK. This package is staged during release so the SDK can pin an exact Codex CLI -version without checking platform binaries into the repo. +version without checking platform binaries into the repo. The distribution name +is `openai-codex-cli-bin`, while the import module remains `codex_cli_bin`. -`codex-cli-bin` is intentionally wheel-only. Do not build or publish an sdist -for this package. +`openai-codex-cli-bin` is intentionally wheel-only. Do not build or publish an +sdist for this package. + +Expected wheel contents: + +- macOS/Linux: `codex_cli_bin/bin/codex` +- Windows: `codex_cli_bin/bin/codex.exe`, + `codex_cli_bin/bin/codex-command-runner.exe`, and + `codex_cli_bin/bin/codex-windows-sandbox-setup.exe` diff --git a/sdk/python-runtime/hatch_build.py b/sdk/python-runtime/hatch_build.py index 319e6973fb..2afc341766 100644 --- a/sdk/python-runtime/hatch_build.py +++ b/sdk/python-runtime/hatch_build.py @@ -1,15 +1,34 @@ from __future__ import annotations +import os + from hatchling.builders.hooks.plugin.interface import BuildHookInterface +PLATFORM_TAG_BY_TARGET = { + "aarch64-apple-darwin": "macosx_11_0_arm64", + "x86_64-apple-darwin": "macosx_10_12_x86_64", + "aarch64-unknown-linux-musl": "musllinux_1_2_aarch64", + "x86_64-unknown-linux-musl": "musllinux_1_2_x86_64", + "aarch64-pc-windows-msvc": "win_arm64", + "x86_64-pc-windows-msvc": "win_amd64", +} + class RuntimeBuildHook(BuildHookInterface): def initialize(self, version: str, build_data: dict[str, object]) -> None: del version if self.target_name == "sdist": raise RuntimeError( - "codex-cli-bin is wheel-only; build and publish platform wheels only." + "openai-codex-cli-bin is wheel-only; build and publish platform wheels only." ) build_data["pure_python"] = False - build_data["infer_tag"] = True + target = os.environ.get("CODEX_PYTHON_RUNTIME_TARGET") + if target is None: + build_data["infer_tag"] = True + return + + platform_tag = PLATFORM_TAG_BY_TARGET.get(target) + if platform_tag is None: + raise RuntimeError(f"Unsupported Codex Python runtime target: {target}") + build_data["tag"] = f"py3-none-{platform_tag}" diff --git a/sdk/python-runtime/pyproject.toml b/sdk/python-runtime/pyproject.toml index 761dccbb9f..281cb7d1a4 100644 --- a/sdk/python-runtime/pyproject.toml +++ b/sdk/python-runtime/pyproject.toml @@ -3,7 +3,7 @@ requires = ["hatchling>=1.24.0"] build-backend = "hatchling.build" [project] -name = "codex-cli-bin" +name = "openai-codex-cli-bin" version = "0.0.0-dev" description = "Pinned Codex CLI runtime for the Python SDK" readme = "README.md" diff --git a/sdk/python-runtime/src/codex_cli_bin/__init__.py b/sdk/python-runtime/src/codex_cli_bin/__init__.py index 8059d39211..cf0720af12 100644 --- a/sdk/python-runtime/src/codex_cli_bin/__init__.py +++ b/sdk/python-runtime/src/codex_cli_bin/__init__.py @@ -3,12 +3,25 @@ from __future__ import annotations import os from pathlib import Path -PACKAGE_NAME = "codex-cli-bin" +PACKAGE_NAME = "openai-codex-cli-bin" + + +def bundled_bin_dir() -> Path: + return Path(__file__).resolve().parent / "bin" + + +def bundled_runtime_files() -> tuple[Path, ...]: + names = ( + ("codex.exe", "codex-command-runner.exe", "codex-windows-sandbox-setup.exe") + if os.name == "nt" + else ("codex",) + ) + return tuple(bundled_bin_dir() / name for name in names) def bundled_codex_path() -> Path: exe = "codex.exe" if os.name == "nt" else "codex" - path = Path(__file__).resolve().parent / "bin" / exe + path = bundled_bin_dir() / exe if not path.is_file(): raise FileNotFoundError( f"{PACKAGE_NAME} is installed but missing its packaged codex binary at {path}" @@ -16,4 +29,9 @@ def bundled_codex_path() -> Path: return path -__all__ = ["PACKAGE_NAME", "bundled_codex_path"] +__all__ = [ + "PACKAGE_NAME", + "bundled_bin_dir", + "bundled_codex_path", + "bundled_runtime_files", +] diff --git a/sdk/python/_runtime_setup.py b/sdk/python/_runtime_setup.py index 5eb3999f4c..e878293f87 100644 --- a/sdk/python/_runtime_setup.py +++ b/sdk/python/_runtime_setup.py @@ -15,7 +15,7 @@ import urllib.request import zipfile from pathlib import Path -PACKAGE_NAME = "codex-cli-bin" +PACKAGE_NAME = "openai-codex-cli-bin" PINNED_RUNTIME_VERSION = "0.116.0-alpha.1" REPO_SLUG = "openai/codex" @@ -39,17 +39,20 @@ def ensure_runtime_package_installed( installed_version = _installed_runtime_version(python_executable) normalized_requested = _normalized_package_version(requested_version) - if installed_version is not None and _normalized_package_version(installed_version) == normalized_requested: + if ( + installed_version is not None + and _normalized_package_version(installed_version) == normalized_requested + ): return requested_version with tempfile.TemporaryDirectory(prefix="codex-python-runtime-") as temp_root_str: temp_root = Path(temp_root_str) archive_path = _download_release_archive(requested_version, temp_root) - runtime_binary = _extract_runtime_binary(archive_path, temp_root) + runtime_bundle_dir = _extract_runtime_bundle(archive_path, temp_root) staged_runtime_dir = _stage_runtime_package( sdk_python_dir, requested_version, - runtime_binary, + runtime_bundle_dir, temp_root / "runtime-stage", ) _install_runtime_package(python_executable, staged_runtime_dir, install_target) @@ -61,7 +64,10 @@ def ensure_runtime_package_installed( importlib.invalidate_caches() installed_version = _installed_runtime_version(python_executable) - if installed_version is None or _normalized_package_version(installed_version) != normalized_requested: + if ( + installed_version is None + or _normalized_package_version(installed_version) != normalized_requested + ): raise RuntimeSetupError( f"Expected {PACKAGE_NAME} {requested_version} in {python_executable}, " f"but found {installed_version!r} after installation." @@ -105,7 +111,7 @@ def _installed_runtime_version(python_executable: str | Path) -> str | None: "try:\n" " from codex_cli_bin import bundled_codex_path\n" " bundled_codex_path()\n" - " print(json.dumps({'version': importlib.metadata.version('codex-cli-bin')}))\n" + f" print(json.dumps({{'version': importlib.metadata.version({PACKAGE_NAME!r})}}))\n" "except Exception:\n" " sys.exit(1)\n" ) @@ -172,7 +178,9 @@ def _download_release_archive(version: str, temp_root: Path) -> Path: metadata = _release_metadata(version) assets = metadata.get("assets") if not isinstance(assets, list): - raise RuntimeSetupError(f"Release rust-v{version} returned malformed assets metadata.") + raise RuntimeSetupError( + f"Release rust-v{version} returned malformed assets metadata." + ) asset = next( ( item @@ -198,7 +206,10 @@ def _download_release_archive(version: str, temp_root: Path) -> Path: headers=_github_api_headers("application/octet-stream"), ) try: - with urllib.request.urlopen(request) as response, archive_path.open("wb") as fh: + with ( + urllib.request.urlopen(request) as response, + archive_path.open("wb") as fh, + ): shutil.copyfileobj(response, fh) return archive_path except urllib.error.HTTPError: @@ -236,7 +247,7 @@ def _download_release_archive(version: str, temp_root: Path) -> Path: return archive_path -def _extract_runtime_binary(archive_path: Path, temp_root: Path) -> Path: +def _extract_runtime_bundle(archive_path: Path, temp_root: Path) -> Path: extract_dir = temp_root / "extracted" extract_dir.mkdir(parents=True, exist_ok=True) if archive_path.name.endswith(".tar.gz"): @@ -249,38 +260,24 @@ def _extract_runtime_binary(archive_path: Path, temp_root: Path) -> Path: with zipfile.ZipFile(archive_path) as zip_file: zip_file.extractall(extract_dir) else: - raise RuntimeSetupError(f"Unsupported release archive format: {archive_path.name}") - - binary_name = runtime_binary_name() - archive_stem = archive_path.name.removesuffix(".tar.gz").removesuffix(".zip") - candidates = [ - path - for path in extract_dir.rglob("*") - if path.is_file() - and ( - path.name == binary_name - or path.name == archive_stem - or path.name.startswith("codex-") - ) - ] - if not candidates: raise RuntimeSetupError( - f"Failed to find {binary_name} in extracted runtime archive {archive_path.name}." + f"Unsupported release archive format: {archive_path.name}" ) - return candidates[0] + + return extract_dir def _stage_runtime_package( sdk_python_dir: Path, runtime_version: str, - runtime_binary: Path, + runtime_bundle_dir: Path, staging_dir: Path, ) -> Path: script_module = _load_update_script_module(sdk_python_dir) return script_module.stage_python_runtime_package( # type: ignore[no-any-return] staging_dir, runtime_version, - runtime_binary.resolve(), + runtime_bundle_dir.resolve(), ) diff --git a/sdk/python/scripts/update_sdk_artifacts.py b/sdk/python/scripts/update_sdk_artifacts.py index 6685fd0999..8cbd0b8618 100755 --- a/sdk/python/scripts/update_sdk_artifacts.py +++ b/sdk/python/scripts/update_sdk_artifacts.py @@ -17,6 +17,9 @@ from dataclasses import dataclass from pathlib import Path from typing import Any, Callable, Sequence, get_args, get_origin +SDK_PKG_NAME = "openai-codex" +RUNTIME_PKG_NAME = "openai-codex-cli-bin" + def repo_root() -> Path: return Path(__file__).resolve().parents[3] @@ -45,16 +48,30 @@ def schema_root_dir() -> Path: return repo_root() / "codex-rs" / "app-server-protocol" / "schema" / "json" -def _is_windows() -> bool: - return platform.system().lower().startswith("win") +def _is_windows(system_name: str | None = None) -> bool: + return (system_name or platform.system()).lower().startswith("win") -def runtime_binary_name() -> str: - return "codex.exe" if _is_windows() else "codex" +def runtime_binary_name(system_name: str | None = None) -> str: + return "codex.exe" if _is_windows(system_name) else "codex" + + +def runtime_file_names(system_name: str | None = None) -> tuple[str, ...]: + if _is_windows(system_name): + return ( + "codex.exe", + "codex-command-runner.exe", + "codex-windows-sandbox-setup.exe", + ) + return ("codex",) + + +def staged_runtime_bin_dir(root: Path) -> Path: + return root / "src" / "codex_cli_bin" / "bin" def staged_runtime_bin_path(root: Path) -> Path: - return root / "src" / "codex_cli_bin" / "bin" / runtime_binary_name() + return staged_runtime_bin_dir(root) / runtime_binary_name() def run(cmd: list[str], cwd: Path) -> None: @@ -110,6 +127,39 @@ def _rewrite_project_version(pyproject_text: str, version: str) -> str: return updated +def _rewrite_project_name(pyproject_text: str, name: str) -> str: + updated, count = re.subn( + r'^name = "[^"]+"$', + f'name = "{name}"', + pyproject_text, + count=1, + flags=re.MULTILINE, + ) + if count != 1: + raise RuntimeError("Could not rewrite project name in pyproject.toml") + return updated + + +def normalize_python_package_version(version: str) -> str: + stripped = version.strip() + if re.fullmatch(r"\d+\.\d+\.\d+(?:a\d+|b\d+|\.dev\d+)?", stripped): + return stripped + + prerelease_match = re.fullmatch( + r"(\d+\.\d+\.\d+)-(alpha|beta)\.(\d+)", + stripped, + ) + if prerelease_match is not None: + base, prerelease, number = prerelease_match.groups() + marker = "a" if prerelease == "alpha" else "b" + return f"{base}{marker}{number}" + + raise RuntimeError( + "Unsupported Python package version. Expected x.y.z, x.y.z-alpha.n, " + f"x.y.z-beta.n, or an already-normalized PEP 440 version; got {version!r}." + ) + + def _rewrite_sdk_runtime_dependency(pyproject_text: str, runtime_version: str) -> str: match = re.search(r"^dependencies = \[(.*?)\]$", pyproject_text, flags=re.MULTILINE) if match is None: @@ -118,15 +168,46 @@ def _rewrite_sdk_runtime_dependency(pyproject_text: str, runtime_version: str) - ) raw_items = [item.strip() for item in match.group(1).split(",") if item.strip()] - raw_items = [item for item in raw_items if "codex-cli-bin" not in item] - raw_items.append(f'"codex-cli-bin=={runtime_version}"') + raw_items = [ + item + for item in raw_items + if "codex-cli-bin" not in item and RUNTIME_PKG_NAME not in item + ] + raw_items.append(f'"{RUNTIME_PKG_NAME}=={runtime_version}"') replacement = "dependencies = [\n " + ",\n ".join(raw_items) + ",\n]" return pyproject_text[: match.start()] + replacement + pyproject_text[match.end() :] +def _rewrite_sdk_init_version(init_text: str, sdk_version: str) -> str: + updated, count = re.subn( + r'^__version__ = "[^"]+"$', + f'__version__ = "{sdk_version}"', + init_text, + count=1, + flags=re.MULTILINE, + ) + if count != 1: + raise RuntimeError("Could not rewrite SDK __version__") + return updated + + +def _rewrite_sdk_client_version(client_text: str, sdk_version: str) -> str: + updated, count = re.subn( + r'client_version: str = "[^"]+"', + f'client_version: str = "{sdk_version}"', + client_text, + count=1, + ) + if count != 1: + raise RuntimeError("Could not rewrite AppServerConfig.client_version") + return updated + + def stage_python_sdk_package( staging_dir: Path, sdk_version: str, runtime_version: str ) -> Path: + sdk_version = normalize_python_package_version(sdk_version) + runtime_version = normalize_python_package_version(runtime_version) _copy_package_tree(sdk_root(), staging_dir) sdk_bin_dir = staging_dir / "src" / "codex_app_server" / "bin" if sdk_bin_dir.exists(): @@ -134,32 +215,88 @@ def stage_python_sdk_package( pyproject_path = staging_dir / "pyproject.toml" pyproject_text = pyproject_path.read_text() + pyproject_text = _rewrite_project_name(pyproject_text, SDK_PKG_NAME) pyproject_text = _rewrite_project_version(pyproject_text, sdk_version) pyproject_text = _rewrite_sdk_runtime_dependency(pyproject_text, runtime_version) pyproject_path.write_text(pyproject_text) + + init_path = staging_dir / "src" / "codex_app_server" / "__init__.py" + init_path.write_text(_rewrite_sdk_init_version(init_path.read_text(), sdk_version)) + + client_path = staging_dir / "src" / "codex_app_server" / "client.py" + client_path.write_text( + _rewrite_sdk_client_version(client_path.read_text(), sdk_version) + ) return staging_dir def stage_python_runtime_package( - staging_dir: Path, runtime_version: str, binary_path: Path + staging_dir: Path, runtime_version: str, runtime_bundle_dir: Path ) -> Path: + runtime_version = normalize_python_package_version(runtime_version) _copy_package_tree(python_runtime_root(), staging_dir) pyproject_path = staging_dir / "pyproject.toml" - pyproject_path.write_text( - _rewrite_project_version(pyproject_path.read_text(), runtime_version) - ) + pyproject_text = _rewrite_project_name(pyproject_path.read_text(), RUNTIME_PKG_NAME) + pyproject_text = _rewrite_project_version(pyproject_text, runtime_version) + pyproject_path.write_text(pyproject_text) - out_bin = staged_runtime_bin_path(staging_dir) - out_bin.parent.mkdir(parents=True, exist_ok=True) - shutil.copy2(binary_path, out_bin) - if not _is_windows(): - out_bin.chmod( - out_bin.stat().st_mode | stat.S_IXUSR | stat.S_IXGRP | stat.S_IXOTH - ) + out_bin_dir = staged_runtime_bin_dir(staging_dir) + out_bin_dir.mkdir(parents=True, exist_ok=True) + for runtime_file_name in runtime_file_names(): + source = _find_runtime_bundle_file(runtime_bundle_dir, runtime_file_name) + out_path = out_bin_dir / runtime_file_name + shutil.copy2(source, out_path) + if not _is_windows(): + out_path.chmod( + out_path.stat().st_mode | stat.S_IXUSR | stat.S_IXGRP | stat.S_IXOTH + ) return staging_dir +def _find_runtime_bundle_file(runtime_bundle_dir: Path, destination_name: str) -> Path: + if not runtime_bundle_dir.is_dir(): + raise RuntimeError(f"Runtime bundle directory not found: {runtime_bundle_dir}") + + exact = runtime_bundle_dir / destination_name + if exact.is_file(): + return exact + + patterns = { + "codex": re.compile(r"^codex-(?!responses-api-proxy)[^.]+$"), + "codex.exe": re.compile( + r"^codex-(?!command-runner|windows-sandbox-setup|responses-api-proxy).+\.exe$" + ), + "codex-command-runner.exe": re.compile(r"^codex-command-runner-.+\.exe$"), + "codex-windows-sandbox-setup.exe": re.compile( + r"^codex-windows-sandbox-setup-.+\.exe$" + ), + } + pattern = patterns.get(destination_name) + candidates = ( + [] + if pattern is None + else sorted( + path + for path in runtime_bundle_dir.iterdir() + if path.is_file() and pattern.fullmatch(path.name) + ) + ) + if len(candidates) == 1: + return candidates[0] + if len(candidates) > 1: + candidate_names = ", ".join(path.name for path in candidates) + raise RuntimeError( + f"Runtime bundle has multiple candidates for {destination_name}: " + f"{candidate_names}" + ) + + raise RuntimeError( + f"Runtime bundle {runtime_bundle_dir} is missing required file " + f"{destination_name}" + ) + + def _flatten_string_enum_one_of(definition: dict[str, Any]) -> bool: branches = definition.get("oneOf") if not isinstance(branches, list) or not branches: @@ -928,7 +1065,7 @@ def build_parser() -> argparse.ArgumentParser: stage_sdk_parser.add_argument( "--runtime-version", required=True, - help="Pinned codex-cli-bin version for the staged SDK package", + help=f"Pinned {RUNTIME_PKG_NAME} version for the staged SDK package", ) stage_sdk_parser.add_argument( "--sdk-version", @@ -945,9 +1082,9 @@ def build_parser() -> argparse.ArgumentParser: help="Output directory for the staged runtime package", ) stage_runtime_parser.add_argument( - "runtime_binary", + "runtime_bundle_dir", type=Path, - help="Path to the codex binary to package for this platform", + help="Directory containing the Codex runtime files to package for this platform", ) stage_runtime_parser.add_argument( "--runtime-version", @@ -984,7 +1121,7 @@ def run_command(args: argparse.Namespace, ops: CliOps) -> None: ops.stage_python_runtime_package( args.staging_dir, args.runtime_version, - args.runtime_binary.resolve(), + args.runtime_bundle_dir.resolve(), ) diff --git a/sdk/python/src/codex_app_server/client.py b/sdk/python/src/codex_app_server/client.py index 146d5186e1..c5e32a8c46 100644 --- a/sdk/python/src/codex_app_server/client.py +++ b/sdk/python/src/codex_app_server/client.py @@ -47,7 +47,8 @@ from .retry import retry_on_overload ModelT = TypeVar("ModelT", bound=BaseModel) ApprovalHandler = Callable[[str, JsonObject | None], JsonObject] -RUNTIME_PKG_NAME = "codex-cli-bin" +SDK_PKG_NAME = "openai-codex" +RUNTIME_PKG_NAME = "openai-codex-cli-bin" def _params_dict( diff --git a/sdk/python/tests/test_artifact_workflow_and_binaries.py b/sdk/python/tests/test_artifact_workflow_and_binaries.py index b19dc745a3..cc439c9823 100644 --- a/sdk/python/tests/test_artifact_workflow_and_binaries.py +++ b/sdk/python/tests/test_artifact_workflow_and_binaries.py @@ -29,7 +29,22 @@ def _load_runtime_setup_module(): runtime_setup_path = ROOT / "_runtime_setup.py" spec = importlib.util.spec_from_file_location("_runtime_setup", runtime_setup_path) if spec is None or spec.loader is None: - raise AssertionError(f"Failed to load runtime setup module: {runtime_setup_path}") + raise AssertionError( + f"Failed to load runtime setup module: {runtime_setup_path}" + ) + module = importlib.util.module_from_spec(spec) + sys.modules[spec.name] = module + spec.loader.exec_module(module) + return module + + +def _load_runtime_package_module(package_root: Path): + runtime_init = package_root / "src" / "codex_cli_bin" / "__init__.py" + spec = importlib.util.spec_from_file_location( + "codex_cli_bin_under_test", runtime_init + ) + if spec is None or spec.loader is None: + raise AssertionError(f"Failed to load runtime package module: {runtime_init}") module = importlib.util.module_from_spec(spec) sys.modules[spec.name] = module spec.loader.exec_module(module) @@ -168,7 +183,9 @@ def test_examples_readme_matches_pinned_runtime_version() -> None: ) -def test_release_metadata_retries_without_invalid_auth(monkeypatch: pytest.MonkeyPatch) -> None: +def test_release_metadata_retries_without_invalid_auth( + monkeypatch: pytest.MonkeyPatch, +) -> None: runtime_setup = _load_runtime_setup_module() authorizations: list[str | None] = [] @@ -198,6 +215,14 @@ def test_runtime_package_is_wheel_only_and_builds_platform_specific_wheels() -> ) hook_source = (ROOT.parent / "python-runtime" / "hatch_build.py").read_text() hook_tree = ast.parse(hook_source) + platform_tag_assignment = next( + node + for node in hook_tree.body + if isinstance(node, ast.Assign) + and len(node.targets) == 1 + and isinstance(node.targets[0], ast.Name) + and node.targets[0].id == "PLATFORM_TAG_BY_TARGET" + ) initialize_fn = next( node for node in ast.walk(hook_tree) @@ -235,6 +260,7 @@ def test_runtime_package_is_wheel_only_and_builds_platform_specific_wheels() -> and isinstance(node.value, ast.Constant) } + assert pyproject["project"]["name"] == "openai-codex-cli-bin" assert pyproject["tool"]["hatch"]["build"]["targets"]["wheel"] == { "packages": ["src/codex_cli_bin"], "include": ["src/codex_cli_bin/bin/**"], @@ -244,23 +270,51 @@ def test_runtime_package_is_wheel_only_and_builds_platform_specific_wheels() -> "hooks": {"custom": {}}, } assert sdist_guard is not None - assert build_data_assignments == {"pure_python": False, "infer_tag": True} + assert build_data_assignments == {"pure_python": False} + assert ast.literal_eval(platform_tag_assignment.value) == { + "aarch64-apple-darwin": "macosx_11_0_arm64", + "x86_64-apple-darwin": "macosx_10_12_x86_64", + "aarch64-unknown-linux-musl": "musllinux_1_2_aarch64", + "x86_64-unknown-linux-musl": "musllinux_1_2_x86_64", + "aarch64-pc-windows-msvc": "win_arm64", + "x86_64-pc-windows-msvc": "win_amd64", + } + assert "CODEX_PYTHON_RUNTIME_TARGET" in hook_source + assert '"infer_tag"' in hook_source + assert '"tag"' in hook_source -def test_stage_runtime_release_copies_binary_and_sets_version(tmp_path: Path) -> None: +def test_python_release_version_normalization() -> None: script = _load_update_script_module() - fake_binary = tmp_path / script.runtime_binary_name() + + assert script.normalize_python_package_version("1.2.3") == "1.2.3" + assert script.normalize_python_package_version("1.2.3-alpha.4") == "1.2.3a4" + assert script.normalize_python_package_version("1.2.3-beta.5") == "1.2.3b5" + assert script.normalize_python_package_version("1.2.3a4") == "1.2.3a4" + assert script.normalize_python_package_version("0.0.0.dev0") == "0.0.0.dev0" + + with pytest.raises(RuntimeError, match="Unsupported Python package version"): + script.normalize_python_package_version("1.2.3-rc.1") + + +def test_stage_runtime_release_copies_bundle_and_sets_version(tmp_path: Path) -> None: + script = _load_update_script_module() + bundle_dir = tmp_path / "bundle" + bundle_dir.mkdir() + fake_binary = bundle_dir / script.runtime_binary_name() fake_binary.write_text("fake codex\n") staged = script.stage_python_runtime_package( tmp_path / "runtime-stage", - "1.2.3", - fake_binary, + "1.2.3-alpha.4", + bundle_dir, ) assert staged == tmp_path / "runtime-stage" assert script.staged_runtime_bin_path(staged).read_text() == "fake codex\n" - assert 'version = "1.2.3"' in (staged / "pyproject.toml").read_text() + pyproject = (staged / "pyproject.toml").read_text() + assert 'name = "openai-codex-cli-bin"' in pyproject + assert 'version = "1.2.3a4"' in pyproject def test_stage_runtime_release_replaces_existing_staging_dir(tmp_path: Path) -> None: @@ -270,13 +324,15 @@ def test_stage_runtime_release_replaces_existing_staging_dir(tmp_path: Path) -> old_file.parent.mkdir(parents=True) old_file.write_text("stale") - fake_binary = tmp_path / script.runtime_binary_name() + bundle_dir = tmp_path / "bundle" + bundle_dir.mkdir() + fake_binary = bundle_dir / script.runtime_binary_name() fake_binary.write_text("fake codex\n") staged = script.stage_python_runtime_package( staging_dir, "1.2.3", - fake_binary, + bundle_dir, ) assert staged == staging_dir @@ -284,13 +340,132 @@ def test_stage_runtime_release_replaces_existing_staging_dir(tmp_path: Path) -> assert script.staged_runtime_bin_path(staged).read_text() == "fake codex\n" -def test_stage_sdk_release_injects_exact_runtime_pin(tmp_path: Path) -> None: +def test_stage_runtime_release_normalizes_target_suffixed_names( + tmp_path: Path, +) -> None: script = _load_update_script_module() - staged = script.stage_python_sdk_package(tmp_path / "sdk-stage", "0.2.1", "1.2.3") + bundle_dir = tmp_path / "bundle" + bundle_dir.mkdir() + (bundle_dir / "codex-x86_64-unknown-linux-musl").write_text("fake codex\n") + + staged = script.stage_python_runtime_package( + tmp_path / "runtime-stage", + "1.2.3", + bundle_dir, + ) + + assert (staged / "src" / "codex_cli_bin" / "bin" / "codex").read_text() == ( + "fake codex\n" + ) + + +def test_stage_runtime_release_requires_complete_windows_bundle( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + script = _load_update_script_module() + monkeypatch.setattr(script.platform, "system", lambda: "Windows") + bundle_dir = tmp_path / "bundle" + bundle_dir.mkdir() + (bundle_dir / "codex-x86_64-pc-windows-msvc.exe").write_text("codex\n") + (bundle_dir / "codex-command-runner-x86_64-pc-windows-msvc.exe").write_text( + "runner\n" + ) + (bundle_dir / "codex-windows-sandbox-setup-x86_64-pc-windows-msvc.exe").write_text( + "setup\n" + ) + + staged = script.stage_python_runtime_package( + tmp_path / "runtime-stage", + "1.2.3", + bundle_dir, + ) + bin_dir = staged / "src" / "codex_cli_bin" / "bin" + + assert (bin_dir / "codex.exe").read_text() == "codex\n" + assert (bin_dir / "codex-command-runner.exe").read_text() == "runner\n" + assert (bin_dir / "codex-windows-sandbox-setup.exe").read_text() == "setup\n" + + +def test_stage_runtime_release_fails_for_missing_required_file( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + script = _load_update_script_module() + monkeypatch.setattr(script.platform, "system", lambda: "Windows") + bundle_dir = tmp_path / "bundle" + bundle_dir.mkdir() + (bundle_dir / "codex.exe").write_text("codex\n") + + with pytest.raises(RuntimeError, match="codex-command-runner.exe"): + script.stage_python_runtime_package( + tmp_path / "runtime-stage", + "1.2.3", + bundle_dir, + ) + + +def test_runtime_package_helpers_return_packaged_paths(tmp_path: Path) -> None: + script = _load_update_script_module() + bundle_dir = tmp_path / "bundle" + bundle_dir.mkdir() + (bundle_dir / "codex").write_text("fake codex\n") + staged = script.stage_python_runtime_package( + tmp_path / "runtime-stage", + "1.2.3", + bundle_dir, + ) + + runtime_module = _load_runtime_package_module(staged) + + assert runtime_module.PACKAGE_NAME == "openai-codex-cli-bin" + assert runtime_module.bundled_bin_dir() == staged / "src" / "codex_cli_bin" / "bin" + assert runtime_module.bundled_runtime_files() == ( + staged / "src" / "codex_cli_bin" / "bin" / "codex", + ) + assert runtime_module.bundled_codex_path() == ( + staged / "src" / "codex_cli_bin" / "bin" / "codex" + ) + + +def test_runtime_package_helpers_report_missing_binary(tmp_path: Path) -> None: + script = _load_update_script_module() + bundle_dir = tmp_path / "bundle" + bundle_dir.mkdir() + (bundle_dir / "codex").write_text("fake codex\n") + staged = script.stage_python_runtime_package( + tmp_path / "runtime-stage", + "1.2.3", + bundle_dir, + ) + (staged / "src" / "codex_cli_bin" / "bin" / "codex").unlink() + + runtime_module = _load_runtime_package_module(staged) + + with pytest.raises(FileNotFoundError, match="openai-codex-cli-bin"): + runtime_module.bundled_codex_path() + + +def test_stage_sdk_release_injects_exact_runtime_pin_and_versions( + tmp_path: Path, +) -> None: + script = _load_update_script_module() + staged = script.stage_python_sdk_package( + tmp_path / "sdk-stage", + "0.2.1-beta.2", + "1.2.3-alpha.4", + ) pyproject = (staged / "pyproject.toml").read_text() - assert 'version = "0.2.1"' in pyproject - assert '"codex-cli-bin==1.2.3"' in pyproject + assert 'name = "openai-codex"' in pyproject + assert 'version = "0.2.1b2"' in pyproject + assert '"openai-codex-cli-bin==1.2.3a4"' in pyproject + assert ( + '__version__ = "0.2.1b2"' + in (staged / "src" / "codex_app_server" / "__init__.py").read_text() + ) + assert ( + 'client_version: str = "0.2.1b2"' + in (staged / "src" / "codex_app_server" / "client.py").read_text() + ) assert not any((staged / "src" / "codex_app_server").glob("bin/**")) @@ -329,7 +504,7 @@ def test_stage_sdk_runs_type_generation_before_staging(tmp_path: Path) -> None: return tmp_path / "sdk-stage" def fake_stage_runtime_package( - _staging_dir: Path, _runtime_version: str, _runtime_binary: Path + _staging_dir: Path, _runtime_version: str, _runtime_bundle_dir: Path ) -> Path: raise AssertionError("runtime staging should not run for stage-sdk") @@ -350,14 +525,15 @@ def test_stage_sdk_runs_type_generation_before_staging(tmp_path: Path) -> None: def test_stage_runtime_stages_binary_without_type_generation(tmp_path: Path) -> None: script = _load_update_script_module() - fake_binary = tmp_path / script.runtime_binary_name() - fake_binary.write_text("fake codex\n") + bundle_dir = tmp_path / "bundle" + bundle_dir.mkdir() + (bundle_dir / script.runtime_binary_name()).write_text("fake codex\n") calls: list[str] = [] args = script.parse_args( [ "stage-runtime", str(tmp_path / "runtime-stage"), - str(fake_binary), + str(bundle_dir), "--runtime-version", "1.2.3", ] @@ -372,7 +548,7 @@ def test_stage_runtime_stages_binary_without_type_generation(tmp_path: Path) -> raise AssertionError("sdk staging should not run for stage-runtime") def fake_stage_runtime_package( - _staging_dir: Path, _runtime_version: str, _runtime_binary: Path + _staging_dir: Path, _runtime_version: str, _runtime_bundle_dir: Path ) -> Path: calls.append("stage_runtime") return tmp_path / "runtime-stage"