From 5fe33443b0cc331f8e886a606c28b278b0c1812f Mon Sep 17 00:00:00 2001 From: Ahmed Ibrahim Date: Tue, 12 May 2026 00:42:26 +0300 Subject: [PATCH] [1/8] Pin Python SDK runtime dependency (#21891) ## Why The Python SDK depends on the app-server runtime package for the bundled `codex` binary and schema source of truth. That relationship should be explicit in package metadata instead of inferred from matching version numbers, so installers, lockfiles, and reviewers can see exactly which runtime the SDK expects. ## What - Declare `openai-codex-cli-bin==0.131.0a4` as a Python SDK dependency. - Update runtime setup helpers to resolve the runtime version from the declared dependency pin. - Refresh the SDK lockfile for the pinned runtime wheel. - Update package/runtime tests and docs that describe where the runtime version comes from. ## Stack 1. This PR `[1/8]` Pin Python SDK runtime dependency 2. #21893 `[2/8]` Generate Python SDK types from pinned runtime 3. #21895 `[3/8]` Run Python SDK tests in CI 4. #21896 `[4/8]` Define Python SDK public API surface 5. #21905 `[5/8]` Rename Python SDK package to `openai-codex` 6. #21910 `[6/8]` Add high-level Python SDK approval mode 7. #22014 `[7/8]` Add Python SDK app-server integration harness 8. #22021 `[8/8]` Add Python SDK Ruff formatting ## Verification - Added coverage for the SDK runtime dependency pin and runtime distribution naming. --------- Co-authored-by: Codex --- .github/workflows/rust-release-windows.yml | 42 +++++++ .github/workflows/rust-release.yml | 118 ++++++++++++++++++ sdk/python/README.md | 2 +- sdk/python/_runtime_setup.py | 39 ++++-- sdk/python/examples/README.md | 2 +- sdk/python/pyproject.toml | 6 +- sdk/python/scripts/update_sdk_artifacts.py | 5 + .../src/codex_app_server/_message_router.py | 2 + sdk/python/src/codex_app_server/api.py | 2 + .../src/codex_app_server/async_client.py | 29 +++++ sdk/python/src/codex_app_server/client.py | 12 ++ .../generated/notification_registry.py | 1 + .../test_artifact_workflow_and_binaries.py | 26 +++- .../tests/test_async_client_behavior.py | 19 +++ sdk/python/tests/test_client_rpc_methods.py | 7 ++ .../tests/test_public_api_runtime_behavior.py | 24 ++++ sdk/python/uv.lock | 22 +++- 17 files changed, 341 insertions(+), 17 deletions(-) diff --git a/.github/workflows/rust-release-windows.yml b/.github/workflows/rust-release-windows.yml index 2eb7ef2a47..8a1b6030a8 100644 --- a/.github/workflows/rust-release-windows.yml +++ b/.github/workflows/rust-release-windows.yml @@ -220,6 +220,48 @@ jobs: "$dest/${binary}-${{ matrix.target }}.exe" done + - name: Build Python runtime wheel + shell: bash + run: | + set -euo pipefail + + case "${{ matrix.target }}" in + aarch64-pc-windows-msvc) + platform_tag="win_arm64" + ;; + x86_64-pc-windows-msvc) + platform_tag="win_amd64" + ;; + *) + echo "No Python runtime wheel platform tag for ${{ matrix.target }}" + exit 1 + ;; + esac + + python -m venv "${RUNNER_TEMP}/python-runtime-build-venv" + "${RUNNER_TEMP}/python-runtime-build-venv/Scripts/python.exe" -m pip install build + + stage_dir="${RUNNER_TEMP}/openai-codex-cli-bin-${{ matrix.target }}" + wheel_dir="${GITHUB_WORKSPACE}/python-runtime-dist/${{ matrix.target }}" + # Keep the helpers next to codex.exe in the runtime wheel so Windows + # sandbox/elevation lookup matches the standalone release zip. + python "${GITHUB_WORKSPACE}/sdk/python/scripts/update_sdk_artifacts.py" \ + stage-runtime \ + "$stage_dir" \ + "${GITHUB_WORKSPACE}/codex-rs/target/${{ matrix.target }}/release/codex.exe" \ + --codex-version "${GITHUB_REF_NAME}" \ + --platform-tag "$platform_tag" \ + --resource-binary "${GITHUB_WORKSPACE}/codex-rs/target/${{ matrix.target }}/release/codex-command-runner.exe" \ + --resource-binary "${GITHUB_WORKSPACE}/codex-rs/target/${{ matrix.target }}/release/codex-windows-sandbox-setup.exe" + "${RUNNER_TEMP}/python-runtime-build-venv/Scripts/python.exe" -m build --wheel --outdir "$wheel_dir" "$stage_dir" + + - name: Upload Python runtime wheel + uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0 + with: + name: python-runtime-wheel-${{ matrix.target }} + path: python-runtime-dist/${{ matrix.target }}/*.whl + if-no-files-found: error + - name: Install DotSlash uses: facebook/install-dotslash@1e4e7b3e07eaca387acb98f1d4720e0bee8dbb6a # v2 diff --git a/.github/workflows/rust-release.yml b/.github/workflows/rust-release.yml index d05340a020..6dd751b7f6 100644 --- a/.github/workflows/rust-release.yml +++ b/.github/workflows/rust-release.yml @@ -399,6 +399,65 @@ jobs: cp target/${{ matrix.target }}/release/codex-${{ matrix.target }}.dmg "$dest/codex-${{ matrix.target }}.dmg" fi + - name: Build Python runtime wheel + if: ${{ matrix.bundle == 'primary' }} + shell: bash + run: | + set -euo pipefail + + case "${{ matrix.target }}" in + aarch64-apple-darwin) + platform_tag="macosx_11_0_arm64" + ;; + x86_64-apple-darwin) + platform_tag="macosx_10_9_x86_64" + ;; + aarch64-unknown-linux-musl) + platform_tag="musllinux_1_1_aarch64" + ;; + x86_64-unknown-linux-musl) + platform_tag="musllinux_1_1_x86_64" + ;; + *) + echo "No Python runtime wheel platform tag for ${{ matrix.target }}" + exit 1 + ;; + esac + + python3 -m venv "${RUNNER_TEMP}/python-runtime-build-venv" + # Do not install into the runner's system Python; macOS runners mark + # the Homebrew Python as externally managed under PEP 668. + "${RUNNER_TEMP}/python-runtime-build-venv/bin/python" -m pip install build + + stage_dir="${RUNNER_TEMP}/openai-codex-cli-bin-${{ matrix.target }}" + wheel_dir="${GITHUB_WORKSPACE}/python-runtime-dist/${{ matrix.target }}" + stage_runtime_args=( + "${GITHUB_WORKSPACE}/sdk/python/scripts/update_sdk_artifacts.py" + stage-runtime + "$stage_dir" + "${GITHUB_WORKSPACE}/codex-rs/target/${{ matrix.target }}/release/codex" + --codex-version "${GITHUB_REF_NAME}" + --platform-tag "$platform_tag" + ) + if [[ "${{ matrix.target }}" == *linux* ]]; then + # Keep bwrap in the runtime wheel so Linux sandbox fallback behavior + # matches the standalone release bundle on hosts without system bwrap. + stage_runtime_args+=( + --resource-binary + "${GITHUB_WORKSPACE}/codex-rs/target/${{ matrix.target }}/release/bwrap" + ) + fi + python3 "${stage_runtime_args[@]}" + "${RUNNER_TEMP}/python-runtime-build-venv/bin/python" -m build --wheel --outdir "$wheel_dir" "$stage_dir" + + - name: Upload Python runtime wheel + if: ${{ matrix.bundle == 'primary' }} + uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0 + with: + name: python-runtime-wheel-${{ matrix.target }} + path: python-runtime-dist/${{ matrix.target }}/*.whl + if-no-files-found: error + - name: Compress artifacts shell: bash run: | @@ -478,6 +537,7 @@ jobs: tag: ${{ github.ref_name }} should_publish_npm: ${{ steps.npm_publish_settings.outputs.should_publish }} npm_tag: ${{ steps.npm_publish_settings.outputs.npm_tag }} + should_publish_python_runtime: ${{ steps.python_runtime_publish_settings.outputs.should_publish }} steps: - name: Checkout repository @@ -554,6 +614,22 @@ jobs: echo "npm_tag=" >> "$GITHUB_OUTPUT" fi + - name: Determine Python runtime publish settings + id: python_runtime_publish_settings + env: + VERSION: ${{ steps.release_name.outputs.name }} + run: | + set -euo pipefail + version="${VERSION}" + + if [[ "${version}" =~ ^[0-9]+\.[0-9]+\.[0-9]+$ ]]; then + echo "should_publish=true" >> "$GITHUB_OUTPUT" + elif [[ "${version}" =~ ^[0-9]+\.[0-9]+\.[0-9]+-alpha\.[0-9]+$ ]]; then + echo "should_publish=true" >> "$GITHUB_OUTPUT" + else + echo "should_publish=false" >> "$GITHUB_OUTPUT" + fi + - name: Setup pnpm uses: pnpm/action-setup@a8198c4bff370c8506180b035930dea56dbd5288 # v5 with: @@ -787,6 +863,48 @@ jobs: exit "${publish_status}" done + # Publish the platform-specific Python runtime wheels using PyPI trusted publishing. + # PyPI project configuration must trust this workflow and job. Keep this + # non-blocking while the Python runtime publishing path is new; failures still + # need release follow-up, but should not invalidate the Rust release itself. + publish-python-runtime: + # Publish to PyPI for stable releases and alpha pre-releases with numeric suffixes. + if: ${{ needs.release.outputs.should_publish_python_runtime == 'true' }} + name: publish-python-runtime + needs: release + runs-on: ubuntu-latest + continue-on-error: true + environment: pypi + permissions: + id-token: write # Required for PyPI trusted publishing. + contents: read + + steps: + - name: Download Python runtime wheels from release + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + RELEASE_TAG: ${{ needs.release.outputs.tag }} + RELEASE_VERSION: ${{ needs.release.outputs.version }} + run: | + set -euo pipefail + python_version="$RELEASE_VERSION" + python_version="${python_version/-alpha./a}" + python_version="${python_version/-beta./b}" + python_version="${python_version/-rc./rc}" + + mkdir -p dist/python-runtime + gh release download "$RELEASE_TAG" \ + --repo "${GITHUB_REPOSITORY}" \ + --pattern "openai_codex_cli_bin-${python_version}-*.whl" \ + --dir dist/python-runtime + ls -lh dist/python-runtime + + - name: Publish Python runtime wheels to PyPI + uses: pypa/gh-action-pypi-publish@ed0c53931b1dc9bd32cbe73a98c7f6766f8a527e # v1.13.0 + with: + packages-dir: dist/python-runtime + skip-existing: true + winget: name: winget needs: release diff --git a/sdk/python/README.md b/sdk/python/README.md index 149420ad95..031471b811 100644 --- a/sdk/python/README.md +++ b/sdk/python/README.md @@ -91,7 +91,7 @@ This supports the CI release flow: - run `generate-types` before packaging - stage `openai-codex-app-server-sdk` 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 +- build and publish `openai-codex-cli-bin` as platform wheels only through PyPI trusted publishing; do not publish an sdist ## Compatibility and versioning diff --git a/sdk/python/_runtime_setup.py b/sdk/python/_runtime_setup.py index 8d33da6c88..0dc2a579ff 100644 --- a/sdk/python/_runtime_setup.py +++ b/sdk/python/_runtime_setup.py @@ -27,16 +27,22 @@ class RuntimeSetupError(RuntimeError): def pinned_runtime_version() -> str: - source_version = _source_tree_project_version() - if source_version is not None: - return _normalized_package_version(source_version) + """Return the exact runtime version pinned by the SDK package dependency.""" + source_pin = _source_tree_runtime_dependency_version() + if source_pin is not None: + return _normalized_package_version(source_pin) try: - return _normalized_package_version(importlib.metadata.version(SDK_PACKAGE_NAME)) + installed_pin = _installed_sdk_runtime_dependency_version() except importlib.metadata.PackageNotFoundError as exc: raise RuntimeSetupError( - f"Unable to resolve {SDK_PACKAGE_NAME} version for runtime pinning." + f"Unable to resolve {SDK_PACKAGE_NAME} metadata for runtime pinning." ) from exc + if installed_pin is None: + raise RuntimeSetupError( + f"Unable to resolve {PACKAGE_NAME} dependency pin from {SDK_PACKAGE_NAME}." + ) + return _normalized_package_version(installed_pin) def ensure_runtime_package_installed( @@ -399,20 +405,33 @@ def _release_tag(version: str) -> str: return f"rust-v{_codex_release_version(version)}" -def _source_tree_project_version() -> str | None: +def _source_tree_runtime_dependency_version() -> str | None: + """Read the runtime dependency pin when the SDK is running from a checkout.""" pyproject_path = Path(__file__).resolve().parent / "pyproject.toml" if not pyproject_path.exists(): return None - match = re.search( - r'(?m)^version = "([^"]+)"$', - pyproject_path.read_text(encoding="utf-8"), - ) + match = re.search(_runtime_dependency_pin_pattern(), pyproject_path.read_text()) if match is None: return None return match.group(1) +def _installed_sdk_runtime_dependency_version() -> str | None: + """Read the runtime dependency pin from installed package metadata.""" + requirements = importlib.metadata.requires(SDK_PACKAGE_NAME) or [] + for requirement in requirements: + match = re.search(_runtime_dependency_pin_pattern(), requirement) + if match is not None: + return match.group(1) + return None + + +def _runtime_dependency_pin_pattern() -> str: + """Match the exact runtime dependency pin in TOML and wheel metadata.""" + return rf'{re.escape(PACKAGE_NAME)}\s*==\s*"?([^",;\s]+)"?' + + __all__ = [ "PACKAGE_NAME", "SDK_PACKAGE_NAME", diff --git a/sdk/python/examples/README.md b/sdk/python/examples/README.md index 59428ba322..3a93c28a0c 100644 --- a/sdk/python/examples/README.md +++ b/sdk/python/examples/README.md @@ -28,7 +28,7 @@ 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. -The pinned runtime version comes from the SDK package version. +The pinned runtime version comes from the SDK package dependency. ## Run examples diff --git a/sdk/python/pyproject.toml b/sdk/python/pyproject.toml index f54838bfa8..6b92983c91 100644 --- a/sdk/python/pyproject.toml +++ b/sdk/python/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "hatchling.build" [project] name = "openai-codex-app-server-sdk" -version = "0.116.0a1" +version = "0.131.0a4" description = "Python SDK for Codex app-server v2" readme = "README.md" requires-python = ">=3.10" @@ -22,7 +22,7 @@ classifiers = [ "Programming Language :: Python :: 3.13", "Topic :: Software Development :: Libraries :: Python Modules", ] -dependencies = ["pydantic>=2.12"] +dependencies = ["pydantic>=2.12", "openai-codex-cli-bin==0.131.0a4"] [project.urls] Homepage = "https://github.com/openai/codex" @@ -63,8 +63,10 @@ testpaths = ["tests"] [tool.uv] exclude-newer = "7 days" +exclude-newer-package = { openai-codex-cli-bin = "2026-05-10T00:00:00Z" } index-strategy = "first-index" [tool.uv.pip] exclude-newer = "7 days" +exclude-newer-package = { openai-codex-cli-bin = "2026-05-10T00:00:00Z" } index-strategy = "first-index" diff --git a/sdk/python/scripts/update_sdk_artifacts.py b/sdk/python/scripts/update_sdk_artifacts.py index 4ff6f0c24f..c44a1444c0 100755 --- a/sdk/python/scripts/update_sdk_artifacts.py +++ b/sdk/python/scripts/update_sdk_artifacts.py @@ -61,6 +61,7 @@ def staged_runtime_bin_path(root: Path) -> Path: def staged_runtime_resource_path(root: Path, resource: Path) -> Path: + """Stage runtime helper binaries beside the main bundled Codex binary.""" # Runtime wheels include the whole bin/ directory, so helper executables # should be staged beside the main Codex binary instead of changing the # package template for each platform. @@ -588,6 +589,7 @@ def _notification_specs() -> list[tuple[str, str]]: def _notification_turn_id_specs( specs: list[tuple[str, str]], ) -> tuple[list[str], list[str]]: + """Classify generated notification payloads by where the turn id lives.""" server_notifications = json.loads( (schema_root_dir() / "ServerNotification.json").read_text() ) @@ -615,6 +617,7 @@ def _notification_turn_id_specs( def _type_tuple_source(class_names: list[str]) -> str: + """Render a generated tuple literal for notification payload classes.""" if not class_names: return "()" if len(class_names) == 1: @@ -623,6 +626,7 @@ def _type_tuple_source(class_names: list[str]) -> str: def generate_notification_registry() -> None: + """Regenerate notification models and routing metadata from generated schemas.""" out = ( sdk_root() / "src" @@ -666,6 +670,7 @@ def generate_notification_registry() -> None: "", "", "def notification_turn_id(payload: BaseModel) -> str | None:", + ' """Return the turn id carried by generated notification payload metadata."""', " if isinstance(payload, DIRECT_TURN_ID_NOTIFICATION_TYPES):", " return payload.turn_id if isinstance(payload.turn_id, str) else None", " if isinstance(payload, NESTED_TURN_NOTIFICATION_TYPES):", diff --git a/sdk/python/src/codex_app_server/_message_router.py b/sdk/python/src/codex_app_server/_message_router.py index 6de575166c..fb466f958c 100644 --- a/sdk/python/src/codex_app_server/_message_router.py +++ b/sdk/python/src/codex_app_server/_message_router.py @@ -22,6 +22,7 @@ class MessageRouter: """ def __init__(self) -> None: + """Create empty response, turn, and global notification queues.""" self._lock = threading.Lock() self._response_waiters: dict[str, queue.Queue[ResponseQueueItem]] = {} self._turn_notifications: dict[str, queue.Queue[NotificationQueueItem]] = {} @@ -144,6 +145,7 @@ class MessageRouter: self._global_notifications.put(exc) def _notification_turn_id(self, notification: Notification) -> str | None: + """Extract routing ids from known generated payloads or raw unknown payloads.""" payload = notification.payload if isinstance(payload, UnknownNotification): raw_turn_id = payload.params.get("turnId") diff --git a/sdk/python/src/codex_app_server/api.py b/sdk/python/src/codex_app_server/api.py index 6f61a55868..886e4dd826 100644 --- a/sdk/python/src/codex_app_server/api.py +++ b/sdk/python/src/codex_app_server/api.py @@ -678,6 +678,7 @@ class TurnHandle: return self._client.turn_interrupt(self.thread_id, self.id) def stream(self) -> Iterator[Notification]: + """Yield only notifications routed to this turn handle.""" self._client.register_turn_notifications(self.id) try: while True: @@ -730,6 +731,7 @@ class AsyncTurnHandle: return await self._codex._client.turn_interrupt(self.thread_id, self.id) async def stream(self) -> AsyncIterator[Notification]: + """Yield only notifications routed to this async turn handle.""" await self._codex._ensure_initialized() self._codex._client.register_turn_notifications(self.id) try: diff --git a/sdk/python/src/codex_app_server/async_client.py b/sdk/python/src/codex_app_server/async_client.py index 6768c7d9fc..581d14abe9 100644 --- a/sdk/python/src/codex_app_server/async_client.py +++ b/sdk/python/src/codex_app_server/async_client.py @@ -40,13 +40,16 @@ class AsyncAppServerClient: """Async wrapper around AppServerClient using thread offloading.""" def __init__(self, config: AppServerConfig | None = None) -> None: + """Create the wrapped sync client that owns the transport process.""" self._sync = AppServerClient(config=config) async def __aenter__(self) -> "AsyncAppServerClient": + """Start the app-server process when entering an async context.""" await self.start() return self async def __aexit__(self, _exc_type, _exc, _tb) -> None: + """Close the app-server process when leaving an async context.""" await self.close() async def _call_sync( @@ -56,30 +59,37 @@ class AsyncAppServerClient: *args: ParamsT.args, **kwargs: ParamsT.kwargs, ) -> ReturnT: + """Run a blocking sync-client operation without blocking the event loop.""" return await asyncio.to_thread(fn, *args, **kwargs) @staticmethod def _next_from_iterator( iterator: Iterator[AgentMessageDeltaNotification], ) -> tuple[bool, AgentMessageDeltaNotification | None]: + """Convert StopIteration into a value that can cross asyncio.to_thread.""" try: return True, next(iterator) except StopIteration: return False, None async def start(self) -> None: + """Start the wrapped sync client in a worker thread.""" await self._call_sync(self._sync.start) async def close(self) -> None: + """Close the wrapped sync client in a worker thread.""" await self._call_sync(self._sync.close) async def initialize(self) -> InitializeResponse: + """Initialize the app-server session.""" return await self._call_sync(self._sync.initialize) def register_turn_notifications(self, turn_id: str) -> None: + """Register a turn notification queue on the wrapped sync client.""" self._sync.register_turn_notifications(turn_id) def unregister_turn_notifications(self, turn_id: str) -> None: + """Unregister a turn notification queue on the wrapped sync client.""" self._sync.unregister_turn_notifications(turn_id) async def request( @@ -89,6 +99,7 @@ class AsyncAppServerClient: *, response_model: type[ModelT], ) -> ModelT: + """Send a typed JSON-RPC request through the wrapped sync client.""" return await self._call_sync( self._sync.request, method, @@ -99,6 +110,7 @@ class AsyncAppServerClient: async def thread_start( self, params: V2ThreadStartParams | JsonObject | None = None ) -> ThreadStartResponse: + """Start a thread using the wrapped sync client.""" return await self._call_sync(self._sync.thread_start, params) async def thread_resume( @@ -106,16 +118,19 @@ class AsyncAppServerClient: thread_id: str, params: V2ThreadResumeParams | JsonObject | None = None, ) -> ThreadResumeResponse: + """Resume a thread using the wrapped sync client.""" return await self._call_sync(self._sync.thread_resume, thread_id, params) async def thread_list( self, params: V2ThreadListParams | JsonObject | None = None ) -> ThreadListResponse: + """List threads using the wrapped sync client.""" return await self._call_sync(self._sync.thread_list, params) async def thread_read( self, thread_id: str, include_turns: bool = False ) -> ThreadReadResponse: + """Read a thread using the wrapped sync client.""" return await self._call_sync(self._sync.thread_read, thread_id, include_turns) async def thread_fork( @@ -123,18 +138,23 @@ class AsyncAppServerClient: thread_id: str, params: V2ThreadForkParams | JsonObject | None = None, ) -> ThreadForkResponse: + """Fork a thread using the wrapped sync client.""" return await self._call_sync(self._sync.thread_fork, thread_id, params) async def thread_archive(self, thread_id: str) -> ThreadArchiveResponse: + """Archive a thread using the wrapped sync client.""" return await self._call_sync(self._sync.thread_archive, thread_id) async def thread_unarchive(self, thread_id: str) -> ThreadUnarchiveResponse: + """Unarchive a thread using the wrapped sync client.""" return await self._call_sync(self._sync.thread_unarchive, thread_id) async def thread_set_name(self, thread_id: str, name: str) -> ThreadSetNameResponse: + """Rename a thread using the wrapped sync client.""" return await self._call_sync(self._sync.thread_set_name, thread_id, name) async def thread_compact(self, thread_id: str) -> ThreadCompactStartResponse: + """Start thread compaction using the wrapped sync client.""" return await self._call_sync(self._sync.thread_compact, thread_id) async def turn_start( @@ -143,6 +163,7 @@ class AsyncAppServerClient: input_items: list[JsonObject] | JsonObject | str, params: V2TurnStartParams | JsonObject | None = None, ) -> TurnStartResponse: + """Start a turn using the wrapped sync client.""" return await self._call_sync( self._sync.turn_start, thread_id, input_items, params ) @@ -150,6 +171,7 @@ class AsyncAppServerClient: async def turn_interrupt( self, thread_id: str, turn_id: str ) -> TurnInterruptResponse: + """Interrupt a turn using the wrapped sync client.""" return await self._call_sync(self._sync.turn_interrupt, thread_id, turn_id) async def turn_steer( @@ -158,6 +180,7 @@ class AsyncAppServerClient: expected_turn_id: str, input_items: list[JsonObject] | JsonObject | str, ) -> TurnSteerResponse: + """Send steering input to a turn using the wrapped sync client.""" return await self._call_sync( self._sync.turn_steer, thread_id, @@ -166,6 +189,7 @@ class AsyncAppServerClient: ) async def model_list(self, include_hidden: bool = False) -> ModelListResponse: + """List models using the wrapped sync client.""" return await self._call_sync(self._sync.model_list, include_hidden) async def request_with_retry_on_overload( @@ -178,6 +202,7 @@ class AsyncAppServerClient: initial_delay_s: float = 0.25, max_delay_s: float = 2.0, ) -> ModelT: + """Send a typed request with the sync client's overload retry policy.""" return await self._call_sync( self._sync.request_with_retry_on_overload, method, @@ -189,12 +214,15 @@ class AsyncAppServerClient: ) async def next_notification(self) -> Notification: + """Wait for the next global notification without blocking the event loop.""" return await self._call_sync(self._sync.next_notification) async def next_turn_notification(self, turn_id: str) -> Notification: + """Wait for the next notification routed to one turn.""" return await self._call_sync(self._sync.next_turn_notification, turn_id) async def wait_for_turn_completed(self, turn_id: str) -> TurnCompletedNotification: + """Wait for the completion notification routed to one turn.""" return await self._call_sync(self._sync.wait_for_turn_completed, turn_id) async def stream_text( @@ -203,6 +231,7 @@ class AsyncAppServerClient: text: str, params: V2TurnStartParams | JsonObject | None = None, ) -> AsyncIterator[AgentMessageDeltaNotification]: + """Stream text deltas from one turn without monopolizing the event loop.""" iterator = self._sync.stream_text(thread_id, text, params) while True: has_value, chunk = await asyncio.to_thread( diff --git a/sdk/python/src/codex_app_server/client.py b/sdk/python/src/codex_app_server/client.py index ce3df4e416..26d23e3fa7 100644 --- a/sdk/python/src/codex_app_server/client.py +++ b/sdk/python/src/codex_app_server/client.py @@ -243,6 +243,7 @@ class AppServerClient: return response_model.model_validate(result) def _request_raw(self, method: str, params: JsonObject | None = None) -> JsonValue: + """Send a JSON-RPC request and wait for the reader thread to route its response.""" request_id = str(uuid.uuid4()) waiter = self._router.create_response_waiter(request_id) @@ -260,18 +261,23 @@ class AppServerClient: return item def notify(self, method: str, params: JsonObject | None = None) -> None: + """Send a JSON-RPC notification without waiting for a response.""" self._write_message({"method": method, "params": params or {}}) def next_notification(self) -> Notification: + """Return the next notification that is not scoped to an active turn.""" return self._router.next_global_notification() def register_turn_notifications(self, turn_id: str) -> None: + """Start routing notifications for one turn into its dedicated queue.""" self._router.register_turn(turn_id) def unregister_turn_notifications(self, turn_id: str) -> None: + """Stop routing notifications for one turn into its dedicated queue.""" self._router.unregister_turn(turn_id) def next_turn_notification(self, turn_id: str) -> Notification: + """Return the next routed notification for the requested turn id.""" return self._router.next_turn_notification(turn_id) def thread_start( @@ -349,6 +355,7 @@ class AppServerClient: input_items: list[JsonObject] | JsonObject | str, params: V2TurnStartParams | JsonObject | None = None, ) -> TurnStartResponse: + """Start a turn and register its notification queue as early as possible.""" payload = { **_params_dict(params), "threadId": thread_id, @@ -406,6 +413,7 @@ class AppServerClient: ) def wait_for_turn_completed(self, turn_id: str) -> TurnCompletedNotification: + """Block on the routed turn stream until the matching completion arrives.""" self.register_turn_notifications(turn_id) try: while True: @@ -425,6 +433,7 @@ class AppServerClient: text: str, params: V2TurnStartParams | JsonObject | None = None, ) -> Iterator[AgentMessageDeltaNotification]: + """Start a text turn and yield only its agent-message delta payloads.""" started = self.turn_start(thread_id, text, params=params) turn_id = started.turn.id self.register_turn_notifications(turn_id) @@ -477,6 +486,7 @@ class AppServerClient: def _default_approval_handler( self, method: str, params: JsonObject | None ) -> JsonObject: + """Accept approval requests when the caller did not provide a handler.""" if method == "item/commandExecution/requestApproval": return {"decision": "accept"} if method == "item/fileChange/requestApproval": @@ -498,6 +508,7 @@ class AppServerClient: self._stderr_thread.start() def _start_reader_thread(self) -> None: + """Start the sole stdout reader that fans messages into router queues.""" if self._proc is None or self._proc.stdout is None: return @@ -505,6 +516,7 @@ class AppServerClient: self._reader_thread.start() def _reader_loop(self) -> None: + """Continuously classify transport messages into requests, responses, and events.""" try: while True: msg = self._read_message() diff --git a/sdk/python/src/codex_app_server/generated/notification_registry.py b/sdk/python/src/codex_app_server/generated/notification_registry.py index b44ca2a436..3319f4edb5 100644 --- a/sdk/python/src/codex_app_server/generated/notification_registry.py +++ b/sdk/python/src/codex_app_server/generated/notification_registry.py @@ -165,6 +165,7 @@ NESTED_TURN_NOTIFICATION_TYPES: tuple[type[BaseModel], ...] = ( def notification_turn_id(payload: BaseModel) -> str | None: + """Return the turn id carried by generated notification payload metadata.""" if isinstance(payload, DIRECT_TURN_ID_NOTIFICATION_TYPES): return payload.turn_id if isinstance(payload.turn_id, str) else None if isinstance(payload, NESTED_TURN_NOTIFICATION_TYPES): diff --git a/sdk/python/tests/test_artifact_workflow_and_binaries.py b/sdk/python/tests/test_artifact_workflow_and_binaries.py index e9b4e6a8bb..98710fdce2 100644 --- a/sdk/python/tests/test_artifact_workflow_and_binaries.py +++ b/sdk/python/tests/test_artifact_workflow_and_binaries.py @@ -163,8 +163,9 @@ def test_runtime_package_template_has_no_checked_in_binaries() -> None: def test_examples_readme_points_to_runtime_version_source_of_truth() -> None: + """Document that examples should point at the dependency pin, not release lore.""" readme = (ROOT / "examples" / "README.md").read_text() - assert "The pinned runtime version comes from the SDK package version." in readme + assert "The pinned runtime version comes from the SDK package dependency." in readme def test_runtime_distribution_name_is_consistent() -> None: @@ -211,12 +212,33 @@ def test_release_metadata_retries_without_invalid_auth( assert authorizations == ["Bearer invalid-token", None] +def test_source_sdk_package_pins_published_runtime() -> None: + """The source package metadata should pin the runtime wheel that ships schemas.""" + pyproject = tomllib.loads((ROOT / "pyproject.toml").read_text()) + + assert { + "sdk_version": pyproject["project"]["version"], + "dependencies": pyproject["project"]["dependencies"], + } == { + "sdk_version": "0.131.0a4", + "dependencies": [ + "pydantic>=2.12", + "openai-codex-cli-bin==0.131.0a4", + ], + } + + def test_runtime_setup_uses_pep440_package_version_and_codex_release_tags() -> None: + """The SDK uses PEP 440 package pins and converts only when fetching releases.""" runtime_setup = _load_runtime_setup_module() pyproject = tomllib.loads((ROOT / "pyproject.toml").read_text()) assert runtime_setup.PACKAGE_NAME == "openai-codex-cli-bin" assert runtime_setup.pinned_runtime_version() == pyproject["project"]["version"] + assert ( + f"{runtime_setup.PACKAGE_NAME}=={pyproject['project']['version']}" + in pyproject["project"]["dependencies"] + ) assert ( runtime_setup._normalized_package_version("rust-v0.116.0-alpha.1") == "0.116.0a1" @@ -352,6 +374,7 @@ def test_stage_runtime_release_can_pin_wheel_platform_tag(tmp_path: Path) -> Non def test_stage_runtime_release_copies_resource_binaries(tmp_path: Path) -> None: + """Runtime staging should copy every helper binary into the wheel bin dir.""" script = _load_update_script_module() fake_binary = tmp_path / script.runtime_binary_name() helper = tmp_path / "helper" @@ -382,6 +405,7 @@ def test_stage_runtime_release_copies_resource_binaries(tmp_path: Path) -> None: def test_runtime_resource_binaries_are_included_by_wheel_config( tmp_path: Path, ) -> None: + """The runtime wheel config should include helper binaries beside Codex.""" script = _load_update_script_module() fake_binary = tmp_path / script.runtime_binary_name() helper = tmp_path / "helper" diff --git a/sdk/python/tests/test_async_client_behavior.py b/sdk/python/tests/test_async_client_behavior.py index 0c4e8096fb..51359f041f 100644 --- a/sdk/python/tests/test_async_client_behavior.py +++ b/sdk/python/tests/test_async_client_behavior.py @@ -13,12 +13,15 @@ from codex_app_server.models import Notification, UnknownNotification def test_async_client_allows_concurrent_transport_calls() -> None: + """Async wrappers should offload sync calls so concurrent awaits can overlap.""" async def scenario() -> int: + """Run two blocking sync calls and report peak overlap.""" client = AsyncAppServerClient() active = 0 max_active = 0 def fake_model_list(include_hidden: bool = False) -> bool: + """Simulate a blocking sync transport call.""" nonlocal active, max_active active += 1 max_active = max(max_active, active) @@ -34,16 +37,20 @@ def test_async_client_allows_concurrent_transport_calls() -> None: def test_async_stream_text_is_incremental_without_blocking_parallel_calls() -> None: + """Async text streaming should yield incrementally without blocking other calls.""" async def scenario() -> tuple[str, list[str], bool]: + """Start a stream, then prove another async client call can finish.""" client = AsyncAppServerClient() def fake_stream_text(thread_id: str, text: str, params=None): # type: ignore[no-untyped-def] + """Yield one item before sleeping so the async wrapper can interleave.""" yield "first" time.sleep(0.03) yield "second" yield "third" def fake_model_list(include_hidden: bool = False) -> str: + """Return immediately to prove the event loop was not monopolized.""" return "done" client._sync.stream_text = fake_stream_text # type: ignore[method-assign] @@ -70,7 +77,9 @@ def test_async_stream_text_is_incremental_without_blocking_parallel_calls() -> N def test_async_client_turn_notification_methods_delegate_to_sync_client() -> None: + """Async turn routing methods should preserve sync-client registration semantics.""" async def scenario() -> tuple[list[tuple[str, str]], Notification, str]: + """Record the sync-client calls made by async turn notification wrappers.""" client = AsyncAppServerClient() event = Notification( method="unknown/direct", @@ -85,16 +94,20 @@ def test_async_client_turn_notification_methods_delegate_to_sync_client() -> Non calls: list[tuple[str, str]] = [] def fake_register(turn_id: str) -> None: + """Record turn registration through the wrapped sync client.""" calls.append(("register", turn_id)) def fake_unregister(turn_id: str) -> None: + """Record turn unregistration through the wrapped sync client.""" calls.append(("unregister", turn_id)) def fake_next(turn_id: str) -> Notification: + """Return one routed notification through the wrapped sync client.""" calls.append(("next", turn_id)) return event def fake_wait(turn_id: str) -> TurnCompletedNotification: + """Return one completion through the wrapped sync client.""" calls.append(("wait", turn_id)) return completed @@ -132,7 +145,9 @@ def test_async_client_turn_notification_methods_delegate_to_sync_client() -> Non def test_async_stream_text_uses_sync_turn_routing() -> None: + """Async text streaming should consume the same per-turn routing path as sync.""" async def scenario() -> tuple[list[tuple[str, str]], list[str]]: + """Record routing calls while streaming two deltas and one completion.""" client = AsyncAppServerClient() notifications = [ Notification( @@ -170,17 +185,21 @@ def test_async_stream_text_uses_sync_turn_routing() -> None: calls: list[tuple[str, str]] = [] def fake_turn_start(thread_id: str, text: str, *, params=None): # type: ignore[no-untyped-def] + """Return a started turn id while recording the request thread.""" calls.append(("turn_start", thread_id)) return SimpleNamespace(turn=SimpleNamespace(id="turn-1")) def fake_register(turn_id: str) -> None: + """Record stream registration for the started turn.""" calls.append(("register", turn_id)) def fake_next(turn_id: str) -> Notification: + """Return the next queued turn notification.""" calls.append(("next", turn_id)) return notifications.pop(0) def fake_unregister(turn_id: str) -> None: + """Record stream cleanup for the started turn.""" calls.append(("unregister", turn_id)) client._sync.turn_start = fake_turn_start # type: ignore[method-assign] diff --git a/sdk/python/tests/test_client_rpc_methods.py b/sdk/python/tests/test_client_rpc_methods.py index 07b88215a8..a049960b07 100644 --- a/sdk/python/tests/test_client_rpc_methods.py +++ b/sdk/python/tests/test_client_rpc_methods.py @@ -135,6 +135,7 @@ def test_invalid_notification_payload_falls_back_to_unknown() -> None: def test_generated_notification_turn_id_handles_known_payload_shapes() -> None: + """Generated routing metadata should cover direct, nested, and unscoped payloads.""" direct = AgentMessageDeltaNotification.model_validate( { "delta": "hello", @@ -159,6 +160,7 @@ def test_generated_notification_turn_id_handles_known_payload_shapes() -> None: def test_turn_notification_router_demuxes_registered_turns() -> None: + """The router should deliver out-of-order turn events to the matching queues.""" client = AppServerClient() client.register_turn_notifications("turn-1") client.register_turn_notifications("turn-2") @@ -201,6 +203,7 @@ def test_turn_notification_router_demuxes_registered_turns() -> None: def test_client_reader_routes_interleaved_turn_notifications_by_turn_id() -> None: + """Reader-loop routing should preserve order within each interleaved turn stream.""" client = AppServerClient() client.register_turn_notifications("turn-1") client.register_turn_notifications("turn-2") @@ -245,6 +248,7 @@ def test_client_reader_routes_interleaved_turn_notifications_by_turn_id() -> Non ] def fake_read_message() -> dict[str, object]: + """Feed the reader loop a realistic interleaved stdout sequence.""" if messages: return messages.pop(0) raise EOFError @@ -278,6 +282,7 @@ def test_client_reader_routes_interleaved_turn_notifications_by_turn_id() -> Non def test_turn_notification_router_buffers_events_before_registration() -> None: + """Early turn events should be replayed once their TurnHandle registers.""" client = AppServerClient() client._router.route_notification( client._coerce_notification( @@ -302,6 +307,7 @@ def test_turn_notification_router_buffers_events_before_registration() -> None: def test_turn_notification_router_clears_unregistered_turn_when_completed() -> None: + """A completed unregistered turn should not leave a pending queue behind.""" client = AppServerClient() client._router.route_notification( client._coerce_notification( @@ -328,6 +334,7 @@ def test_turn_notification_router_clears_unregistered_turn_when_completed() -> N def test_turn_notification_router_routes_unknown_turn_notifications() -> None: + """Unknown notifications should still route when their raw params carry a turn id.""" client = AppServerClient() client.register_turn_notifications("turn-1") client.register_turn_notifications("turn-2") diff --git a/sdk/python/tests/test_public_api_runtime_behavior.py b/sdk/python/tests/test_public_api_runtime_behavior.py index 4f0fc45a7c..3cf18c4e47 100644 --- a/sdk/python/tests/test_public_api_runtime_behavior.py +++ b/sdk/python/tests/test_public_api_runtime_behavior.py @@ -227,6 +227,7 @@ def test_async_codex_initializes_only_once_under_concurrency() -> None: def test_turn_streams_can_consume_multiple_turns_on_one_client() -> None: + """Two sync TurnHandle streams should advance independently on one client.""" client = AppServerClient() notifications: dict[str, deque[Notification]] = { "turn-1": deque( @@ -257,10 +258,13 @@ def test_turn_streams_can_consume_multiple_turns_on_one_client() -> None: def test_async_turn_streams_can_consume_multiple_turns_on_one_client() -> None: + """Two async TurnHandle streams should advance independently on one client.""" async def scenario() -> None: + """Interleave two async streams backed by separate per-turn queues.""" codex = AsyncCodex() async def fake_ensure_initialized() -> None: + """Avoid starting a real app-server process for this stream test.""" return None notifications: dict[str, deque[Notification]] = { @@ -279,6 +283,7 @@ def test_async_turn_streams_can_consume_multiple_turns_on_one_client() -> None: } async def fake_next_notification(turn_id: str) -> Notification: + """Return the next notification from the requested per-turn queue.""" return notifications[turn_id].popleft() codex._ensure_initialized = fake_ensure_initialized # type: ignore[method-assign] @@ -468,6 +473,7 @@ def test_thread_run_raises_on_failed_turn() -> None: def test_stream_text_registers_and_consumes_turn_notifications() -> None: + """stream_text should register, consume, and unregister one turn queue.""" client = AppServerClient() notifications: deque[Notification] = deque( [ @@ -482,13 +488,16 @@ def test_stream_text_registers_and_consumes_turn_notifications() -> None: ) def fake_register(turn_id: str) -> None: + """Record registration for the turn created by stream_text.""" calls.append(("register", turn_id)) def fake_next(turn_id: str) -> Notification: + """Return the next queued notification for stream_text.""" calls.append(("next", turn_id)) return notifications.popleft() def fake_unregister(turn_id: str) -> None: + """Record cleanup for the turn created by stream_text.""" calls.append(("unregister", turn_id)) client.register_turn_notifications = fake_register # type: ignore[method-assign] @@ -510,10 +519,13 @@ def test_stream_text_registers_and_consumes_turn_notifications() -> None: def test_async_thread_run_accepts_string_input_and_returns_run_result() -> None: + """Async Thread.run should normalize string input and collect routed results.""" async def scenario() -> None: + """Feed item, usage, and completion events through the async turn stream.""" codex = AsyncCodex() async def fake_ensure_initialized() -> None: + """Avoid starting a real app-server process for this run test.""" return None item_notification = _item_completed_notification(text="Hello async.") @@ -528,12 +540,14 @@ def test_async_thread_run_accepts_string_input_and_returns_run_result() -> None: seen: dict[str, object] = {} async def fake_turn_start(thread_id: str, wire_input: object, *, params=None): # noqa: ANN001,ANN202 + """Capture normalized input and return a synthetic turn id.""" seen["thread_id"] = thread_id seen["wire_input"] = wire_input seen["params"] = params return SimpleNamespace(turn=SimpleNamespace(id="turn-1")) async def fake_next_notification(_turn_id: str) -> Notification: + """Return the next queued notification for the synthetic turn.""" return notifications.popleft() codex._ensure_initialized = fake_ensure_initialized # type: ignore[method-assign] @@ -556,10 +570,13 @@ def test_async_thread_run_accepts_string_input_and_returns_run_result() -> None: def test_async_thread_run_uses_last_completed_assistant_message_as_final_response() -> ( None ): + """Async run should use the last final assistant message as the response text.""" async def scenario() -> None: + """Feed two completed agent messages through the async per-turn stream.""" codex = AsyncCodex() async def fake_ensure_initialized() -> None: + """Avoid starting a real app-server process for this run test.""" return None first_item_notification = _item_completed_notification( @@ -577,9 +594,11 @@ def test_async_thread_run_uses_last_completed_assistant_message_as_final_respons ) async def fake_turn_start(thread_id: str, wire_input: object, *, params=None): # noqa: ANN001,ANN202,ARG001 + """Return a synthetic turn id after AsyncThread.run builds input.""" return SimpleNamespace(turn=SimpleNamespace(id="turn-1")) async def fake_next_notification(_turn_id: str) -> Notification: + """Return the next queued notification for that synthetic turn.""" return notifications.popleft() codex._ensure_initialized = fake_ensure_initialized # type: ignore[method-assign] @@ -598,10 +617,13 @@ def test_async_thread_run_uses_last_completed_assistant_message_as_final_respons def test_async_thread_run_returns_none_when_only_commentary_messages_complete() -> None: + """Async Thread.run should ignore commentary-only messages for final text.""" async def scenario() -> None: + """Feed a commentary item and completion through the async turn stream.""" codex = AsyncCodex() async def fake_ensure_initialized() -> None: + """Avoid starting a real app-server process for this run test.""" return None commentary_notification = _item_completed_notification( @@ -616,9 +638,11 @@ def test_async_thread_run_returns_none_when_only_commentary_messages_complete() ) async def fake_turn_start(thread_id: str, wire_input: object, *, params=None): # noqa: ANN001,ANN202,ARG001 + """Return a synthetic turn id for commentary-only output.""" return SimpleNamespace(turn=SimpleNamespace(id="turn-1")) async def fake_next_notification(_turn_id: str) -> Notification: + """Return the next queued commentary/completion notification.""" return notifications.popleft() codex._ensure_initialized = fake_ensure_initialized # type: ignore[method-assign] diff --git a/sdk/python/uv.lock b/sdk/python/uv.lock index d0e2cf7372..b8e3a8f662 100644 --- a/sdk/python/uv.lock +++ b/sdk/python/uv.lock @@ -3,9 +3,12 @@ revision = 3 requires-python = ">=3.10" [options] -exclude-newer = "2026-04-20T18:19:27.620299Z" +exclude-newer = "2026-05-02T06:28:46.47929Z" exclude-newer-span = "P7D" +[options.exclude-newer-package] +openai-codex-cli-bin = "2026-05-10T00:00:00Z" + [[package]] name = "annotated-types" version = "0.7.0" @@ -279,9 +282,10 @@ wheels = [ [[package]] name = "openai-codex-app-server-sdk" -version = "0.116.0a1" +version = "0.131.0a4" source = { editable = "." } dependencies = [ + { name = "openai-codex-cli-bin" }, { name = "pydantic" }, ] @@ -295,12 +299,26 @@ dev = [ [package.metadata] requires-dist = [ { name = "datamodel-code-generator", marker = "extra == 'dev'", specifier = "==0.31.2" }, + { name = "openai-codex-cli-bin", specifier = "==0.131.0a4" }, { name = "pydantic", specifier = ">=2.12" }, { name = "pytest", marker = "extra == 'dev'", specifier = ">=8.0" }, { name = "ruff", marker = "extra == 'dev'", specifier = ">=0.11" }, ] provides-extras = ["dev"] +[[package]] +name = "openai-codex-cli-bin" +version = "0.131.0a4" +source = { registry = "https://pypi.org/simple" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/6b/9f/f9fc4bb1b2b7a20d4d65143ebb4c4dcd2301a718183b539ecb5b1c0ac3ec/openai_codex_cli_bin-0.131.0a4-py3-none-macosx_10_9_x86_64.whl", hash = "sha256:db0f3cb7dda310641ac04fbaf3f128693a3817ab83ae59b67a3c9c74bd53f8b8", size = 88367585, upload-time = "2026-05-09T06:14:09.453Z" }, + { url = "https://files.pythonhosted.org/packages/dc/39/eb95ed0e8156669e895a192dec760be07dabe891c3c6340f7c6487b9a976/openai_codex_cli_bin-0.131.0a4-py3-none-macosx_11_0_arm64.whl", hash = "sha256:6cae5af6edca7f6d3f0bcbbd93cfc8a6dc3e33fb5955af21ae492b6d5d0dcb72", size = 79245567, upload-time = "2026-05-09T06:14:13.581Z" }, + { url = "https://files.pythonhosted.org/packages/0c/92/ade176fa78d746d5ff7a6e371d64740c0d95ab299b0dd58a5404b89b3915/openai_codex_cli_bin-0.131.0a4-py3-none-musllinux_1_1_aarch64.whl", hash = "sha256:5728f9887baf62d7e72f4f242093b3ff81e26c81d80d346fe1eef7eda6838aa8", size = 77758628, upload-time = "2026-05-09T06:14:18.374Z" }, + { url = "https://files.pythonhosted.org/packages/28/e6/bfe6c65f8e3e5499f71b24c3b6e8d07e4d426543d25e429b9b141b544e5f/openai_codex_cli_bin-0.131.0a4-py3-none-musllinux_1_1_x86_64.whl", hash = "sha256:d7a47fd3667fbcc216593839c202deffa056e9b3d46c6933e72594d461f4fea0", size = 84535509, upload-time = "2026-05-09T06:14:22.851Z" }, + { url = "https://files.pythonhosted.org/packages/bd/b7/53dc094a691ab6f2ca079e8e865b122843809ac4fad51cac4d59021e599d/openai_codex_cli_bin-0.131.0a4-py3-none-win_amd64.whl", hash = "sha256:c61bcf029672494c4c7fdc8567dbaa659a48bb75641d91c2ade27c1e46803434", size = 88185543, upload-time = "2026-05-09T06:14:27.282Z" }, + { url = "https://files.pythonhosted.org/packages/82/99/e0852ffcf9b4d2794fef83e0c3a267b3c773a776f136e9f7ce19f0c8df42/openai_codex_cli_bin-0.131.0a4-py3-none-win_arm64.whl", hash = "sha256:bbde750186861f102e346ac066f4e9608f515f7b71b16a6e8b7ef1ddc02a97a5", size = 81196380, upload-time = "2026-05-09T06:14:32.103Z" }, +] + [[package]] name = "packaging" version = "26.1"