mirror of
https://github.com/openai/codex.git
synced 2026-10-26 18:38:47 +00:00
Generate Python SDK types from repository app-server schemas (#44032)
## Why Keep Python protocol models aligned with the checked-in app-server schemas and preserve reviewed generated artifacts when staging SDK releases. ## What changed - Generate SDK types from the schema directory configured in `pyproject.toml`, with a `--schema-dir` override, instead of invoking the pinned runtime binary. - Refresh Python artifacts through `just write-app-server-schema` for standard repository exports. Skip SDK updates for scratch and experimental exports. - Regenerate protocol models and notification dispatch, deriving the known payload union from the registry so `Notification.payload` covers every registered event. - Explicitly allowlist convenience API parameters so new protocol fields do not silently expand method signatures. Preserve existing approval path wrappers. - Stage SDK releases using checked-in generated files without regenerating them. ## Testing Add coverage for schema selection, refresh gating and failure handling, release artifact preservation, notification payload typing, and approval path compatibility. Update the generation drift test to use repository schemas. GitOrigin-RevId: fab350b07cf170258b91fbafa8da384aeb2e3bd7
This commit is contained in:
@@ -10,9 +10,13 @@ import urllib.error
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
import tomllib
|
||||
from pydantic import ValidationError
|
||||
|
||||
try:
|
||||
import tomllib
|
||||
except ModuleNotFoundError:
|
||||
import tomli as tomllib
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[1]
|
||||
|
||||
|
||||
@@ -378,38 +382,76 @@ def test_generate_types_wires_all_generation_steps() -> None:
|
||||
]
|
||||
|
||||
|
||||
def _load_runtime_schema_bundle(tmp_path: Path) -> dict:
|
||||
"""Ask the pinned runtime package for a real schema bundle used by tests."""
|
||||
@pytest.mark.parametrize("schema_override", [None, "override-schema"])
|
||||
def test_generation_resolves_configured_schema_and_explicit_override(
|
||||
tmp_path: Path, monkeypatch: pytest.MonkeyPatch, schema_override: str | None
|
||||
) -> None:
|
||||
script = _load_update_script_module()
|
||||
schema_dir = script.generate_schema_from_pinned_runtime(tmp_path / "schema")
|
||||
sdk_dir = tmp_path / "sdk" / "python"
|
||||
sdk_dir.mkdir(parents=True)
|
||||
monkeypatch.setattr(script, "sdk_root", lambda: sdk_dir)
|
||||
monkeypatch.chdir(tmp_path)
|
||||
selected_schemas: list[Path] = []
|
||||
monkeypatch.setattr(script, "generate_types_from_schema_dir", selected_schemas.append)
|
||||
args = ["generate-types"]
|
||||
if schema_override is None:
|
||||
(sdk_dir / "pyproject.toml").write_text(
|
||||
'[tool.codex.codegen]\nschema-dir = "../../configured-schema"\n'
|
||||
)
|
||||
expected_schema = tmp_path / "configured-schema"
|
||||
else:
|
||||
args.extend(["--schema-dir", schema_override])
|
||||
expected_schema = tmp_path / schema_override
|
||||
|
||||
script.main(args)
|
||||
|
||||
assert selected_schemas == [expected_schema]
|
||||
|
||||
|
||||
def _load_repository_schema_bundle() -> dict:
|
||||
"""Read the repository app-server schema bundle used by generation."""
|
||||
script = _load_update_script_module()
|
||||
pyproject = tomllib.loads((ROOT / "pyproject.toml").read_text())
|
||||
schema_dir = ROOT / pyproject["tool"]["codex"]["codegen"]["schema-dir"]
|
||||
return json.loads(script.schema_bundle_path(schema_dir).read_text())
|
||||
|
||||
|
||||
def test_schema_normalization_only_flattens_string_literal_oneofs(
|
||||
tmp_path: Path,
|
||||
) -> None:
|
||||
"""Schema normalization should only flatten the enum-shaped oneOf variants."""
|
||||
def test_schema_normalization_flattens_string_literal_oneofs() -> None:
|
||||
script = _load_update_script_module()
|
||||
schema = _load_runtime_schema_bundle(tmp_path)
|
||||
definitions = schema["definitions"]
|
||||
flattened = [
|
||||
name
|
||||
for name, definition in definitions.items()
|
||||
if isinstance(definition, dict) and script._flatten_string_enum_one_of(definition.copy())
|
||||
]
|
||||
definition = {
|
||||
"title": "Mode",
|
||||
"description": "Allowed modes.",
|
||||
"oneOf": [
|
||||
{"type": "string", "enum": ["first"]},
|
||||
{"type": "string", "enum": ["second"]},
|
||||
],
|
||||
}
|
||||
|
||||
assert sorted(flattened) == [
|
||||
"AuthMode",
|
||||
"AutoCompactTokenLimitScope",
|
||||
"CommandExecOutputStream",
|
||||
"ConsumeAccountRateLimitResetCreditOutcome",
|
||||
"ExperimentalFeatureStage",
|
||||
"InputModality",
|
||||
"MessagePhase",
|
||||
"PluginAvailability",
|
||||
"ProcessOutputStream",
|
||||
"TurnItemsView",
|
||||
]
|
||||
assert script._flatten_string_enum_one_of(definition)
|
||||
assert definition == {
|
||||
"title": "Mode",
|
||||
"description": "Allowed modes.",
|
||||
"type": "string",
|
||||
"enum": ["first", "second"],
|
||||
}
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"branch",
|
||||
[
|
||||
{"type": "object", "properties": {"value": {"type": "string"}}},
|
||||
{"type": "string", "enum": ["first", "second"]},
|
||||
{"type": "string", "enum": [1]},
|
||||
{"type": "string", "enum": ["second"], "minLength": 2},
|
||||
],
|
||||
)
|
||||
def test_schema_normalization_preserves_nonliteral_unions(branch: dict) -> None:
|
||||
script = _load_update_script_module()
|
||||
definition = {"oneOf": [{"type": "string", "enum": ["first"]}, branch]}
|
||||
original = json.loads(json.dumps(definition))
|
||||
|
||||
assert not script._flatten_string_enum_one_of(definition)
|
||||
assert definition == original
|
||||
|
||||
|
||||
def test_schema_normalization_makes_chatgpt_account_email_nullable() -> None:
|
||||
@@ -438,12 +480,10 @@ def test_schema_normalization_makes_chatgpt_account_email_nullable() -> None:
|
||||
assert "email" in chatgpt_account["required"]
|
||||
|
||||
|
||||
def test_python_codegen_schema_annotation_adds_stable_variant_titles(
|
||||
tmp_path: Path,
|
||||
) -> None:
|
||||
def test_python_codegen_schema_annotation_adds_stable_variant_titles() -> None:
|
||||
"""Schema annotations should give generated protocol classes stable names."""
|
||||
script = _load_update_script_module()
|
||||
schema = _load_runtime_schema_bundle(tmp_path)
|
||||
schema = _load_repository_schema_bundle()
|
||||
script._annotate_schema(schema)
|
||||
definitions = schema["definitions"]
|
||||
|
||||
@@ -525,25 +565,6 @@ def test_runtime_distribution_name_is_consistent() -> None:
|
||||
)
|
||||
|
||||
|
||||
def test_source_sdk_template_pins_published_runtime() -> None:
|
||||
"""The source template should carry a development version and reviewed runtime pin."""
|
||||
script = _load_update_script_module()
|
||||
pyproject = tomllib.loads((ROOT / "pyproject.toml").read_text())
|
||||
|
||||
assert {
|
||||
"sdk_template_version": pyproject["project"]["version"],
|
||||
"runtime_pin": script.pinned_runtime_version(),
|
||||
"dependencies": pyproject["project"]["dependencies"],
|
||||
} == {
|
||||
"sdk_template_version": "0.0.0-dev",
|
||||
"runtime_pin": "0.147.0",
|
||||
"dependencies": [
|
||||
"pydantic>=2.12",
|
||||
"openai-codex-cli-bin==0.147.0",
|
||||
],
|
||||
}
|
||||
|
||||
|
||||
def test_source_sdk_package_declares_stable_documentation() -> None:
|
||||
"""Public package metadata should link stable docs."""
|
||||
pyproject = tomllib.loads((ROOT / "pyproject.toml").read_text())
|
||||
@@ -858,11 +879,24 @@ def test_runtime_package_layout_is_included_by_wheel_config(
|
||||
]
|
||||
|
||||
|
||||
def test_stage_sdk_release_preserves_reviewed_runtime_pin(tmp_path: Path) -> None:
|
||||
def test_stage_sdk_release_packages_reviewed_artifacts(tmp_path: Path) -> None:
|
||||
script = _load_update_script_module()
|
||||
staged = script.stage_python_sdk_package(
|
||||
tmp_path / "sdk-stage",
|
||||
"0.147.0",
|
||||
staged = tmp_path / "sdk-stage"
|
||||
source_project = tomllib.loads((ROOT / "pyproject.toml").read_text())
|
||||
generated_paths = [
|
||||
"src/openai_codex/generated/v2_all.py",
|
||||
"src/openai_codex/generated/notification_registry.py",
|
||||
"src/openai_codex/api.py",
|
||||
]
|
||||
reviewed_artifacts = {path: (ROOT / path).read_bytes() for path in generated_paths}
|
||||
|
||||
script.main(
|
||||
[
|
||||
"stage-sdk",
|
||||
str(staged),
|
||||
"--sdk-version",
|
||||
"0.153.0",
|
||||
]
|
||||
)
|
||||
|
||||
pyproject = tomllib.loads((staged / "pyproject.toml").read_text())
|
||||
@@ -872,12 +906,10 @@ def test_stage_sdk_release_preserves_reviewed_runtime_pin(tmp_path: Path) -> Non
|
||||
"dependencies": pyproject["project"]["dependencies"],
|
||||
} == {
|
||||
"name": "openai-codex",
|
||||
"version": "0.147.0",
|
||||
"dependencies": [
|
||||
"pydantic>=2.12",
|
||||
"openai-codex-cli-bin==0.147.0",
|
||||
],
|
||||
"version": "0.153.0",
|
||||
"dependencies": source_project["project"]["dependencies"],
|
||||
}
|
||||
assert {path: (staged / path).read_bytes() for path in generated_paths} == reviewed_artifacts
|
||||
assert (
|
||||
'__version__ = "0.147.0"'
|
||||
not in (staged / "src" / "openai_codex" / "__init__.py").read_text()
|
||||
@@ -933,48 +965,6 @@ def test_sdk_release_matches_stable_runtime(tmp_path: Path) -> None:
|
||||
}
|
||||
|
||||
|
||||
def test_stage_sdk_runs_type_generation_before_staging(tmp_path: Path) -> None:
|
||||
script = _load_update_script_module()
|
||||
calls: list[str] = []
|
||||
args = script.parse_args(
|
||||
[
|
||||
"stage-sdk",
|
||||
str(tmp_path / "sdk-stage"),
|
||||
"--sdk-version",
|
||||
"0.147.0",
|
||||
]
|
||||
)
|
||||
|
||||
def fake_generate_types() -> None:
|
||||
calls.append("generate_types")
|
||||
|
||||
def fake_stage_sdk_package(_staging_dir: Path, sdk_version: str) -> Path:
|
||||
calls.append(f"stage_sdk:{sdk_version}")
|
||||
return tmp_path / "sdk-stage"
|
||||
|
||||
def fake_stage_runtime_package(
|
||||
_staging_dir: Path,
|
||||
_runtime_version: str,
|
||||
_package_dir: Path,
|
||||
_platform_tag: str | None,
|
||||
) -> Path:
|
||||
raise AssertionError("runtime staging should not run for stage-sdk")
|
||||
|
||||
def fake_current_sdk_version() -> str:
|
||||
return "0.116.0a1"
|
||||
|
||||
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", "stage_sdk:0.147.0"]
|
||||
|
||||
|
||||
def test_stage_runtime_stages_package_without_type_generation(tmp_path: Path) -> None:
|
||||
script = _load_update_script_module()
|
||||
package_archive = _write_fake_codex_package_archive(tmp_path, script)
|
||||
@@ -991,7 +981,7 @@ def test_stage_runtime_stages_package_without_type_generation(tmp_path: Path) ->
|
||||
]
|
||||
)
|
||||
|
||||
def fake_generate_types() -> None:
|
||||
def fake_generate_types(_schema_dir: Path) -> None:
|
||||
calls.append("generate_types")
|
||||
|
||||
def fake_stage_sdk_package(_staging_dir: Path, _codex_version: str) -> Path:
|
||||
@@ -1006,14 +996,10 @@ def test_stage_runtime_stages_package_without_type_generation(tmp_path: Path) ->
|
||||
calls.append(f"stage_runtime:{codex_version}:{platform_tag}:{package_archive.name}")
|
||||
return tmp_path / "runtime-stage"
|
||||
|
||||
def fake_current_sdk_version() -> str:
|
||||
return "0.116.0a1"
|
||||
|
||||
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)
|
||||
|
||||
@@ -1,22 +1,28 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from pathlib import Path
|
||||
from typing import get_type_hints
|
||||
|
||||
import pytest
|
||||
|
||||
from openai_codex.client import CodexClient, _params_dict
|
||||
from openai_codex.generated.notification_registry import notification_turn_id
|
||||
from openai_codex.generated.v2_all import (
|
||||
AbsolutePathBuf,
|
||||
AccountRateLimitsUpdatedNotification,
|
||||
AccountUpdatedNotification,
|
||||
AgentMessageDeltaNotification,
|
||||
ApplyPatchGuardianApprovalReviewAction,
|
||||
ApprovalsReviewer,
|
||||
AuthRecoveryNotification,
|
||||
CommandGuardianApprovalReviewAction,
|
||||
GetAccountResponse,
|
||||
PlanType,
|
||||
ReasoningEffort,
|
||||
ReasoningEffortOption,
|
||||
ThreadForkParams,
|
||||
ThreadListParams,
|
||||
ThreadQueueChangedNotification,
|
||||
ThreadResumeResponse,
|
||||
ThreadStartParams,
|
||||
ThreadTokenUsageUpdatedNotification,
|
||||
@@ -30,6 +36,31 @@ from openai_codex.types import ThreadSource
|
||||
ROOT = Path(__file__).resolve().parents[1]
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("model", "fields"),
|
||||
[
|
||||
(
|
||||
CommandGuardianApprovalReviewAction,
|
||||
{"type": "command", "command": "pwd", "source": "shell"},
|
||||
),
|
||||
(
|
||||
ApplyPatchGuardianApprovalReviewAction,
|
||||
{"type": "applyPatch", "files": [AbsolutePathBuf("/workspace/file")]},
|
||||
),
|
||||
],
|
||||
)
|
||||
def test_approval_review_paths_preserve_existing_wrappers(model, fields) -> None:
|
||||
action = model(cwd=AbsolutePathBuf("/workspace"), **fields)
|
||||
expected = {
|
||||
**fields,
|
||||
"cwd": "/workspace",
|
||||
}
|
||||
if "files" in expected:
|
||||
expected["files"] = ["/workspace/file"]
|
||||
assert action.model_dump(mode="json") == expected
|
||||
assert isinstance(action.cwd, AbsolutePathBuf)
|
||||
|
||||
|
||||
def test_generated_params_models_are_snake_case_and_dump_by_alias() -> None:
|
||||
params = ThreadListParams(search_term="needle", limit=5)
|
||||
|
||||
@@ -209,6 +240,44 @@ def test_unknown_notifications_fall_back_to_unknown_payloads() -> None:
|
||||
assert event.payload.params["msg"] == {"type": "turn_aborted"}
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("method", "params", "expected"),
|
||||
[
|
||||
(
|
||||
"modelProvider/authRecoveryCompleted",
|
||||
{
|
||||
"provider": "openai",
|
||||
"message": "Authentication recovered",
|
||||
"threadId": "thread-1",
|
||||
"turnId": "turn-1",
|
||||
},
|
||||
AuthRecoveryNotification(
|
||||
provider="openai",
|
||||
message="Authentication recovered",
|
||||
thread_id="thread-1",
|
||||
turn_id="turn-1",
|
||||
),
|
||||
),
|
||||
(
|
||||
"thread/queue/changed",
|
||||
{"threadId": "thread-1"},
|
||||
ThreadQueueChangedNotification(thread_id="thread-1"),
|
||||
),
|
||||
("warning", {"message": "heads up"}, WarningNotification(message="heads up")),
|
||||
(
|
||||
"future/notification",
|
||||
{"newField": "value"},
|
||||
UnknownNotification(params={"newField": "value"}),
|
||||
),
|
||||
],
|
||||
)
|
||||
def test_decoded_notifications_match_the_declared_payload_type(method, params, expected) -> None:
|
||||
event = CodexClient()._coerce_notification(method, params)
|
||||
|
||||
assert event == Notification(method=method, payload=expected)
|
||||
assert isinstance(event.payload, get_type_hints(Notification)["payload"])
|
||||
|
||||
|
||||
def test_invalid_notification_payload_falls_back_to_unknown() -> None:
|
||||
client = CodexClient()
|
||||
event = client._coerce_notification("thread/tokenUsage/updated", {"threadId": "missing"})
|
||||
|
||||
@@ -1,11 +1,13 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import importlib.metadata
|
||||
import os
|
||||
import runpy
|
||||
import subprocess
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[1]
|
||||
GENERATED_TARGETS = [
|
||||
Path("src/openai_codex/generated/notification_registry.py"),
|
||||
@@ -35,14 +37,10 @@ def _snapshot_targets(root: Path) -> dict[str, dict[str, bytes] | bytes | None]:
|
||||
|
||||
|
||||
def test_generated_files_are_up_to_date():
|
||||
"""Regenerating from the pinned runtime package should leave artifacts unchanged."""
|
||||
"""Regenerating from repository schemas should leave reviewed artifacts unchanged."""
|
||||
before = _snapshot_targets(ROOT)
|
||||
|
||||
# Regenerate contract artifacts via the pinned runtime package, not a local
|
||||
# app-server binary from the checkout or CI environment.
|
||||
assert importlib.metadata.version("openai-codex-cli-bin") == "0.147.0"
|
||||
env = os.environ.copy()
|
||||
env.pop("CODEX_EXEC_PATH", None)
|
||||
python_bin = str(Path(sys.executable).parent)
|
||||
env["PATH"] = f"{python_bin}{os.pathsep}{env.get('PATH', '')}"
|
||||
|
||||
@@ -55,3 +53,44 @@ def test_generated_files_are_up_to_date():
|
||||
|
||||
after = _snapshot_targets(ROOT)
|
||||
assert before == after, "Generated files drifted after regeneration"
|
||||
|
||||
|
||||
@pytest.mark.parametrize("mode", ["repository", "scratch", "experimental"])
|
||||
def test_schema_refresh_only_updates_python_for_repository_schemas(monkeypatch, tmp_path, mode):
|
||||
script = ROOT.parents[1] / "codex-rs/app-server-protocol/scripts/write_schema_fixtures.py"
|
||||
arguments = {
|
||||
"repository": [],
|
||||
"scratch": ["--schema-root", str(tmp_path / "schema")],
|
||||
"experimental": ["--experimental"],
|
||||
}[mode]
|
||||
calls = []
|
||||
monkeypatch.setattr(sys, "argv", [str(script), *arguments])
|
||||
monkeypatch.setattr(subprocess, "run", lambda args, **kwargs: calls.append((args, kwargs)))
|
||||
|
||||
runpy.run_path(str(script), run_name="__main__")
|
||||
|
||||
assert [args[0] for args, _kwargs in calls] == (
|
||||
["cargo", "uv"] if mode == "repository" else ["cargo"]
|
||||
)
|
||||
assert all(kwargs["check"] for _args, kwargs in calls)
|
||||
if mode == "repository":
|
||||
assert calls[1][0][-3:] == [
|
||||
"generate-types",
|
||||
"--schema-dir",
|
||||
str(ROOT.parents[1] / "codex-rs/app-server-protocol/schema/json"),
|
||||
]
|
||||
|
||||
|
||||
def test_schema_generation_failure_does_not_update_python(monkeypatch):
|
||||
script = ROOT.parents[1] / "codex-rs/app-server-protocol/scripts/write_schema_fixtures.py"
|
||||
calls = []
|
||||
|
||||
def fail(args, **_kwargs):
|
||||
calls.append(args[0])
|
||||
raise subprocess.CalledProcessError(1, args)
|
||||
|
||||
monkeypatch.setattr(sys, "argv", [str(script)])
|
||||
monkeypatch.setattr(subprocess, "run", fail)
|
||||
with pytest.raises(subprocess.CalledProcessError):
|
||||
runpy.run_path(str(script), run_name="__main__")
|
||||
assert calls == ["cargo"]
|
||||
|
||||
Reference in New Issue
Block a user