Compare commits

..

1 Commits

Author SHA1 Message Date
Eric Traut
6550007cca Stabilize exec-server process tests (#17605)
Problem: After #17294 switched exec-server tests to launch the top-level
`codex exec-server` command, parallel remote exec-process cases can
flake while waiting for the child server's listen URL or transport
shutdown.

Solution: Serialize remote exec-server-backed process tests and harden
the harness so spawned servers are killed on drop and shutdown waits for
the child process to exit.
2026-04-13 00:31:13 -07:00
19 changed files with 866 additions and 1078 deletions

1
codex-rs/Cargo.lock generated
View File

@@ -2107,6 +2107,7 @@ dependencies = [
"pretty_assertions",
"serde",
"serde_json",
"serial_test",
"tempfile",
"test-case",
"thiserror 2.0.18",

View File

@@ -42,5 +42,6 @@ uuid = { workspace = true, features = ["v4"] }
anyhow = { workspace = true }
codex-utils-cargo-bin = { workspace = true }
pretty_assertions = { workspace = true }
serial_test = { workspace = true }
tempfile = { workspace = true }
test-case = "3.3.1"

View File

@@ -47,6 +47,7 @@ pub(crate) async fn exec_server() -> anyhow::Result<ExecServerHarness> {
child.stdin(Stdio::null());
child.stdout(Stdio::piped());
child.stderr(Stdio::inherit());
child.kill_on_drop(true);
let mut child = child.spawn()?;
let websocket_url = read_listen_url_from_stdout(&mut child).await?;
@@ -140,6 +141,9 @@ impl ExecServerHarness {
pub(crate) async fn shutdown(&mut self) -> anyhow::Result<()> {
self.child.start_kill()?;
timeout(CONNECT_TIMEOUT, self.child.wait())
.await
.map_err(|_| anyhow!("timed out waiting for exec-server shutdown"))??;
Ok(())
}

View File

@@ -210,6 +210,8 @@ async fn assert_exec_process_preserves_queued_events_before_subscribe(
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
// Serialize tests that launch a real exec-server process through the full CLI.
#[serial_test::serial(remote_exec_server)]
async fn remote_exec_process_reports_transport_disconnect() -> Result<()> {
let mut context = create_process_context(/*use_remote*/ true).await?;
let session = context
@@ -255,6 +257,8 @@ async fn remote_exec_process_reports_transport_disconnect() -> Result<()> {
#[test_case(false ; "local")]
#[test_case(true ; "remote")]
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
// Serialize tests that launch a real exec-server process through the full CLI.
#[serial_test::serial(remote_exec_server)]
async fn exec_process_starts_and_exits(use_remote: bool) -> Result<()> {
assert_exec_process_starts_and_exits(use_remote).await
}
@@ -262,6 +266,8 @@ async fn exec_process_starts_and_exits(use_remote: bool) -> Result<()> {
#[test_case(false ; "local")]
#[test_case(true ; "remote")]
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
// Serialize tests that launch a real exec-server process through the full CLI.
#[serial_test::serial(remote_exec_server)]
async fn exec_process_streams_output(use_remote: bool) -> Result<()> {
assert_exec_process_streams_output(use_remote).await
}
@@ -269,6 +275,8 @@ async fn exec_process_streams_output(use_remote: bool) -> Result<()> {
#[test_case(false ; "local")]
#[test_case(true ; "remote")]
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
// Serialize tests that launch a real exec-server process through the full CLI.
#[serial_test::serial(remote_exec_server)]
async fn exec_process_write_then_read(use_remote: bool) -> Result<()> {
assert_exec_process_write_then_read(use_remote).await
}
@@ -276,6 +284,8 @@ async fn exec_process_write_then_read(use_remote: bool) -> Result<()> {
#[test_case(false ; "local")]
#[test_case(true ; "remote")]
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
// Serialize tests that launch a real exec-server process through the full CLI.
#[serial_test::serial(remote_exec_server)]
async fn exec_process_preserves_queued_events_before_subscribe(use_remote: bool) -> Result<()> {
assert_exec_process_preserves_queued_events_before_subscribe(use_remote).await
}

View File

@@ -1,17 +1,9 @@
# Codex CLI Runtime for Python SDK
Platform-specific runtime package consumed by the published `openai-codex` SDK.
Platform-specific runtime package consumed by the published `codex-app-server-sdk`.
This package is staged during release so the SDK can pin an exact Codex CLI
version without checking platform binaries into the repo. The distribution name
is `openai-codex-cli-bin`, while the import module remains `codex_cli_bin`.
version without checking platform binaries into the repo.
`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`
`codex-cli-bin` is intentionally wheel-only. Do not build or publish an sdist
for this package.

View File

@@ -1,34 +1,15 @@
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(
"openai-codex-cli-bin is wheel-only; build and publish platform wheels only."
"codex-cli-bin is wheel-only; build and publish platform wheels only."
)
build_data["pure_python"] = False
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}"
build_data["infer_tag"] = True

View File

@@ -3,7 +3,7 @@ requires = ["hatchling>=1.24.0"]
build-backend = "hatchling.build"
[project]
name = "openai-codex-cli-bin"
name = "codex-cli-bin"
version = "0.0.0-dev"
description = "Pinned Codex CLI runtime for the Python SDK"
readme = "README.md"

View File

@@ -3,25 +3,12 @@ from __future__ import annotations
import os
from pathlib import Path
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)
PACKAGE_NAME = "codex-cli-bin"
def bundled_codex_path() -> Path:
exe = "codex.exe" if os.name == "nt" else "codex"
path = bundled_bin_dir() / exe
path = Path(__file__).resolve().parent / "bin" / exe
if not path.is_file():
raise FileNotFoundError(
f"{PACKAGE_NAME} is installed but missing its packaged codex binary at {path}"
@@ -29,9 +16,4 @@ def bundled_codex_path() -> Path:
return path
__all__ = [
"PACKAGE_NAME",
"bundled_bin_dir",
"bundled_codex_path",
"bundled_runtime_files",
]
__all__ = ["PACKAGE_NAME", "bundled_codex_path"]

View File

@@ -2,7 +2,7 @@
Experimental Python SDK for `codex app-server` JSON-RPC v2 over stdio, with a small default surface optimized for real scripts and apps.
The generated wire-model layer is sourced from the pinned runtime's `codex app-server generate-json-schema` output and exposed as Pydantic models with snake_case Python fields that serialize back to the app-servers camelCase wire format.
The generated wire-model layer is currently sourced from the bundled v2 schema and exposed as Pydantic models with snake_case Python fields that serialize back to the app-servers camelCase wire format.
## Install
@@ -11,16 +11,10 @@ cd sdk/python
python -m pip install -e .
```
Published SDK builds pin an exact `openai-codex-cli-bin` runtime dependency.
For local repo development, either pass `AppServerConfig(codex_bin=...)` to
point at a local build explicitly, or use the repo examples/notebook bootstrap
which installs the pinned runtime package automatically.
When published, normal installs should use:
```bash
python -m pip install openai-codex
```
Published SDK builds pin an exact `codex-cli-bin` runtime dependency. For local
repo development, either pass `AppServerConfig(codex_bin=...)` to point at a
local build explicitly, or use the repo examples/notebook bootstrap which
installs the pinned runtime package automatically.
## Quickstart
@@ -59,9 +53,9 @@ python examples/01_quickstart_constructor/async.py
The repo no longer checks `codex` binaries into `sdk/python`.
Published SDK builds are pinned to an exact `openai-codex-cli-bin` package
version, and that runtime package carries the platform-specific binary bundle
for the target wheel.
Published SDK builds are pinned to an exact `codex-cli-bin` package version,
and that runtime package carries the platform-specific binary for the target
wheel.
For local repo development, the checked-in `sdk/python-runtime` package is only
a template for staged release artifacts. Editable installs should use an
@@ -75,34 +69,30 @@ cd sdk/python
python scripts/update_sdk_artifacts.py generate-types
python scripts/update_sdk_artifacts.py \
stage-sdk \
/tmp/codex-python-release/openai-codex \
/tmp/codex-python-release/codex-app-server-sdk \
--runtime-version 1.2.3
python scripts/update_sdk_artifacts.py \
stage-runtime \
/tmp/codex-python-release/openai-codex-cli-bin \
/path/to/runtime-bundle-dir \
/tmp/codex-python-release/codex-cli-bin \
/path/to/codex \
--runtime-version 1.2.3
```
This supports the CI release flow:
- run `generate-types` before packaging
- generate types from the pinned runtime schema, then convert that schema to Python
- stage `openai-codex` once with an exact `openai-codex-cli-bin==...` dependency
- stage `openai-codex-cli-bin` on each supported platform runner with the same pinned runtime version
- build and publish `openai-codex-cli-bin` as platform wheels only; do not publish an sdist
- stage `codex-app-server-sdk` once with an exact `codex-cli-bin==...` dependency
- stage `codex-cli-bin` on each supported platform runner with the same pinned runtime version
- build and publish `codex-cli-bin` as platform wheels only; do not publish an sdist
## Compatibility and versioning
- Package: `openai-codex`
- Runtime package: `openai-codex-cli-bin`
- Package: `codex-app-server-sdk`
- Runtime package: `codex-cli-bin`
- Current SDK version in this repo: `0.2.0`
- Python: `>=3.10`
- Target protocol: Codex `app-server` JSON-RPC v2
- Release tags map to Python package versions as follows: `rust-v1.2.3` ->
`1.2.3`, `rust-v1.2.3-alpha.4` -> `1.2.3a4`, and
`rust-v1.2.3-beta.5` -> `1.2.3b5`.
- Recommendation: keep SDK and `codex` CLI at the exact same published version.
- Recommendation: keep SDK and `codex` CLI reasonably up to date together
## Notes

View File

@@ -15,7 +15,7 @@ import urllib.request
import zipfile
from pathlib import Path
PACKAGE_NAME = "openai-codex-cli-bin"
PACKAGE_NAME = "codex-cli-bin"
PINNED_RUNTIME_VERSION = "0.116.0-alpha.1"
REPO_SLUG = "openai/codex"
@@ -39,20 +39,17 @@ 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_bundle_dir = _extract_runtime_bundle(archive_path, temp_root)
runtime_binary = _extract_runtime_binary(archive_path, temp_root)
staged_runtime_dir = _stage_runtime_package(
sdk_python_dir,
requested_version,
runtime_bundle_dir,
runtime_binary,
temp_root / "runtime-stage",
)
_install_runtime_package(python_executable, staged_runtime_dir, install_target)
@@ -64,10 +61,7 @@ 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."
@@ -111,7 +105,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"
f" print(json.dumps({{'version': importlib.metadata.version({PACKAGE_NAME!r})}}))\n"
" print(json.dumps({'version': importlib.metadata.version('codex-cli-bin')}))\n"
"except Exception:\n"
" sys.exit(1)\n"
)
@@ -178,9 +172,7 @@ 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
@@ -206,10 +198,7 @@ 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:
@@ -247,7 +236,7 @@ def _download_release_archive(version: str, temp_root: Path) -> Path:
return archive_path
def _extract_runtime_bundle(archive_path: Path, temp_root: Path) -> Path:
def _extract_runtime_binary(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"):
@@ -260,24 +249,38 @@ def _extract_runtime_bundle(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}"
)
raise RuntimeSetupError(f"Unsupported release archive format: {archive_path.name}")
return extract_dir
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}."
)
return candidates[0]
def _stage_runtime_package(
sdk_python_dir: Path,
runtime_version: str,
runtime_bundle_dir: Path,
runtime_binary: 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_bundle_dir.resolve(),
runtime_binary.resolve(),
)

View File

@@ -54,28 +54,26 @@ This avoids duplicate ways to do the same operation and keeps behavior explicit.
Common causes:
- published runtime package (`openai-codex-cli-bin`) is not installed
- published runtime package (`codex-cli-bin`) is not installed
- local `codex_bin` override points to a missing file
- local auth/session is missing
- incompatible/old app-server
Maintainers stage releases by building the SDK once and the runtime once per
platform with the same pinned runtime version. `generate-types` first asks that
pinned runtime to emit the app-server JSON schema, then converts the emitted
schema to Python. Publish `openai-codex-cli-bin` as platform wheels only; do not
publish an sdist:
platform with the same pinned runtime version. Publish `codex-cli-bin` as
platform wheels only; do not publish an sdist:
```bash
cd sdk/python
python scripts/update_sdk_artifacts.py generate-types
python scripts/update_sdk_artifacts.py \
stage-sdk \
/tmp/codex-python-release/openai-codex \
/tmp/codex-python-release/codex-app-server-sdk \
--runtime-version 1.2.3
python scripts/update_sdk_artifacts.py \
stage-runtime \
/tmp/codex-python-release/openai-codex-cli-bin \
/path/to/runtime-bundle-dir \
/tmp/codex-python-release/codex-cli-bin \
/path/to/codex \
--runtime-version 1.2.3
```

View File

@@ -16,7 +16,7 @@ python -m pip install -e .
Requirements:
- Python `>=3.10`
- installed `openai-codex-cli-bin` runtime package, or an explicit `codex_bin` override
- installed `codex-cli-bin` runtime package, or an explicit `codex_bin` override
- local Codex auth/session configured
## 2) Run your first turn (sync)

View File

@@ -23,12 +23,12 @@ python -m pip install -e .
When running examples from this repo checkout, the SDK source uses the local
tree and does not bundle a runtime binary. The helper in `examples/_bootstrap.py`
uses the installed `openai-codex-cli-bin` runtime package.
uses the installed `codex-cli-bin` runtime package.
If the pinned `openai-codex-cli-bin` runtime is not already installed, the bootstrap
If the pinned `codex-cli-bin` runtime is not already installed, the bootstrap
will download the matching GitHub release artifact, stage a temporary local
`openai-codex-cli-bin` package, install it into your active interpreter, and
clean up the temporary files afterward.
`codex-cli-bin` package, install it into your active interpreter, and clean up
the temporary files afterward.
Current pinned runtime version: `0.116.0-alpha.1`
@@ -43,8 +43,8 @@ python examples/<example-folder>/async.py
The examples bootstrap local imports from `sdk/python/src` automatically, so no
SDK wheel install is required. You only need the Python dependencies for your
active interpreter and an installed `openai-codex-cli-bin` runtime package
(either already present or automatically provisioned by the bootstrap).
active interpreter and an installed `codex-cli-bin` runtime package (either
already present or automatically provisioned by the bootstrap).
## Recommended first run

View File

@@ -3,13 +3,13 @@ requires = ["hatchling>=1.24.0"]
build-backend = "hatchling.build"
[project]
name = "openai-codex"
name = "codex-app-server-sdk"
version = "0.2.0"
description = "Python SDK for Codex app-server v2"
readme = "README.md"
requires-python = ">=3.10"
license = { text = "Apache-2.0" }
authors = [{ name = "OpenAI" }]
authors = [{ name = "OpenClaw Assistant" }]
keywords = ["codex", "json-rpc", "sdk", "llm", "app-server"]
classifiers = [
"Development Status :: 4 - Beta",

View File

@@ -3,7 +3,6 @@ from __future__ import annotations
import argparse
import importlib
import importlib.util
import json
import platform
import re
@@ -18,9 +17,6 @@ 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]
@@ -34,44 +30,31 @@ def python_runtime_root() -> Path:
return repo_root() / "sdk" / "python-runtime"
def schema_bundle_path(schema_dir: Path | None = None) -> Path:
return schema_root_dir(schema_dir) / "codex_app_server_protocol.v2.schemas.json"
def schema_bundle_path() -> Path:
return (
repo_root()
/ "codex-rs"
/ "app-server-protocol"
/ "schema"
/ "json"
/ "codex_app_server_protocol.v2.schemas.json"
)
def schema_root_dir(schema_dir: Path | None = None) -> Path:
if schema_dir is not None:
return schema_dir
def schema_root_dir() -> Path:
return repo_root() / "codex-rs" / "app-server-protocol" / "schema" / "json"
def runtime_setup_path() -> Path:
return sdk_root() / "_runtime_setup.py"
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(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 runtime_binary_name() -> str:
return "codex.exe" if _is_windows() else "codex"
def staged_runtime_bin_path(root: Path) -> Path:
return staged_runtime_bin_dir(root) / runtime_binary_name()
return root / "src" / "codex_cli_bin" / "bin" / runtime_binary_name()
def run(cmd: list[str], cwd: Path) -> None:
@@ -127,39 +110,6 @@ 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:
@@ -168,46 +118,15 @@ 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 and RUNTIME_PKG_NAME not in item
]
raw_items.append(f'"{RUNTIME_PKG_NAME}=={runtime_version}"')
raw_items = [item for item in raw_items if "codex-cli-bin" not in item]
raw_items.append(f'"codex-cli-bin=={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():
@@ -215,150 +134,32 @@ 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, runtime_bundle_dir: Path
staging_dir: Path, runtime_version: str, binary_path: 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_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)
pyproject_path.write_text(
_rewrite_project_version(pyproject_path.read_text(), runtime_version)
)
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
)
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
)
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 _load_runtime_setup_module() -> Any:
spec = importlib.util.spec_from_file_location(
"_codex_python_runtime_setup", runtime_setup_path()
)
if spec is None or spec.loader is None:
raise RuntimeError(f"Failed to load {runtime_setup_path()}")
module = importlib.util.module_from_spec(spec)
sys.modules[spec.name] = module
spec.loader.exec_module(module)
return module
def _bundled_codex_path_from_install_target(install_target: Path) -> Path:
package_init = install_target / "codex_cli_bin" / "__init__.py"
spec = importlib.util.spec_from_file_location(
"_codex_cli_bin_for_schema",
package_init,
submodule_search_locations=[str(package_init.parent)],
)
if spec is None or spec.loader is None:
raise RuntimeError(f"Failed to load installed runtime package: {package_init}")
module = importlib.util.module_from_spec(spec)
sys.modules[spec.name] = module
spec.loader.exec_module(module)
return module.bundled_codex_path()
def _run_runtime_schema_generator(codex_bin: Path, out_dir: Path) -> None:
run(
[
str(codex_bin),
"app-server",
"generate-json-schema",
"--out",
str(out_dir),
],
cwd=repo_root(),
)
def _generate_json_schema_from_runtime(
out_dir: Path, runtime_version: str | None = None
) -> str:
runtime_setup = _load_runtime_setup_module()
requested_version = runtime_version or runtime_setup.pinned_runtime_version()
with tempfile.TemporaryDirectory(prefix="codex-python-schema-runtime-") as td:
install_target = Path(td) / "runtime-package"
original_pinned_runtime_version = runtime_setup.PINNED_RUNTIME_VERSION
runtime_setup.PINNED_RUNTIME_VERSION = requested_version
try:
runtime_setup.ensure_runtime_package_installed(
sys.executable,
sdk_root(),
install_target,
)
finally:
runtime_setup.PINNED_RUNTIME_VERSION = original_pinned_runtime_version
codex_bin = _bundled_codex_path_from_install_target(install_target)
_run_runtime_schema_generator(codex_bin, out_dir)
return requested_version
def _flatten_string_enum_one_of(definition: dict[str, Any]) -> bool:
branches = definition.get("oneOf")
if not isinstance(branches, list) or not branches:
@@ -595,8 +396,8 @@ def _annotate_schema(value: Any, base: str | None = None) -> None:
_annotate_schema(child, base)
def _normalized_schema_bundle_text(schema_dir: Path | None = None) -> str:
schema = json.loads(schema_bundle_path(schema_dir).read_text())
def _normalized_schema_bundle_text() -> str:
schema = json.loads(schema_bundle_path().read_text())
definitions = schema.get("definitions", {})
if isinstance(definitions, dict):
for definition in definitions.values():
@@ -608,7 +409,7 @@ def _normalized_schema_bundle_text(schema_dir: Path | None = None) -> str:
return json.dumps(schema, indent=2, sort_keys=True) + "\n"
def generate_v2_all(schema_dir: Path | None = None) -> None:
def generate_v2_all() -> None:
out_path = sdk_root() / "src" / "codex_app_server" / "generated" / "v2_all.py"
out_dir = out_path.parent
old_package_dir = out_dir / "v2_all"
@@ -616,8 +417,8 @@ def generate_v2_all(schema_dir: Path | None = None) -> None:
shutil.rmtree(old_package_dir)
out_dir.mkdir(parents=True, exist_ok=True)
with tempfile.TemporaryDirectory() as td:
normalized_bundle = Path(td) / schema_bundle_path(schema_dir).name
normalized_bundle.write_text(_normalized_schema_bundle_text(schema_dir))
normalized_bundle = Path(td) / schema_bundle_path().name
normalized_bundle.write_text(_normalized_schema_bundle_text())
run_python_module(
"datamodel_code_generator",
[
@@ -654,9 +455,9 @@ def generate_v2_all(schema_dir: Path | None = None) -> None:
_normalize_generated_timestamps(out_path)
def _notification_specs(schema_dir: Path | None = None) -> list[tuple[str, str]]:
def _notification_specs() -> list[tuple[str, str]]:
server_notifications = json.loads(
(schema_root_dir(schema_dir) / "ServerNotification.json").read_text()
(schema_root_dir() / "ServerNotification.json").read_text()
)
one_of = server_notifications.get("oneOf", [])
generated_source = (
@@ -693,7 +494,7 @@ def _notification_specs(schema_dir: Path | None = None) -> list[tuple[str, str]]
return specs
def generate_notification_registry(schema_dir: Path | None = None) -> None:
def generate_notification_registry() -> None:
out = (
sdk_root()
/ "src"
@@ -701,7 +502,7 @@ def generate_notification_registry(schema_dir: Path | None = None) -> None:
/ "generated"
/ "notification_registry.py"
)
specs = _notification_specs(schema_dir)
specs = _notification_specs()
class_names = sorted({class_name for _, class_name in specs})
lines = [
@@ -756,7 +557,7 @@ class PublicFieldSpec:
@dataclass(frozen=True)
class CliOps:
generate_types: Callable[[str | None], None]
generate_types: Callable[[], None]
stage_python_sdk_package: Callable[[Path, str, str], Path]
stage_python_runtime_package: Callable[[Path, str, Path], Path]
current_sdk_version: Callable[[], str]
@@ -1100,30 +901,20 @@ def generate_public_api_flat_methods() -> None:
public_api_path.write_text(source)
def generate_types(runtime_version: str | None = None) -> None:
with tempfile.TemporaryDirectory(prefix="codex-python-schema-") as schema_root:
schema_dir = Path(schema_root)
_generate_json_schema_from_runtime(schema_dir, runtime_version)
# v2_all is the authoritative generated surface.
generate_v2_all(schema_dir)
generate_notification_registry(schema_dir)
generate_public_api_flat_methods()
def generate_types() -> None:
# v2_all is the authoritative generated surface.
generate_v2_all()
generate_notification_registry()
generate_public_api_flat_methods()
def build_parser() -> argparse.ArgumentParser:
parser = argparse.ArgumentParser(description="Single SDK maintenance entrypoint")
subparsers = parser.add_subparsers(dest="command", required=True)
generate_types_parser = subparsers.add_parser(
subparsers.add_parser(
"generate-types", help="Regenerate Python protocol-derived types"
)
generate_types_parser.add_argument(
"--runtime-version",
help=(
"Runtime release version used to emit app-server JSON schema "
"(defaults to sdk/python/_runtime_setup.py's pinned version)"
),
)
stage_sdk_parser = subparsers.add_parser(
"stage-sdk",
@@ -1137,7 +928,7 @@ def build_parser() -> argparse.ArgumentParser:
stage_sdk_parser.add_argument(
"--runtime-version",
required=True,
help=f"Pinned {RUNTIME_PKG_NAME} version for the staged SDK package",
help="Pinned codex-cli-bin version for the staged SDK package",
)
stage_sdk_parser.add_argument(
"--sdk-version",
@@ -1154,9 +945,9 @@ def build_parser() -> argparse.ArgumentParser:
help="Output directory for the staged runtime package",
)
stage_runtime_parser.add_argument(
"runtime_bundle_dir",
"runtime_binary",
type=Path,
help="Directory containing the Codex runtime files to package for this platform",
help="Path to the codex binary to package for this platform",
)
stage_runtime_parser.add_argument(
"--runtime-version",
@@ -1181,9 +972,9 @@ def default_cli_ops() -> CliOps:
def run_command(args: argparse.Namespace, ops: CliOps) -> None:
if args.command == "generate-types":
ops.generate_types(args.runtime_version)
ops.generate_types()
elif args.command == "stage-sdk":
ops.generate_types(None)
ops.generate_types()
ops.stage_python_sdk_package(
args.staging_dir,
args.sdk_version or ops.current_sdk_version(),
@@ -1193,7 +984,7 @@ def run_command(args: argparse.Namespace, ops: CliOps) -> None:
ops.stage_python_runtime_package(
args.staging_dir,
args.runtime_version,
args.runtime_bundle_dir.resolve(),
args.runtime_binary.resolve(),
)

View File

@@ -47,8 +47,7 @@ from .retry import retry_on_overload
ModelT = TypeVar("ModelT", bound=BaseModel)
ApprovalHandler = Callable[[str, JsonObject | None], JsonObject]
SDK_PKG_NAME = "openai-codex"
RUNTIME_PKG_NAME = "openai-codex-cli-bin"
RUNTIME_PKG_NAME = "codex-cli-bin"
def _params_dict(

View File

@@ -17,6 +17,7 @@ from .v2_all import ContextCompactedNotification
from .v2_all import DeprecationNoticeNotification
from .v2_all import ErrorNotification
from .v2_all import FileChangeOutputDeltaNotification
from .v2_all import FsChangedNotification
from .v2_all import FuzzyFileSearchSessionCompletedNotification
from .v2_all import FuzzyFileSearchSessionUpdatedNotification
from .v2_all import HookCompletedNotification
@@ -26,6 +27,7 @@ from .v2_all import ItemGuardianApprovalReviewCompletedNotification
from .v2_all import ItemGuardianApprovalReviewStartedNotification
from .v2_all import ItemStartedNotification
from .v2_all import McpServerOauthLoginCompletedNotification
from .v2_all import McpServerStatusUpdatedNotification
from .v2_all import McpToolCallProgressNotification
from .v2_all import ModelReroutedNotification
from .v2_all import PlanDeltaNotification
@@ -43,6 +45,7 @@ from .v2_all import ThreadRealtimeErrorNotification
from .v2_all import ThreadRealtimeItemAddedNotification
from .v2_all import ThreadRealtimeOutputAudioDeltaNotification
from .v2_all import ThreadRealtimeStartedNotification
from .v2_all import ThreadRealtimeTranscriptUpdatedNotification
from .v2_all import ThreadStartedNotification
from .v2_all import ThreadStatusChangedNotification
from .v2_all import ThreadTokenUsageUpdatedNotification
@@ -63,6 +66,7 @@ NOTIFICATION_MODELS: dict[str, type[BaseModel]] = {
"configWarning": ConfigWarningNotification,
"deprecationNotice": DeprecationNoticeNotification,
"error": ErrorNotification,
"fs/changed": FsChangedNotification,
"fuzzyFileSearch/sessionCompleted": FuzzyFileSearchSessionCompletedNotification,
"fuzzyFileSearch/sessionUpdated": FuzzyFileSearchSessionUpdatedNotification,
"hook/completed": HookCompletedNotification,
@@ -81,6 +85,7 @@ NOTIFICATION_MODELS: dict[str, type[BaseModel]] = {
"item/reasoning/textDelta": ReasoningTextDeltaNotification,
"item/started": ItemStartedNotification,
"mcpServer/oauthLogin/completed": McpServerOauthLoginCompletedNotification,
"mcpServer/startupStatus/updated": McpServerStatusUpdatedNotification,
"model/rerouted": ModelReroutedNotification,
"serverRequest/resolved": ServerRequestResolvedNotification,
"skills/changed": SkillsChangedNotification,
@@ -93,6 +98,7 @@ NOTIFICATION_MODELS: dict[str, type[BaseModel]] = {
"thread/realtime/itemAdded": ThreadRealtimeItemAddedNotification,
"thread/realtime/outputAudio/delta": ThreadRealtimeOutputAudioDeltaNotification,
"thread/realtime/started": ThreadRealtimeStartedNotification,
"thread/realtime/transcriptUpdated": ThreadRealtimeTranscriptUpdatedNotification,
"thread/started": ThreadStartedNotification,
"thread/status/changed": ThreadStatusChangedNotification,
"thread/tokenUsage/updated": ThreadTokenUsageUpdatedNotification,

File diff suppressed because it is too large Load Diff

View File

@@ -29,22 +29,7 @@ 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}"
)
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}")
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)
@@ -70,18 +55,18 @@ def test_generate_types_wires_all_generation_steps() -> None:
)
assert generate_types_fn is not None
calls = {
node.func.id
for node in ast.walk(generate_types_fn)
if isinstance(node, ast.Call) and isinstance(node.func, ast.Name)
}
calls: list[str] = []
for node in generate_types_fn.body:
if isinstance(node, ast.Expr) and isinstance(node.value, ast.Call):
fn = node.value.func
if isinstance(fn, ast.Name):
calls.append(fn.id)
assert {
"_generate_json_schema_from_runtime",
assert calls == [
"generate_v2_all",
"generate_notification_registry",
"generate_public_api_flat_methods",
} <= calls
]
def test_schema_normalization_only_flattens_string_literal_oneofs() -> None:
@@ -183,9 +168,7 @@ 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] = []
@@ -215,14 +198,6 @@ 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)
@@ -260,7 +235,6 @@ 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/**"],
@@ -270,51 +244,23 @@ 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}
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
assert build_data_assignments == {"pure_python": False, "infer_tag": True}
def test_python_release_version_normalization() -> None:
def test_stage_runtime_release_copies_binary_and_sets_version(tmp_path: Path) -> None:
script = _load_update_script_module()
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 = tmp_path / script.runtime_binary_name()
fake_binary.write_text("fake codex\n")
staged = script.stage_python_runtime_package(
tmp_path / "runtime-stage",
"1.2.3-alpha.4",
bundle_dir,
"1.2.3",
fake_binary,
)
assert staged == tmp_path / "runtime-stage"
assert script.staged_runtime_bin_path(staged).read_text() == "fake codex\n"
pyproject = (staged / "pyproject.toml").read_text()
assert 'name = "openai-codex-cli-bin"' in pyproject
assert 'version = "1.2.3a4"' in pyproject
assert 'version = "1.2.3"' in (staged / "pyproject.toml").read_text()
def test_stage_runtime_release_replaces_existing_staging_dir(tmp_path: Path) -> None:
@@ -324,15 +270,13 @@ def test_stage_runtime_release_replaces_existing_staging_dir(tmp_path: Path) ->
old_file.parent.mkdir(parents=True)
old_file.write_text("stale")
bundle_dir = tmp_path / "bundle"
bundle_dir.mkdir()
fake_binary = bundle_dir / script.runtime_binary_name()
fake_binary = tmp_path / script.runtime_binary_name()
fake_binary.write_text("fake codex\n")
staged = script.stage_python_runtime_package(
staging_dir,
"1.2.3",
bundle_dir,
fake_binary,
)
assert staged == staging_dir
@@ -340,132 +284,13 @@ 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_runtime_release_normalizes_target_suffixed_names(
tmp_path: Path,
) -> None:
def test_stage_sdk_release_injects_exact_runtime_pin(tmp_path: Path) -> None:
script = _load_update_script_module()
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",
)
staged = script.stage_python_sdk_package(tmp_path / "sdk-stage", "0.2.1", "1.2.3")
pyproject = (staged / "pyproject.toml").read_text()
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 'version = "0.2.1"' in pyproject
assert '"codex-cli-bin==1.2.3"' in pyproject
assert not any((staged / "src" / "codex_app_server").glob("bin/**"))
@@ -494,8 +319,8 @@ def test_stage_sdk_runs_type_generation_before_staging(tmp_path: Path) -> None:
]
)
def fake_generate_types(runtime_version: str | None) -> None:
calls.append(f"generate_types:{runtime_version}")
def fake_generate_types() -> None:
calls.append("generate_types")
def fake_stage_sdk_package(
_staging_dir: Path, _sdk_version: str, _runtime_version: str
@@ -504,7 +329,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_bundle_dir: Path
_staging_dir: Path, _runtime_version: str, _runtime_binary: Path
) -> Path:
raise AssertionError("runtime staging should not run for stage-sdk")
@@ -520,91 +345,25 @@ def test_stage_sdk_runs_type_generation_before_staging(tmp_path: Path) -> None:
script.run_command(args, ops)
assert calls == ["generate_types:None", "stage_sdk"]
def test_generate_types_accepts_runtime_version_override() -> None:
script = _load_update_script_module()
calls: list[str] = []
args = script.parse_args(
[
"generate-types",
"--runtime-version",
"1.2.3-alpha.4",
]
)
def fake_generate_types(runtime_version: str | None) -> None:
calls.append(f"generate_types:{runtime_version}")
def fake_stage_sdk_package(
_staging_dir: Path, _sdk_version: str, _runtime_version: str
) -> Path:
raise AssertionError("sdk staging should not run for generate-types")
def fake_stage_runtime_package(
_staging_dir: Path, _runtime_version: str, _runtime_bundle_dir: Path
) -> Path:
raise AssertionError("runtime staging should not run for generate-types")
def fake_current_sdk_version() -> str:
return "0.2.0"
ops = script.CliOps(
generate_types=fake_generate_types,
stage_python_sdk_package=fake_stage_sdk_package,
stage_python_runtime_package=fake_stage_runtime_package,
current_sdk_version=fake_current_sdk_version,
)
script.run_command(args, ops)
assert calls == ["generate_types:1.2.3-alpha.4"]
def test_runtime_schema_generator_uses_app_server_json_schema_command(
tmp_path: Path,
) -> None:
script = _load_update_script_module()
codex_bin = tmp_path / "codex"
out_dir = tmp_path / "schema"
args_path = tmp_path / "args.txt"
codex_bin.write_text(
"#!/usr/bin/env sh\n"
f'printf \'%s\\n\' "$@" > "{args_path}"\n'
'mkdir -p "$4"\n'
"printf '{}' > \"$4/codex_app_server_protocol.v2.schemas.json\"\n"
"printf '{}' > \"$4/ServerNotification.json\"\n"
)
codex_bin.chmod(0o755)
script._run_runtime_schema_generator(codex_bin, out_dir)
assert args_path.read_text().splitlines() == [
"app-server",
"generate-json-schema",
"--out",
str(out_dir),
]
assert calls == ["generate_types", "stage_sdk"]
def test_stage_runtime_stages_binary_without_type_generation(tmp_path: Path) -> None:
script = _load_update_script_module()
bundle_dir = tmp_path / "bundle"
bundle_dir.mkdir()
(bundle_dir / script.runtime_binary_name()).write_text("fake codex\n")
fake_binary = tmp_path / script.runtime_binary_name()
fake_binary.write_text("fake codex\n")
calls: list[str] = []
args = script.parse_args(
[
"stage-runtime",
str(tmp_path / "runtime-stage"),
str(bundle_dir),
str(fake_binary),
"--runtime-version",
"1.2.3",
]
)
def fake_generate_types(_runtime_version: str | None) -> None:
def fake_generate_types() -> None:
calls.append("generate_types")
def fake_stage_sdk_package(
@@ -613,7 +372,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_bundle_dir: Path
_staging_dir: Path, _runtime_version: str, _runtime_binary: Path
) -> Path:
calls.append("stage_runtime")
return tmp_path / "runtime-stage"