Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
33 changes: 33 additions & 0 deletions .github/workflows/sdk.yml
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,39 @@ on:
pull_request: {}

jobs:
python-sdk:
runs-on:
group: codex-runners
labels: codex-linux-x64
timeout-minutes: 10
steps:
- name: Checkout repository
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
ref: ${{ github.event_name == 'pull_request' && github.event.pull_request.head.sha || github.sha }}
persist-credentials: false

- name: Test Python SDK
shell: bash
run: |
set -euo pipefail

# Run inside Alpine so dependency resolution exercises the pinned
# runtime wheel on the same Linux wheel family that CI installs.
docker run --rm \
--user "$(id -u):$(id -g)" \
-e HOME=/tmp/codex-python-sdk-home \
-e UV_LINK_MODE=copy \
-v "${GITHUB_WORKSPACE}:${GITHUB_WORKSPACE}" \
-w "${GITHUB_WORKSPACE}/sdk/python" \
python:3.12-alpine \
sh -euxc '
python -m venv /tmp/uv
/tmp/uv/bin/python -m pip install uv==0.11.3
/tmp/uv/bin/uv sync --extra dev --frozen
/tmp/uv/bin/uv run --extra dev pytest
'

sdks:
runs-on:
group: codex-runners
Expand Down
5 changes: 4 additions & 1 deletion sdk/python/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,9 @@

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 currently sourced from the bundled v2 schema and exposed as Pydantic models with snake_case Python fields that serialize back to the app-server’s camelCase wire format.
The generated wire-model layer is sourced from the pinned `openai-codex-cli-bin`
runtime package and exposed as Pydantic models with snake_case Python fields
that serialize back to the app-server’s camelCase wire format.

## Install

Expand Down Expand Up @@ -68,6 +70,7 @@ notebook bootstrap the pinned runtime package automatically.

```bash
cd sdk/python
uv sync
python scripts/update_sdk_artifacts.py generate-types
python scripts/update_sdk_artifacts.py \
stage-sdk \
Expand Down
161 changes: 132 additions & 29 deletions sdk/python/scripts/update_sdk_artifacts.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@

import argparse
import importlib
import importlib.metadata
import json
import platform
import re
Expand Down Expand Up @@ -33,19 +34,14 @@ def python_runtime_root() -> Path:
return repo_root() / "sdk" / "python-runtime"


def schema_bundle_path() -> Path:
return (
repo_root()
/ "codex-rs"
/ "app-server-protocol"
/ "schema"
/ "json"
/ "codex_app_server_protocol.v2.schemas.json"
)
def sdk_pyproject_path() -> Path:
"""Return the SDK pyproject file that owns package pins and versions."""
return sdk_root() / "pyproject.toml"


def schema_root_dir() -> Path:
return repo_root() / "codex-rs" / "app-server-protocol" / "schema" / "json"
def schema_bundle_path(schema_dir: Path) -> Path:
"""Return the aggregate v2 schema bundle emitted by the runtime binary."""
return schema_dir / "codex_app_server_protocol.v2.schemas.json"


def _is_windows() -> bool:
Expand Down Expand Up @@ -79,14 +75,67 @@ def run_python_module(module: str, args: list[str], cwd: Path) -> None:
def current_sdk_version() -> str:
match = re.search(
r'^version = "([^"]+)"$',
(sdk_root() / "pyproject.toml").read_text(),
sdk_pyproject_path().read_text(),
flags=re.MULTILINE,
)
if match is None:
raise RuntimeError("Could not determine Python SDK version from pyproject.toml")
return match.group(1)


def pinned_runtime_version() -> str:
"""Read the exact runtime package pin used for schema generation."""
pyproject_text = sdk_pyproject_path().read_text()
match = re.search(r"(?ms)^dependencies = \[(.*?)\]$", pyproject_text)
if match is None:
raise RuntimeError(
"Could not find dependencies array in sdk/python/pyproject.toml"
)

pins = re.findall(
rf'"{re.escape(RUNTIME_DISTRIBUTION_NAME)}==([^"]+)"',
match.group(1),
)
if len(pins) != 1:
raise RuntimeError(
f"Expected exactly one {RUNTIME_DISTRIBUTION_NAME} dependency pin "
"in sdk/python/pyproject.toml"
)
return normalize_codex_version(pins[0])


def pinned_runtime_codex_path() -> Path:
"""Return the bundled Codex binary from the installed pinned runtime wheel."""
expected_version = pinned_runtime_version()
try:
installed_version = importlib.metadata.version(RUNTIME_DISTRIBUTION_NAME)
except importlib.metadata.PackageNotFoundError as exc:
raise RuntimeError(
f"Install {RUNTIME_DISTRIBUTION_NAME}=={expected_version} before "
"generating Python SDK types."
) from exc

normalized_installed_version = normalize_codex_version(installed_version)
if normalized_installed_version != expected_version:
raise RuntimeError(
f"Expected {RUNTIME_DISTRIBUTION_NAME}=={expected_version}, "
f"but found {installed_version}."
)

try:
from codex_cli_bin import bundled_codex_path
except ImportError as exc:
raise RuntimeError(
f"Installed {RUNTIME_DISTRIBUTION_NAME} package does not expose "
"bundled_codex_path."
) from exc

codex_path = bundled_codex_path()
if not codex_path.exists():
raise RuntimeError(f"Pinned Codex runtime binary not found at {codex_path}.")
return codex_path


def normalize_codex_version(version: str) -> str:
normalized = version.strip()
if normalized.startswith("rust-v"):
Expand Down Expand Up @@ -488,8 +537,28 @@ def _annotate_schema(value: Any, base: str | None = None) -> None:
_annotate_schema(child, base)


def _normalized_schema_bundle_text() -> str:
schema = json.loads(schema_bundle_path().read_text())
def generate_schema_from_pinned_runtime(schema_dir: Path) -> Path:
"""Generate app-server schemas by invoking the installed pinned runtime binary."""
codex_path = pinned_runtime_codex_path()
if schema_dir.exists():
shutil.rmtree(schema_dir)
schema_dir.mkdir(parents=True)
run(
[
str(codex_path),
"app-server",
"generate-json-schema",
"--out",
str(schema_dir),
],
cwd=sdk_root(),
)
return schema_dir


def _normalized_schema_bundle_text(schema_dir: Path) -> str:
"""Normalize the schema bundle before feeding it to the Python type generator."""
schema = json.loads(schema_bundle_path(schema_dir).read_text())
definitions = schema.get("definitions", {})
if isinstance(definitions, dict):
for definition in definitions.values():
Expand All @@ -501,16 +570,17 @@ def _normalized_schema_bundle_text() -> str:
return json.dumps(schema, indent=2, sort_keys=True) + "\n"


def generate_v2_all() -> None:
def generate_v2_all(schema_dir: Path) -> None:
"""Regenerate the Pydantic v2 protocol model module from runtime schemas."""
out_path = sdk_root() / "src" / "codex_app_server" / "generated" / "v2_all.py"
out_dir = out_path.parent
old_package_dir = out_dir / "v2_all"
if old_package_dir.exists():
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().name
normalized_bundle.write_text(_normalized_schema_bundle_text())
normalized_bundle = Path(td) / schema_bundle_path(schema_dir).name
normalized_bundle.write_text(_normalized_schema_bundle_text(schema_dir))
run_python_module(
"datamodel_code_generator",
[
Expand Down Expand Up @@ -547,9 +617,10 @@ def generate_v2_all() -> None:
_normalize_generated_timestamps(out_path)


def _notification_specs() -> list[tuple[str, str]]:
def _notification_specs(schema_dir: Path) -> list[tuple[str, str]]:
"""Map each server notification method to its generated payload model class."""
server_notifications = json.loads(
(schema_root_dir() / "ServerNotification.json").read_text()
(schema_dir / "ServerNotification.json").read_text()
)
one_of = server_notifications.get("oneOf", [])
generated_source = (
Expand Down Expand Up @@ -587,11 +658,12 @@ def _notification_specs() -> list[tuple[str, str]]:


def _notification_turn_id_specs(
schema_dir: Path,
specs: list[tuple[str, str]],
) -> tuple[list[str], list[str]]:
"""Classify generated notification payloads by where the turn id lives."""
"""Classify notification payloads by where their turn id is carried."""
server_notifications = json.loads(
(schema_root_dir() / "ServerNotification.json").read_text()
(schema_dir / "ServerNotification.json").read_text()
)
definitions = server_notifications.get("definitions", {})
if not isinstance(definitions, dict):
Expand Down Expand Up @@ -625,18 +697,21 @@ def _type_tuple_source(class_names: list[str]) -> str:
return "(\n" + "".join(f" {class_name},\n" for class_name in class_names) + ")"


def generate_notification_registry() -> None:
"""Regenerate notification models and routing metadata from generated schemas."""
def generate_notification_registry(schema_dir: Path) -> None:
"""Regenerate notification dispatch metadata from the runtime notification schema."""
out = (
sdk_root()
/ "src"
/ "codex_app_server"
/ "generated"
/ "notification_registry.py"
)
specs = _notification_specs()
specs = _notification_specs(schema_dir)
class_names = sorted({class_name for _, class_name in specs})
direct_turn_id_types, nested_turn_types = _notification_turn_id_specs(specs)
direct_turn_id_types, nested_turn_types = _notification_turn_id_specs(
schema_dir,
specs,
)

lines = [
"# Auto-generated by scripts/update_sdk_artifacts.py",
Expand Down Expand Up @@ -757,8 +832,12 @@ def _camel_to_snake(name: str) -> str:
def _load_public_fields(
module_name: str, class_name: str, *, exclude: set[str] | None = None
) -> list[PublicFieldSpec]:
"""Load generated model fields used to render the ergonomic public methods."""
exclude = exclude or set()
module = importlib.import_module(module_name)
if module_name == "codex_app_server.generated.v2_all":
module = _load_generated_v2_all_module()
else:
module = importlib.import_module(module_name)
model = getattr(module, class_name)
fields: list[PublicFieldSpec] = []
for name, field in model.model_fields.items():
Expand All @@ -780,6 +859,20 @@ def _load_public_fields(
return fields


def _load_generated_v2_all_module() -> types.ModuleType:
"""Import the freshly generated v2_all module without importing package init."""
module_name = "_codex_app_server_generated_v2_all_for_artifacts"
sys.modules.pop(module_name, None)
module_path = sdk_root() / "src" / "codex_app_server" / "generated" / "v2_all.py"
spec = importlib.util.spec_from_file_location(module_name, module_path)
if spec is None or spec.loader is None:
raise RuntimeError(f"Failed to load generated module from {module_path}")
module = importlib.util.module_from_spec(spec)
sys.modules[module_name] = module
spec.loader.exec_module(module)
return module


def _kw_signature_lines(fields: list[PublicFieldSpec]) -> list[str]:
lines: list[str] = []
for field in fields:
Expand Down Expand Up @@ -989,6 +1082,7 @@ def _render_async_thread_block(


def generate_public_api_flat_methods() -> None:
"""Regenerate the public convenience methods from generated protocol models."""
src_dir = sdk_root() / "src"
public_api_path = src_dir / "codex_app_server" / "api.py"
if not public_api_path.exists():
Expand Down Expand Up @@ -1054,15 +1148,24 @@ def generate_public_api_flat_methods() -> None:
_render_async_thread_block(turn_start_fields),
)
public_api_path.write_text(source)
run_python_module("ruff", ["format", str(public_api_path)], cwd=sdk_root())


def generate_types() -> None:
def generate_types_from_schema_dir(schema_dir: Path) -> None:
"""Regenerate every SDK artifact derived from an existing schema directory."""
# v2_all is the authoritative generated surface.
generate_v2_all()
generate_notification_registry()
generate_v2_all(schema_dir)
generate_notification_registry(schema_dir)
generate_public_api_flat_methods()


def generate_types() -> None:
"""Generate schemas from the pinned runtime and then refresh SDK artifacts."""
with tempfile.TemporaryDirectory(prefix="codex-python-schema-") as td:
schema_dir = generate_schema_from_pinned_runtime(Path(td) / "schema")
generate_types_from_schema_dir(schema_dir)


def build_parser() -> argparse.ArgumentParser:
parser = argparse.ArgumentParser(description="Single SDK maintenance entrypoint")
subparsers = parser.add_subparsers(dest="command", required=True)
Expand Down
4 changes: 2 additions & 2 deletions sdk/python/src/codex_app_server/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -22,12 +22,12 @@
ReasoningSummary,
SandboxMode,
SandboxPolicy,
ServiceTier,
ThreadItem,
ThreadForkParams,
ThreadListParams,
ThreadResumeParams,
ThreadSortKey,
ThreadSource,
ThreadSourceKind,
ThreadStartParams,
ThreadTokenUsageUpdatedNotification,
Expand Down Expand Up @@ -86,11 +86,11 @@
"ReasoningSummary",
"SandboxMode",
"SandboxPolicy",
"ServiceTier",
"ThreadStartParams",
"ThreadResumeParams",
"ThreadListParams",
"ThreadSortKey",
"ThreadSource",
"ThreadSourceKind",
"ThreadForkParams",
"TurnStatus",
Expand Down
Loading
Loading