mirror of
https://github.com/openai/codex.git
synced 2026-04-24 14:45:27 +00:00
python-sdk: generate types from pinned runtime schema
This commit is contained in:
@@ -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 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 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-server’s camelCase wire format.
|
||||
|
||||
## Install
|
||||
|
||||
@@ -87,6 +87,7 @@ python scripts/update_sdk_artifacts.py \
|
||||
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
|
||||
|
||||
@@ -60,8 +60,10 @@ Common causes:
|
||||
- incompatible/old app-server
|
||||
|
||||
Maintainers stage releases by building the SDK once and the runtime once per
|
||||
platform with the same pinned runtime version. Publish `openai-codex-cli-bin`
|
||||
as platform wheels only; do not publish an sdist:
|
||||
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:
|
||||
|
||||
```bash
|
||||
cd sdk/python
|
||||
|
||||
@@ -3,6 +3,7 @@ from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import importlib
|
||||
import importlib.util
|
||||
import json
|
||||
import platform
|
||||
import re
|
||||
@@ -33,21 +34,20 @@ 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 schema_bundle_path(schema_dir: Path | None = None) -> Path:
|
||||
return schema_root_dir(schema_dir) / "codex_app_server_protocol.v2.schemas.json"
|
||||
|
||||
|
||||
def schema_root_dir() -> Path:
|
||||
def schema_root_dir(schema_dir: Path | None = None) -> Path:
|
||||
if schema_dir is not None:
|
||||
return schema_dir
|
||||
return repo_root() / "codex-rs" / "app-server-protocol" / "schema" / "json"
|
||||
|
||||
|
||||
def runtime_setup_path() -> Path:
|
||||
return sdk_root() / "_runtime_setup.py"
|
||||
|
||||
|
||||
def _is_windows(system_name: str | None = None) -> bool:
|
||||
return (system_name or platform.system()).lower().startswith("win")
|
||||
|
||||
@@ -297,6 +297,68 @@ def _find_runtime_bundle_file(runtime_bundle_dir: Path, destination_name: str) -
|
||||
)
|
||||
|
||||
|
||||
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:
|
||||
@@ -533,8 +595,8 @@ 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 _normalized_schema_bundle_text(schema_dir: Path | None = None) -> str:
|
||||
schema = json.loads(schema_bundle_path(schema_dir).read_text())
|
||||
definitions = schema.get("definitions", {})
|
||||
if isinstance(definitions, dict):
|
||||
for definition in definitions.values():
|
||||
@@ -546,7 +608,7 @@ 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 = None) -> 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"
|
||||
@@ -554,8 +616,8 @@ def generate_v2_all() -> 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().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",
|
||||
[
|
||||
@@ -592,9 +654,9 @@ def generate_v2_all() -> None:
|
||||
_normalize_generated_timestamps(out_path)
|
||||
|
||||
|
||||
def _notification_specs() -> list[tuple[str, str]]:
|
||||
def _notification_specs(schema_dir: Path | None = None) -> list[tuple[str, str]]:
|
||||
server_notifications = json.loads(
|
||||
(schema_root_dir() / "ServerNotification.json").read_text()
|
||||
(schema_root_dir(schema_dir) / "ServerNotification.json").read_text()
|
||||
)
|
||||
one_of = server_notifications.get("oneOf", [])
|
||||
generated_source = (
|
||||
@@ -631,7 +693,7 @@ def _notification_specs() -> list[tuple[str, str]]:
|
||||
return specs
|
||||
|
||||
|
||||
def generate_notification_registry() -> None:
|
||||
def generate_notification_registry(schema_dir: Path | None = None) -> None:
|
||||
out = (
|
||||
sdk_root()
|
||||
/ "src"
|
||||
@@ -639,7 +701,7 @@ def generate_notification_registry() -> None:
|
||||
/ "generated"
|
||||
/ "notification_registry.py"
|
||||
)
|
||||
specs = _notification_specs()
|
||||
specs = _notification_specs(schema_dir)
|
||||
class_names = sorted({class_name for _, class_name in specs})
|
||||
|
||||
lines = [
|
||||
@@ -694,7 +756,7 @@ class PublicFieldSpec:
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class CliOps:
|
||||
generate_types: Callable[[], None]
|
||||
generate_types: Callable[[str | None], None]
|
||||
stage_python_sdk_package: Callable[[Path, str, str], Path]
|
||||
stage_python_runtime_package: Callable[[Path, str, Path], Path]
|
||||
current_sdk_version: Callable[[], str]
|
||||
@@ -1038,20 +1100,30 @@ def generate_public_api_flat_methods() -> None:
|
||||
public_api_path.write_text(source)
|
||||
|
||||
|
||||
def generate_types() -> None:
|
||||
# v2_all is the authoritative generated surface.
|
||||
generate_v2_all()
|
||||
generate_notification_registry()
|
||||
generate_public_api_flat_methods()
|
||||
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 build_parser() -> argparse.ArgumentParser:
|
||||
parser = argparse.ArgumentParser(description="Single SDK maintenance entrypoint")
|
||||
subparsers = parser.add_subparsers(dest="command", required=True)
|
||||
|
||||
subparsers.add_parser(
|
||||
generate_types_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",
|
||||
@@ -1109,9 +1181,9 @@ def default_cli_ops() -> CliOps:
|
||||
|
||||
def run_command(args: argparse.Namespace, ops: CliOps) -> None:
|
||||
if args.command == "generate-types":
|
||||
ops.generate_types()
|
||||
ops.generate_types(args.runtime_version)
|
||||
elif args.command == "stage-sdk":
|
||||
ops.generate_types()
|
||||
ops.generate_types(None)
|
||||
ops.stage_python_sdk_package(
|
||||
args.staging_dir,
|
||||
args.sdk_version or ops.current_sdk_version(),
|
||||
|
||||
@@ -146,7 +146,6 @@ class Codex:
|
||||
sandbox: SandboxMode | None = None,
|
||||
service_name: str | None = None,
|
||||
service_tier: ServiceTier | None = None,
|
||||
session_start_source: ThreadStartSource | None = None,
|
||||
) -> Thread:
|
||||
params = ThreadStartParams(
|
||||
approval_policy=approval_policy,
|
||||
@@ -162,7 +161,6 @@ class Codex:
|
||||
sandbox=sandbox,
|
||||
service_name=service_name,
|
||||
service_tier=service_tier,
|
||||
session_start_source=session_start_source,
|
||||
)
|
||||
started = self._client.thread_start(params)
|
||||
return Thread(self._client, started.thread.id)
|
||||
@@ -338,7 +336,6 @@ class AsyncCodex:
|
||||
sandbox: SandboxMode | None = None,
|
||||
service_name: str | None = None,
|
||||
service_tier: ServiceTier | None = None,
|
||||
session_start_source: ThreadStartSource | None = None,
|
||||
) -> AsyncThread:
|
||||
await self._ensure_initialized()
|
||||
params = ThreadStartParams(
|
||||
@@ -355,7 +352,6 @@ class AsyncCodex:
|
||||
sandbox=sandbox,
|
||||
service_name=service_name,
|
||||
service_tier=service_tier,
|
||||
session_start_source=session_start_source,
|
||||
)
|
||||
started = await self._client.thread_start(params)
|
||||
return AsyncThread(self, started.thread.id)
|
||||
|
||||
@@ -17,7 +17,6 @@ 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
|
||||
@@ -27,7 +26,6 @@ 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
|
||||
@@ -44,9 +42,7 @@ from .v2_all import ThreadRealtimeClosedNotification
|
||||
from .v2_all import ThreadRealtimeErrorNotification
|
||||
from .v2_all import ThreadRealtimeItemAddedNotification
|
||||
from .v2_all import ThreadRealtimeOutputAudioDeltaNotification
|
||||
from .v2_all import ThreadRealtimeSdpNotification
|
||||
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
|
||||
@@ -67,7 +63,6 @@ NOTIFICATION_MODELS: dict[str, type[BaseModel]] = {
|
||||
"configWarning": ConfigWarningNotification,
|
||||
"deprecationNotice": DeprecationNoticeNotification,
|
||||
"error": ErrorNotification,
|
||||
"fs/changed": FsChangedNotification,
|
||||
"fuzzyFileSearch/sessionCompleted": FuzzyFileSearchSessionCompletedNotification,
|
||||
"fuzzyFileSearch/sessionUpdated": FuzzyFileSearchSessionUpdatedNotification,
|
||||
"hook/completed": HookCompletedNotification,
|
||||
@@ -86,7 +81,6 @@ 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,
|
||||
@@ -98,9 +92,7 @@ NOTIFICATION_MODELS: dict[str, type[BaseModel]] = {
|
||||
"thread/realtime/error": ThreadRealtimeErrorNotification,
|
||||
"thread/realtime/itemAdded": ThreadRealtimeItemAddedNotification,
|
||||
"thread/realtime/outputAudio/delta": ThreadRealtimeOutputAudioDeltaNotification,
|
||||
"thread/realtime/sdp": ThreadRealtimeSdpNotification,
|
||||
"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
@@ -70,18 +70,18 @@ def test_generate_types_wires_all_generation_steps() -> None:
|
||||
)
|
||||
assert generate_types_fn is not None
|
||||
|
||||
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)
|
||||
calls = {
|
||||
node.func.id
|
||||
for node in ast.walk(generate_types_fn)
|
||||
if isinstance(node, ast.Call) and isinstance(node.func, ast.Name)
|
||||
}
|
||||
|
||||
assert calls == [
|
||||
assert {
|
||||
"_generate_json_schema_from_runtime",
|
||||
"generate_v2_all",
|
||||
"generate_notification_registry",
|
||||
"generate_public_api_flat_methods",
|
||||
]
|
||||
} <= calls
|
||||
|
||||
|
||||
def test_schema_normalization_only_flattens_string_literal_oneofs() -> None:
|
||||
@@ -494,8 +494,8 @@ def test_stage_sdk_runs_type_generation_before_staging(tmp_path: Path) -> None:
|
||||
]
|
||||
)
|
||||
|
||||
def fake_generate_types() -> None:
|
||||
calls.append("generate_types")
|
||||
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
|
||||
@@ -520,7 +520,72 @@ def test_stage_sdk_runs_type_generation_before_staging(tmp_path: Path) -> None:
|
||||
|
||||
script.run_command(args, ops)
|
||||
|
||||
assert calls == ["generate_types", "stage_sdk"]
|
||||
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),
|
||||
]
|
||||
|
||||
|
||||
def test_stage_runtime_stages_binary_without_type_generation(tmp_path: Path) -> None:
|
||||
@@ -539,7 +604,7 @@ def test_stage_runtime_stages_binary_without_type_generation(tmp_path: Path) ->
|
||||
]
|
||||
)
|
||||
|
||||
def fake_generate_types() -> None:
|
||||
def fake_generate_types(_runtime_version: str | None) -> None:
|
||||
calls.append("generate_types")
|
||||
|
||||
def fake_stage_sdk_package(
|
||||
|
||||
@@ -56,7 +56,6 @@ def test_generated_public_signatures_are_snake_case_and_typed() -> None:
|
||||
"sandbox",
|
||||
"service_name",
|
||||
"service_tier",
|
||||
"session_start_source",
|
||||
],
|
||||
Codex.thread_list: [
|
||||
"archived",
|
||||
@@ -132,7 +131,6 @@ def test_generated_public_signatures_are_snake_case_and_typed() -> None:
|
||||
"sandbox",
|
||||
"service_name",
|
||||
"service_tier",
|
||||
"session_start_source",
|
||||
],
|
||||
AsyncCodex.thread_list: [
|
||||
"archived",
|
||||
|
||||
Reference in New Issue
Block a user