From 9b3134a27e8d3db940bf76a6bd4b78321047208a Mon Sep 17 00:00:00 2001 From: "stainless-app[bot]" <142633134+stainless-app[bot]@users.noreply.github.com> Date: Sun, 31 Aug 2025 06:00:06 +0000 Subject: [PATCH 01/20] chore: sync repo --- .devcontainer/Dockerfile | 9 - .devcontainer/devcontainer.json | 43 - .github/workflows/ci.yml | 98 - .github/workflows/publish-pypi.yml | 31 - .github/workflows/release-doctor.yml | 21 - .gitignore | 15 - .python-version | 1 - .release-please-manifest.json | 3 - .stats.yml | 4 - .vscode/settings.json | 3 - Brewfile | 2 - CHANGELOG.md | 342 --- CONTRIBUTING.md | 128 -- LICENSE | 7 - README.md | 375 +--- SECURITY.md | 27 - api.md | 145 -- bin/check-release-environment | 21 - bin/publish-pypi | 6 - examples/.keep | 4 - mypy.ini | 50 - noxfile.py | 9 - pyproject.toml | 212 -- release-please-config.json | 66 - requirements-dev.lock | 135 -- requirements.lock | 72 - scripts/bootstrap | 19 - scripts/format | 8 - scripts/lint | 11 - scripts/mock | 41 - scripts/test | 61 - scripts/utils/ruffen-docs.py | 167 -- scripts/utils/upload-artifact.sh | 27 - src/opencode/lib/.keep | 4 - src/opencode_ai/__init__.py | 100 - src/opencode_ai/_base_client.py | 1995 ----------------- src/opencode_ai/_client.py | 413 ---- src/opencode_ai/_compat.py | 219 -- src/opencode_ai/_constants.py | 14 - src/opencode_ai/_exceptions.py | 108 - src/opencode_ai/_files.py | 123 - src/opencode_ai/_models.py | 829 ------- src/opencode_ai/_qs.py | 150 -- src/opencode_ai/_resource.py | 43 - src/opencode_ai/_response.py | 832 ------- src/opencode_ai/_streaming.py | 333 --- src/opencode_ai/_types.py | 219 -- src/opencode_ai/_utils/__init__.py | 57 - src/opencode_ai/_utils/_logs.py | 25 - src/opencode_ai/_utils/_proxy.py | 65 - src/opencode_ai/_utils/_reflection.py | 42 - src/opencode_ai/_utils/_resources_proxy.py | 24 - src/opencode_ai/_utils/_streams.py | 12 - src/opencode_ai/_utils/_sync.py | 86 - src/opencode_ai/_utils/_transform.py | 447 ---- src/opencode_ai/_utils/_typing.py | 151 -- src/opencode_ai/_utils/_utils.py | 422 ---- src/opencode_ai/_version.py | 4 - src/opencode_ai/lib/.keep | 4 - src/opencode_ai/py.typed | 0 src/opencode_ai/resources/__init__.py | 103 - src/opencode_ai/resources/app.py | 408 ---- src/opencode_ai/resources/config.py | 135 -- src/opencode_ai/resources/event.py | 142 -- src/opencode_ai/resources/file.py | 220 -- src/opencode_ai/resources/find.py | 335 --- src/opencode_ai/resources/session.py | 1088 --------- src/opencode_ai/resources/tui.py | 214 -- src/opencode_ai/types/__init__.py | 73 - src/opencode_ai/types/app.py | 33 - src/opencode_ai/types/app_init_response.py | 7 - src/opencode_ai/types/app_log_params.py | 22 - src/opencode_ai/types/app_log_response.py | 7 - src/opencode_ai/types/app_modes_response.py | 10 - .../types/app_providers_response.py | 14 - src/opencode_ai/types/assistant_message.py | 82 - src/opencode_ai/types/config.py | 216 -- src/opencode_ai/types/event_list_response.py | 272 --- src/opencode_ai/types/file.py | 17 - src/opencode_ai/types/file_part.py | 29 - .../types/file_part_input_param.py | 23 - src/opencode_ai/types/file_part_source.py | 12 - .../types/file_part_source_param.py | 13 - .../types/file_part_source_text.py | 13 - .../types/file_part_source_text_param.py | 15 - src/opencode_ai/types/file_read_params.py | 11 - src/opencode_ai/types/file_read_response.py | 13 - src/opencode_ai/types/file_source.py | 16 - src/opencode_ai/types/file_source_param.py | 17 - src/opencode_ai/types/file_status_response.py | 10 - src/opencode_ai/types/find_files_params.py | 11 - src/opencode_ai/types/find_files_response.py | 8 - src/opencode_ai/types/find_symbols_params.py | 11 - .../types/find_symbols_response.py | 10 - src/opencode_ai/types/find_text_params.py | 11 - src/opencode_ai/types/find_text_response.py | 50 - src/opencode_ai/types/keybinds_config.py | 123 - src/opencode_ai/types/mcp_local_config.py | 22 - src/opencode_ai/types/mcp_remote_config.py | 22 - src/opencode_ai/types/message.py | 12 - src/opencode_ai/types/mode.py | 27 - src/opencode_ai/types/mode_config.py | 19 - src/opencode_ai/types/model.py | 45 - src/opencode_ai/types/part.py | 37 - src/opencode_ai/types/provider.py | 22 - src/opencode_ai/types/session.py | 45 - .../types/session_abort_response.py | 7 - src/opencode_ai/types/session_chat_params.py | 31 - .../types/session_delete_response.py | 7 - src/opencode_ai/types/session_init_params.py | 17 - .../types/session_init_response.py | 7 - .../types/session_list_response.py | 10 - .../types/session_messages_response.py | 19 - .../types/session_revert_params.py | 15 - .../types/session_summarize_params.py | 15 - .../types/session_summarize_response.py | 7 - src/opencode_ai/types/shared/__init__.py | 5 - .../types/shared/message_aborted_error.py | 13 - .../types/shared/provider_auth_error.py | 21 - src/opencode_ai/types/shared/unknown_error.py | 17 - src/opencode_ai/types/snapshot_part.py | 21 - src/opencode_ai/types/step_finish_part.py | 39 - src/opencode_ai/types/step_start_part.py | 19 - src/opencode_ai/types/symbol.py | 37 - src/opencode_ai/types/symbol_source.py | 40 - src/opencode_ai/types/symbol_source_param.py | 41 - src/opencode_ai/types/text_part.py | 32 - .../types/text_part_input_param.py | 25 - src/opencode_ai/types/tool_part.py | 35 - src/opencode_ai/types/tool_state_completed.py | 28 - src/opencode_ai/types/tool_state_error.py | 24 - src/opencode_ai/types/tool_state_pending.py | 11 - src/opencode_ai/types/tool_state_running.py | 24 - .../types/tui_append_prompt_params.py | 11 - .../types/tui_append_prompt_response.py | 7 - .../types/tui_open_help_response.py | 7 - src/opencode_ai/types/user_message.py | 23 - tests/__init__.py | 1 - tests/api_resources/__init__.py | 1 - tests/api_resources/test_app.py | 356 --- tests/api_resources/test_config.py | 80 - tests/api_resources/test_event.py | 76 - tests/api_resources/test_file.py | 148 -- tests/api_resources/test_find.py | 232 -- tests/api_resources/test_session.py | 1169 ---------- tests/api_resources/test_tui.py | 148 -- tests/conftest.py | 80 - tests/sample_file.txt | 1 - tests/test_client.py | 1661 -------------- tests/test_deepcopy.py | 58 - tests/test_extract_files.py | 64 - tests/test_files.py | 51 - tests/test_models.py | 963 -------- tests/test_qs.py | 78 - tests/test_required_args.py | 111 - tests/test_response.py | 277 --- tests/test_streaming.py | 248 -- tests/test_transform.py | 453 ---- tests/test_utils/test_proxy.py | 34 - tests/test_utils/test_typing.py | 73 - tests/utils.py | 159 -- 161 files changed, 1 insertion(+), 20065 deletions(-) delete mode 100644 .devcontainer/Dockerfile delete mode 100644 .devcontainer/devcontainer.json delete mode 100644 .github/workflows/ci.yml delete mode 100644 .github/workflows/publish-pypi.yml delete mode 100644 .github/workflows/release-doctor.yml delete mode 100644 .gitignore delete mode 100644 .python-version delete mode 100644 .release-please-manifest.json delete mode 100644 .stats.yml delete mode 100644 .vscode/settings.json delete mode 100644 Brewfile delete mode 100644 CHANGELOG.md delete mode 100644 CONTRIBUTING.md delete mode 100644 LICENSE delete mode 100644 SECURITY.md delete mode 100644 api.md delete mode 100644 bin/check-release-environment delete mode 100644 bin/publish-pypi delete mode 100644 examples/.keep delete mode 100644 mypy.ini delete mode 100644 noxfile.py delete mode 100644 pyproject.toml delete mode 100644 release-please-config.json delete mode 100644 requirements-dev.lock delete mode 100644 requirements.lock delete mode 100755 scripts/bootstrap delete mode 100755 scripts/format delete mode 100755 scripts/lint delete mode 100755 scripts/mock delete mode 100755 scripts/test delete mode 100644 scripts/utils/ruffen-docs.py delete mode 100755 scripts/utils/upload-artifact.sh delete mode 100644 src/opencode/lib/.keep delete mode 100644 src/opencode_ai/__init__.py delete mode 100644 src/opencode_ai/_base_client.py delete mode 100644 src/opencode_ai/_client.py delete mode 100644 src/opencode_ai/_compat.py delete mode 100644 src/opencode_ai/_constants.py delete mode 100644 src/opencode_ai/_exceptions.py delete mode 100644 src/opencode_ai/_files.py delete mode 100644 src/opencode_ai/_models.py delete mode 100644 src/opencode_ai/_qs.py delete mode 100644 src/opencode_ai/_resource.py delete mode 100644 src/opencode_ai/_response.py delete mode 100644 src/opencode_ai/_streaming.py delete mode 100644 src/opencode_ai/_types.py delete mode 100644 src/opencode_ai/_utils/__init__.py delete mode 100644 src/opencode_ai/_utils/_logs.py delete mode 100644 src/opencode_ai/_utils/_proxy.py delete mode 100644 src/opencode_ai/_utils/_reflection.py delete mode 100644 src/opencode_ai/_utils/_resources_proxy.py delete mode 100644 src/opencode_ai/_utils/_streams.py delete mode 100644 src/opencode_ai/_utils/_sync.py delete mode 100644 src/opencode_ai/_utils/_transform.py delete mode 100644 src/opencode_ai/_utils/_typing.py delete mode 100644 src/opencode_ai/_utils/_utils.py delete mode 100644 src/opencode_ai/_version.py delete mode 100644 src/opencode_ai/lib/.keep delete mode 100644 src/opencode_ai/py.typed delete mode 100644 src/opencode_ai/resources/__init__.py delete mode 100644 src/opencode_ai/resources/app.py delete mode 100644 src/opencode_ai/resources/config.py delete mode 100644 src/opencode_ai/resources/event.py delete mode 100644 src/opencode_ai/resources/file.py delete mode 100644 src/opencode_ai/resources/find.py delete mode 100644 src/opencode_ai/resources/session.py delete mode 100644 src/opencode_ai/resources/tui.py delete mode 100644 src/opencode_ai/types/__init__.py delete mode 100644 src/opencode_ai/types/app.py delete mode 100644 src/opencode_ai/types/app_init_response.py delete mode 100644 src/opencode_ai/types/app_log_params.py delete mode 100644 src/opencode_ai/types/app_log_response.py delete mode 100644 src/opencode_ai/types/app_modes_response.py delete mode 100644 src/opencode_ai/types/app_providers_response.py delete mode 100644 src/opencode_ai/types/assistant_message.py delete mode 100644 src/opencode_ai/types/config.py delete mode 100644 src/opencode_ai/types/event_list_response.py delete mode 100644 src/opencode_ai/types/file.py delete mode 100644 src/opencode_ai/types/file_part.py delete mode 100644 src/opencode_ai/types/file_part_input_param.py delete mode 100644 src/opencode_ai/types/file_part_source.py delete mode 100644 src/opencode_ai/types/file_part_source_param.py delete mode 100644 src/opencode_ai/types/file_part_source_text.py delete mode 100644 src/opencode_ai/types/file_part_source_text_param.py delete mode 100644 src/opencode_ai/types/file_read_params.py delete mode 100644 src/opencode_ai/types/file_read_response.py delete mode 100644 src/opencode_ai/types/file_source.py delete mode 100644 src/opencode_ai/types/file_source_param.py delete mode 100644 src/opencode_ai/types/file_status_response.py delete mode 100644 src/opencode_ai/types/find_files_params.py delete mode 100644 src/opencode_ai/types/find_files_response.py delete mode 100644 src/opencode_ai/types/find_symbols_params.py delete mode 100644 src/opencode_ai/types/find_symbols_response.py delete mode 100644 src/opencode_ai/types/find_text_params.py delete mode 100644 src/opencode_ai/types/find_text_response.py delete mode 100644 src/opencode_ai/types/keybinds_config.py delete mode 100644 src/opencode_ai/types/mcp_local_config.py delete mode 100644 src/opencode_ai/types/mcp_remote_config.py delete mode 100644 src/opencode_ai/types/message.py delete mode 100644 src/opencode_ai/types/mode.py delete mode 100644 src/opencode_ai/types/mode_config.py delete mode 100644 src/opencode_ai/types/model.py delete mode 100644 src/opencode_ai/types/part.py delete mode 100644 src/opencode_ai/types/provider.py delete mode 100644 src/opencode_ai/types/session.py delete mode 100644 src/opencode_ai/types/session_abort_response.py delete mode 100644 src/opencode_ai/types/session_chat_params.py delete mode 100644 src/opencode_ai/types/session_delete_response.py delete mode 100644 src/opencode_ai/types/session_init_params.py delete mode 100644 src/opencode_ai/types/session_init_response.py delete mode 100644 src/opencode_ai/types/session_list_response.py delete mode 100644 src/opencode_ai/types/session_messages_response.py delete mode 100644 src/opencode_ai/types/session_revert_params.py delete mode 100644 src/opencode_ai/types/session_summarize_params.py delete mode 100644 src/opencode_ai/types/session_summarize_response.py delete mode 100644 src/opencode_ai/types/shared/__init__.py delete mode 100644 src/opencode_ai/types/shared/message_aborted_error.py delete mode 100644 src/opencode_ai/types/shared/provider_auth_error.py delete mode 100644 src/opencode_ai/types/shared/unknown_error.py delete mode 100644 src/opencode_ai/types/snapshot_part.py delete mode 100644 src/opencode_ai/types/step_finish_part.py delete mode 100644 src/opencode_ai/types/step_start_part.py delete mode 100644 src/opencode_ai/types/symbol.py delete mode 100644 src/opencode_ai/types/symbol_source.py delete mode 100644 src/opencode_ai/types/symbol_source_param.py delete mode 100644 src/opencode_ai/types/text_part.py delete mode 100644 src/opencode_ai/types/text_part_input_param.py delete mode 100644 src/opencode_ai/types/tool_part.py delete mode 100644 src/opencode_ai/types/tool_state_completed.py delete mode 100644 src/opencode_ai/types/tool_state_error.py delete mode 100644 src/opencode_ai/types/tool_state_pending.py delete mode 100644 src/opencode_ai/types/tool_state_running.py delete mode 100644 src/opencode_ai/types/tui_append_prompt_params.py delete mode 100644 src/opencode_ai/types/tui_append_prompt_response.py delete mode 100644 src/opencode_ai/types/tui_open_help_response.py delete mode 100644 src/opencode_ai/types/user_message.py delete mode 100644 tests/__init__.py delete mode 100644 tests/api_resources/__init__.py delete mode 100644 tests/api_resources/test_app.py delete mode 100644 tests/api_resources/test_config.py delete mode 100644 tests/api_resources/test_event.py delete mode 100644 tests/api_resources/test_file.py delete mode 100644 tests/api_resources/test_find.py delete mode 100644 tests/api_resources/test_session.py delete mode 100644 tests/api_resources/test_tui.py delete mode 100644 tests/conftest.py delete mode 100644 tests/sample_file.txt delete mode 100644 tests/test_client.py delete mode 100644 tests/test_deepcopy.py delete mode 100644 tests/test_extract_files.py delete mode 100644 tests/test_files.py delete mode 100644 tests/test_models.py delete mode 100644 tests/test_qs.py delete mode 100644 tests/test_required_args.py delete mode 100644 tests/test_response.py delete mode 100644 tests/test_streaming.py delete mode 100644 tests/test_transform.py delete mode 100644 tests/test_utils/test_proxy.py delete mode 100644 tests/test_utils/test_typing.py delete mode 100644 tests/utils.py diff --git a/.devcontainer/Dockerfile b/.devcontainer/Dockerfile deleted file mode 100644 index ff261ba..0000000 --- a/.devcontainer/Dockerfile +++ /dev/null @@ -1,9 +0,0 @@ -ARG VARIANT="3.9" -FROM mcr.microsoft.com/vscode/devcontainers/python:0-${VARIANT} - -USER vscode - -RUN curl -sSf https://rye.astral.sh/get | RYE_VERSION="0.44.0" RYE_INSTALL_OPTION="--yes" bash -ENV PATH=/home/vscode/.rye/shims:$PATH - -RUN echo "[[ -d .venv ]] && source .venv/bin/activate || export PATH=\$PATH" >> /home/vscode/.bashrc diff --git a/.devcontainer/devcontainer.json b/.devcontainer/devcontainer.json deleted file mode 100644 index c17fdc1..0000000 --- a/.devcontainer/devcontainer.json +++ /dev/null @@ -1,43 +0,0 @@ -// For format details, see https://aka.ms/devcontainer.json. For config options, see the -// README at: https://github.com/devcontainers/templates/tree/main/src/debian -{ - "name": "Debian", - "build": { - "dockerfile": "Dockerfile", - "context": ".." - }, - - "postStartCommand": "rye sync --all-features", - - "customizations": { - "vscode": { - "extensions": [ - "ms-python.python" - ], - "settings": { - "terminal.integrated.shell.linux": "/bin/bash", - "python.pythonPath": ".venv/bin/python", - "python.defaultInterpreterPath": ".venv/bin/python", - "python.typeChecking": "basic", - "terminal.integrated.env.linux": { - "PATH": "/home/vscode/.rye/shims:${env:PATH}" - } - } - } - }, - "features": { - "ghcr.io/devcontainers/features/node:1": {} - } - - // Features to add to the dev container. More info: https://containers.dev/features. - // "features": {}, - - // Use 'forwardPorts' to make a list of ports inside the container available locally. - // "forwardPorts": [], - - // Configure tool-specific properties. - // "customizations": {}, - - // Uncomment to connect as root instead. More info: https://aka.ms/dev-containers-non-root. - // "remoteUser": "root" -} diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml deleted file mode 100644 index 04808c6..0000000 --- a/.github/workflows/ci.yml +++ /dev/null @@ -1,98 +0,0 @@ -name: CI -on: - push: - branches-ignore: - - 'generated' - - 'codegen/**' - - 'integrated/**' - - 'stl-preview-head/**' - - 'stl-preview-base/**' - pull_request: - branches-ignore: - - 'stl-preview-head/**' - - 'stl-preview-base/**' - -jobs: - lint: - timeout-minutes: 10 - name: lint - runs-on: ${{ github.repository == 'stainless-sdks/opencode-python' && 'depot-ubuntu-24.04' || 'ubuntu-latest' }} - if: github.event_name == 'push' || github.event.pull_request.head.repo.fork - steps: - - uses: actions/checkout@v4 - - - name: Install Rye - run: | - curl -sSf https://rye.astral.sh/get | bash - echo "$HOME/.rye/shims" >> $GITHUB_PATH - env: - RYE_VERSION: '0.44.0' - RYE_INSTALL_OPTION: '--yes' - - - name: Install dependencies - run: rye sync --all-features - - - name: Run lints - run: ./scripts/lint - - build: - if: github.event_name == 'push' || github.event.pull_request.head.repo.fork - timeout-minutes: 10 - name: build - permissions: - contents: read - id-token: write - runs-on: ${{ github.repository == 'stainless-sdks/opencode-python' && 'depot-ubuntu-24.04' || 'ubuntu-latest' }} - steps: - - uses: actions/checkout@v4 - - - name: Install Rye - run: | - curl -sSf https://rye.astral.sh/get | bash - echo "$HOME/.rye/shims" >> $GITHUB_PATH - env: - RYE_VERSION: '0.44.0' - RYE_INSTALL_OPTION: '--yes' - - - name: Install dependencies - run: rye sync --all-features - - - name: Run build - run: rye build - - - name: Get GitHub OIDC Token - if: github.repository == 'stainless-sdks/opencode-python' - id: github-oidc - uses: actions/github-script@v6 - with: - script: core.setOutput('github_token', await core.getIDToken()); - - - name: Upload tarball - if: github.repository == 'stainless-sdks/opencode-python' - env: - URL: https://pkg.stainless.com/s - AUTH: ${{ steps.github-oidc.outputs.github_token }} - SHA: ${{ github.sha }} - run: ./scripts/utils/upload-artifact.sh - - test: - timeout-minutes: 10 - name: test - runs-on: ${{ github.repository == 'stainless-sdks/opencode-python' && 'depot-ubuntu-24.04' || 'ubuntu-latest' }} - if: github.event_name == 'push' || github.event.pull_request.head.repo.fork - steps: - - uses: actions/checkout@v4 - - - name: Install Rye - run: | - curl -sSf https://rye.astral.sh/get | bash - echo "$HOME/.rye/shims" >> $GITHUB_PATH - env: - RYE_VERSION: '0.44.0' - RYE_INSTALL_OPTION: '--yes' - - - name: Bootstrap - run: ./scripts/bootstrap - - - name: Run tests - run: ./scripts/test diff --git a/.github/workflows/publish-pypi.yml b/.github/workflows/publish-pypi.yml deleted file mode 100644 index c9b531e..0000000 --- a/.github/workflows/publish-pypi.yml +++ /dev/null @@ -1,31 +0,0 @@ -# This workflow is triggered when a GitHub release is created. -# It can also be run manually to re-publish to PyPI in case it failed for some reason. -# You can run this workflow by navigating to https://www.github.com/sst/opencode-sdk-python/actions/workflows/publish-pypi.yml -name: Publish PyPI -on: - workflow_dispatch: - - release: - types: [published] - -jobs: - publish: - name: publish - runs-on: ubuntu-latest - - steps: - - uses: actions/checkout@v4 - - - name: Install Rye - run: | - curl -sSf https://rye.astral.sh/get | bash - echo "$HOME/.rye/shims" >> $GITHUB_PATH - env: - RYE_VERSION: '0.44.0' - RYE_INSTALL_OPTION: '--yes' - - - name: Publish to PyPI - run: | - bash ./bin/publish-pypi - env: - PYPI_TOKEN: ${{ secrets.OPENCODE_PYPI_TOKEN || secrets.PYPI_TOKEN }} diff --git a/.github/workflows/release-doctor.yml b/.github/workflows/release-doctor.yml deleted file mode 100644 index 0e12a2b..0000000 --- a/.github/workflows/release-doctor.yml +++ /dev/null @@ -1,21 +0,0 @@ -name: Release Doctor -on: - pull_request: - branches: - - main - workflow_dispatch: - -jobs: - release_doctor: - name: release doctor - runs-on: ubuntu-latest - if: github.repository == 'sst/opencode-sdk-python' && (github.event_name == 'push' || github.event_name == 'workflow_dispatch' || startsWith(github.head_ref, 'release-please') || github.head_ref == 'next') - - steps: - - uses: actions/checkout@v4 - - - name: Check release environment - run: | - bash ./bin/check-release-environment - env: - PYPI_TOKEN: ${{ secrets.OPENCODE_PYPI_TOKEN || secrets.PYPI_TOKEN }} diff --git a/.gitignore b/.gitignore deleted file mode 100644 index 95ceb18..0000000 --- a/.gitignore +++ /dev/null @@ -1,15 +0,0 @@ -.prism.log -_dev - -__pycache__ -.mypy_cache - -dist - -.venv -.idea - -.env -.envrc -codegen.log -Brewfile.lock.json diff --git a/.python-version b/.python-version deleted file mode 100644 index 43077b2..0000000 --- a/.python-version +++ /dev/null @@ -1 +0,0 @@ -3.9.18 diff --git a/.release-please-manifest.json b/.release-please-manifest.json deleted file mode 100644 index a696b6a..0000000 --- a/.release-please-manifest.json +++ /dev/null @@ -1,3 +0,0 @@ -{ - ".": "0.1.0-alpha.36" -} \ No newline at end of file diff --git a/.stats.yml b/.stats.yml deleted file mode 100644 index 3f719fa..0000000 --- a/.stats.yml +++ /dev/null @@ -1,4 +0,0 @@ -configured_endpoints: 26 -openapi_spec_url: https://storage.googleapis.com/stainless-sdk-openapi-specs/opencode%2Fopencode-62d8fccba4eb8dc3a80434e0849eab3352e49fb96a718bb7b6d17ed8e582b716.yml -openapi_spec_hash: 4ff9376cf9634e91731e63fe482ea532 -config_hash: 1ae82c93499b9f0b9ba828b8919f9cb3 diff --git a/.vscode/settings.json b/.vscode/settings.json deleted file mode 100644 index 5b01030..0000000 --- a/.vscode/settings.json +++ /dev/null @@ -1,3 +0,0 @@ -{ - "python.analysis.importFormat": "relative", -} diff --git a/Brewfile b/Brewfile deleted file mode 100644 index 492ca37..0000000 --- a/Brewfile +++ /dev/null @@ -1,2 +0,0 @@ -brew "rye" - diff --git a/CHANGELOG.md b/CHANGELOG.md deleted file mode 100644 index 16b4106..0000000 --- a/CHANGELOG.md +++ /dev/null @@ -1,342 +0,0 @@ -# Changelog - -## 0.1.0-alpha.36 (2025-08-27) - -Full Changelog: [v0.1.0-alpha.35...v0.1.0-alpha.36](https://github.com/sst/opencode-sdk-python/compare/v0.1.0-alpha.35...v0.1.0-alpha.36) - -### Features - -* **client:** support file upload requests ([c2e1522](https://github.com/sst/opencode-sdk-python/commit/c2e1522ffba596910098e1e58eef7b4d00548e18)) - - -### Bug Fixes - -* avoid newer type syntax ([42779eb](https://github.com/sst/opencode-sdk-python/commit/42779eb3d7035b677ef213d6508658dbd56b50bf)) - - -### Chores - -* **internal:** change ci workflow machines ([14c918e](https://github.com/sst/opencode-sdk-python/commit/14c918ee18edc797d2d8dd1f1d462ffb004a7e89)) -* **internal:** codegen related update ([477ff58](https://github.com/sst/opencode-sdk-python/commit/477ff58aa920fd0c02378ff33c041d061923dee4)) -* **internal:** fix ruff target version ([359b956](https://github.com/sst/opencode-sdk-python/commit/359b95615445c2f675aa3520cb233c19b50dfe31)) -* **internal:** update comment in script ([9ac7cbb](https://github.com/sst/opencode-sdk-python/commit/9ac7cbb6dba85d18d18bd37f95dffc0eea3d2605)) -* **internal:** update pyright exclude list ([5d96f63](https://github.com/sst/opencode-sdk-python/commit/5d96f63f7a9aaebb2e85fcd11b380bbeca3b7310)) -* update @stainless-api/prism-cli to v5.15.0 ([88487ee](https://github.com/sst/opencode-sdk-python/commit/88487ee6dbc8ac60e9d26573c8f08ae3f1389e83)) -* update github action ([fe98742](https://github.com/sst/opencode-sdk-python/commit/fe98742cd53b1e8783f600879164dd16fea610d3)) - -## 0.1.0-alpha.35 (2025-07-29) - -Full Changelog: [v0.1.0-alpha.34...v0.1.0-alpha.35](https://github.com/sst/opencode-sdk-python/compare/v0.1.0-alpha.34...v0.1.0-alpha.35) - -### Features - -* **api:** api update ([06ebf15](https://github.com/sst/opencode-sdk-python/commit/06ebf15e7ed0f782dbf51352a71fc5edb948a93c)) - -## 0.1.0-alpha.34 (2025-07-28) - -Full Changelog: [v0.1.0-alpha.33...v0.1.0-alpha.34](https://github.com/sst/opencode-sdk-python/compare/v0.1.0-alpha.33...v0.1.0-alpha.34) - -### Features - -* **api:** api update ([0bc9251](https://github.com/sst/opencode-sdk-python/commit/0bc92517109d280c22e22639ee4ffa58d63d837b)) -* **api:** api update ([14ada9d](https://github.com/sst/opencode-sdk-python/commit/14ada9d7d1e93d85f357f417633b691b116c8ad5)) - -## 0.1.0-alpha.33 (2025-07-25) - -Full Changelog: [v0.1.0-alpha.32...v0.1.0-alpha.33](https://github.com/sst/opencode-sdk-python/compare/v0.1.0-alpha.32...v0.1.0-alpha.33) - -### Features - -* **api:** api update ([9002768](https://github.com/sst/opencode-sdk-python/commit/9002768edd617a44d4d686dd9e88f41fe6a56f2f)) - - -### Chores - -* **project:** add settings file for vscode ([7fff9af](https://github.com/sst/opencode-sdk-python/commit/7fff9af8fd66865dc933dce74f0385250377af87)) - -## 0.1.0-alpha.32 (2025-07-24) - -Full Changelog: [v0.1.0-alpha.31...v0.1.0-alpha.32](https://github.com/sst/opencode-sdk-python/compare/v0.1.0-alpha.31...v0.1.0-alpha.32) - -### Features - -* **api:** api update ([988b38c](https://github.com/sst/opencode-sdk-python/commit/988b38ce1d4b7694083abe26f2198463d4555012)) - -## 0.1.0-alpha.31 (2025-07-24) - -Full Changelog: [v0.1.0-alpha.30...v0.1.0-alpha.31](https://github.com/sst/opencode-sdk-python/compare/v0.1.0-alpha.30...v0.1.0-alpha.31) - -### Features - -* **api:** api update ([35553a6](https://github.com/sst/opencode-sdk-python/commit/35553a6e3b3472562cdc38f0399fcd37af1b52e9)) - -## 0.1.0-alpha.30 (2025-07-23) - -Full Changelog: [v0.1.0-alpha.29...v0.1.0-alpha.30](https://github.com/sst/opencode-sdk-python/compare/v0.1.0-alpha.29...v0.1.0-alpha.30) - -### Bug Fixes - -* **parsing:** parse extra field types ([6817656](https://github.com/sst/opencode-sdk-python/commit/6817656ba347e8074960af1526763c134d75cf7d)) - -## 0.1.0-alpha.29 (2025-07-22) - -Full Changelog: [v0.1.0-alpha.28...v0.1.0-alpha.29](https://github.com/sst/opencode-sdk-python/compare/v0.1.0-alpha.28...v0.1.0-alpha.29) - -### Features - -* **api:** api update ([08130f0](https://github.com/sst/opencode-sdk-python/commit/08130f0c068f4008ffda297c68a68a44dec34d95)) - -## 0.1.0-alpha.28 (2025-07-22) - -Full Changelog: [v0.1.0-alpha.27...v0.1.0-alpha.28](https://github.com/sst/opencode-sdk-python/compare/v0.1.0-alpha.27...v0.1.0-alpha.28) - -### Features - -* **api:** api update ([e8022cd](https://github.com/sst/opencode-sdk-python/commit/e8022cd6d313c1c710dc2721f7e962285d48b02e)) - -## 0.1.0-alpha.27 (2025-07-22) - -Full Changelog: [v0.1.0-alpha.26...v0.1.0-alpha.27](https://github.com/sst/opencode-sdk-python/compare/v0.1.0-alpha.26...v0.1.0-alpha.27) - -### Features - -* **api:** api update ([50c887c](https://github.com/sst/opencode-sdk-python/commit/50c887c4202f587317afecb8998181c6de78b7b4)) - - -### Bug Fixes - -* **parsing:** ignore empty metadata ([8ee35ae](https://github.com/sst/opencode-sdk-python/commit/8ee35ae762cb0ade81b08cc41a9f496afe9fd484)) - -## 0.1.0-alpha.26 (2025-07-21) - -Full Changelog: [v0.1.0-alpha.25...v0.1.0-alpha.26](https://github.com/sst/opencode-sdk-python/compare/v0.1.0-alpha.25...v0.1.0-alpha.26) - -### Features - -* **api:** api update ([827dc0c](https://github.com/sst/opencode-sdk-python/commit/827dc0c780afd217f981cdd31d371fe96327aeec)) - -## 0.1.0-alpha.25 (2025-07-21) - -Full Changelog: [v0.1.0-alpha.24...v0.1.0-alpha.25](https://github.com/sst/opencode-sdk-python/compare/v0.1.0-alpha.24...v0.1.0-alpha.25) - -### Features - -* **api:** api update ([a85f832](https://github.com/sst/opencode-sdk-python/commit/a85f832a942075091b9ca3f7e6399ba00239f354)) - -## 0.1.0-alpha.24 (2025-07-21) - -Full Changelog: [v0.1.0-alpha.23...v0.1.0-alpha.24](https://github.com/sst/opencode-sdk-python/compare/v0.1.0-alpha.23...v0.1.0-alpha.24) - -### Features - -* **api:** api update ([bd6dd48](https://github.com/sst/opencode-sdk-python/commit/bd6dd48f11b23c77802e0a36af94c1a92c0326c7)) - -## 0.1.0-alpha.23 (2025-07-18) - -Full Changelog: [v0.1.0-alpha.22...v0.1.0-alpha.23](https://github.com/sst/opencode-sdk-python/compare/v0.1.0-alpha.22...v0.1.0-alpha.23) - -### Features - -* **api:** api update ([d8c9fc9](https://github.com/sst/opencode-sdk-python/commit/d8c9fc984c48b7dadce8840c5c1e010a508d58b4)) - -## 0.1.0-alpha.22 (2025-07-17) - -Full Changelog: [v0.1.0-alpha.21...v0.1.0-alpha.22](https://github.com/sst/opencode-sdk-python/compare/v0.1.0-alpha.21...v0.1.0-alpha.22) - -### Features - -* **api:** api update ([582070a](https://github.com/sst/opencode-sdk-python/commit/582070ae69b0ae1088271038b0fcb818c30c74cf)) - -## 0.1.0-alpha.21 (2025-07-17) - -Full Changelog: [v0.1.0-alpha.20...v0.1.0-alpha.21](https://github.com/sst/opencode-sdk-python/compare/v0.1.0-alpha.20...v0.1.0-alpha.21) - -### Features - -* **api:** api update ([71211e8](https://github.com/sst/opencode-sdk-python/commit/71211e888ecd5e848ac4de5ed058e4756025f694)) - -## 0.1.0-alpha.20 (2025-07-17) - -Full Changelog: [v0.1.0-alpha.19...v0.1.0-alpha.20](https://github.com/sst/opencode-sdk-python/compare/v0.1.0-alpha.19...v0.1.0-alpha.20) - -### Features - -* **api:** api update ([f48c0d6](https://github.com/sst/opencode-sdk-python/commit/f48c0d6bb1943df3e3758d19b83c70fd1c15e2c2)) - -## 0.1.0-alpha.19 (2025-07-16) - -Full Changelog: [v0.1.0-alpha.18...v0.1.0-alpha.19](https://github.com/sst/opencode-sdk-python/compare/v0.1.0-alpha.18...v0.1.0-alpha.19) - -### Features - -* **api:** api update ([07b8550](https://github.com/sst/opencode-sdk-python/commit/07b8550e658373298ac5d64eed102f21d03a29fa)) - -## 0.1.0-alpha.18 (2025-07-16) - -Full Changelog: [v0.1.0-alpha.17...v0.1.0-alpha.18](https://github.com/sst/opencode-sdk-python/compare/v0.1.0-alpha.17...v0.1.0-alpha.18) - -### Features - -* **api:** api update ([02c3399](https://github.com/sst/opencode-sdk-python/commit/02c3399fb52fa96d50e6dd9c74f3106d1107308e)) - -## 0.1.0-alpha.17 (2025-07-16) - -Full Changelog: [v0.1.0-alpha.16...v0.1.0-alpha.17](https://github.com/sst/opencode-sdk-python/compare/v0.1.0-alpha.16...v0.1.0-alpha.17) - -### Features - -* **api:** api update ([e76b574](https://github.com/sst/opencode-sdk-python/commit/e76b57439c37c0d3514e1497a4d1a78279844bdc)) - -## 0.1.0-alpha.16 (2025-07-15) - -Full Changelog: [v0.1.0-alpha.15...v0.1.0-alpha.16](https://github.com/sst/opencode-sdk-python/compare/v0.1.0-alpha.15...v0.1.0-alpha.16) - -### Features - -* **api:** api update ([670fa88](https://github.com/sst/opencode-sdk-python/commit/670fa889512f9000e6fee8c9f5c2b49434224592)) - -## 0.1.0-alpha.15 (2025-07-15) - -Full Changelog: [v0.1.0-alpha.14...v0.1.0-alpha.15](https://github.com/sst/opencode-sdk-python/compare/v0.1.0-alpha.14...v0.1.0-alpha.15) - -### Features - -* **api:** api update ([88bbf66](https://github.com/sst/opencode-sdk-python/commit/88bbf66c1f6ec7266fccb7f8e3265bb074afd5e6)) - -## 0.1.0-alpha.14 (2025-07-15) - -Full Changelog: [v0.1.0-alpha.13...v0.1.0-alpha.14](https://github.com/sst/opencode-sdk-python/compare/v0.1.0-alpha.13...v0.1.0-alpha.14) - -### Features - -* **api:** api update ([80d8572](https://github.com/sst/opencode-sdk-python/commit/80d85724c6b17b867ac3d19b0741bb88bb604798)) - -## 0.1.0-alpha.13 (2025-07-15) - -Full Changelog: [v0.1.0-alpha.12...v0.1.0-alpha.13](https://github.com/sst/opencode-sdk-python/compare/v0.1.0-alpha.12...v0.1.0-alpha.13) - -### Features - -* **api:** api update ([a51d627](https://github.com/sst/opencode-sdk-python/commit/a51d627f3a39324ca769a688b63c95dc8f5eba35)) - -## 0.1.0-alpha.12 (2025-07-12) - -Full Changelog: [v0.1.0-alpha.11...v0.1.0-alpha.12](https://github.com/sst/opencode-sdk-python/compare/v0.1.0-alpha.11...v0.1.0-alpha.12) - -### Bug Fixes - -* **client:** don't send Content-Type header on GET requests ([d52fbac](https://github.com/sst/opencode-sdk-python/commit/d52fbac0f4e2ae7f3338272eb7075f1401912fe4)) - - -### Chores - -* **readme:** fix version rendering on pypi ([d7ae516](https://github.com/sst/opencode-sdk-python/commit/d7ae5162cc2346314e69fd7609050d0e97eecf6c)) - -## 0.1.0-alpha.11 (2025-07-09) - -Full Changelog: [v0.1.0-alpha.10...v0.1.0-alpha.11](https://github.com/sst/opencode-sdk-python/compare/v0.1.0-alpha.10...v0.1.0-alpha.11) - -### Bug Fixes - -* **parsing:** correctly handle nested discriminated unions ([ff5c4a1](https://github.com/sst/opencode-sdk-python/commit/ff5c4a14337714858bd0c193a453fc28f011b263)) - - -### Chores - -* **internal:** bump pinned h11 dep ([6faa22e](https://github.com/sst/opencode-sdk-python/commit/6faa22e132534a89f10a872ead9ce78fd4ab553c)) -* **package:** mark python 3.13 as supported ([5f2edbe](https://github.com/sst/opencode-sdk-python/commit/5f2edbe52d0450a205d69d57e75ee571cabe4b10)) - -## 0.1.0-alpha.10 (2025-07-06) - -Full Changelog: [v0.1.0-alpha.9...v0.1.0-alpha.10](https://github.com/sst/opencode-sdk-python/compare/v0.1.0-alpha.9...v0.1.0-alpha.10) - -### Features - -* **api:** manual updates ([fdab2a9](https://github.com/sst/opencode-sdk-python/commit/fdab2a9ee5b71d90b1c18c00f67e40247efae0e4)) - -## 0.1.0-alpha.9 (2025-07-05) - -Full Changelog: [v0.1.0-alpha.8...v0.1.0-alpha.9](https://github.com/sst/opencode-sdk-python/compare/v0.1.0-alpha.8...v0.1.0-alpha.9) - -### Features - -* **api:** manual updates ([27a53d3](https://github.com/sst/opencode-sdk-python/commit/27a53d3f43455c8420c1501f3995c140f0bf777d)) - -## 0.1.0-alpha.8 (2025-07-03) - -Full Changelog: [v0.1.0-alpha.7...v0.1.0-alpha.8](https://github.com/sst/opencode-sdk-python/compare/v0.1.0-alpha.7...v0.1.0-alpha.8) - -### Features - -* **api:** api update ([6f7ea7f](https://github.com/sst/opencode-sdk-python/commit/6f7ea7f1f813c31e513fbe33d8653fe3e07f7831)) - -## 0.1.0-alpha.7 (2025-07-02) - -Full Changelog: [v0.1.0-alpha.6...v0.1.0-alpha.7](https://github.com/sst/opencode-sdk-python/compare/v0.1.0-alpha.6...v0.1.0-alpha.7) - -### Features - -* **api:** update via SDK Studio ([84628c0](https://github.com/sst/opencode-sdk-python/commit/84628c0bd3cd508832f04db0fd8a6cd5367dddf3)) - - -### Chores - -* **ci:** change upload type ([f3019c9](https://github.com/sst/opencode-sdk-python/commit/f3019c94cb548e436b2d7d884969a90db4649f80)) - -## 0.1.0-alpha.6 (2025-06-30) - -Full Changelog: [v0.1.0-alpha.5...v0.1.0-alpha.6](https://github.com/sst/opencode-sdk-python/compare/v0.1.0-alpha.5...v0.1.0-alpha.6) - -### Features - -* **api:** update via SDK Studio ([e1cb382](https://github.com/sst/opencode-sdk-python/commit/e1cb382c5391eb135a31ad98c7301c061191c563)) -* **api:** update via SDK Studio ([0985851](https://github.com/sst/opencode-sdk-python/commit/09858518e9312ca72238efd596cc0313927c26e3)) - -## 0.1.0-alpha.5 (2025-06-30) - -Full Changelog: [v0.1.0-alpha.4...v0.1.0-alpha.5](https://github.com/sst/opencode-sdk-python/compare/v0.1.0-alpha.4...v0.1.0-alpha.5) - -### Bug Fixes - -* **ci:** correct conditional ([6a748da](https://github.com/sst/opencode-sdk-python/commit/6a748dadf9df2b27b9c1123dc3ef989213f75090)) - - -### Chores - -* **ci:** only run for pushes and fork pull requests ([493f7d2](https://github.com/sst/opencode-sdk-python/commit/493f7d2131e0e17fc2128dad40b327e708f64366)) - -## 0.1.0-alpha.4 (2025-06-27) - -Full Changelog: [v0.1.0-alpha.3...v0.1.0-alpha.4](https://github.com/sst/opencode-sdk-python/compare/v0.1.0-alpha.3...v0.1.0-alpha.4) - -### Features - -* **api:** update via SDK Studio ([6a793f7](https://github.com/sst/opencode-sdk-python/commit/6a793f7fd33a34f19656a3e723b61a32b0068a88)) - -## 0.1.0-alpha.3 (2025-06-27) - -Full Changelog: [v0.1.0-alpha.2...v0.1.0-alpha.3](https://github.com/sst/opencode-sdk-python/compare/v0.1.0-alpha.2...v0.1.0-alpha.3) - -### Features - -* **api:** update via SDK Studio ([9ab5a57](https://github.com/sst/opencode-sdk-python/commit/9ab5a570a78b28aa0dfbad5e6302f930f2011fed)) -* **api:** update via SDK Studio ([3e426e4](https://github.com/sst/opencode-sdk-python/commit/3e426e4328bd876b3bc5123e20b9a1b69dd1756d)) - -## 0.1.0-alpha.2 (2025-06-27) - -Full Changelog: [v0.1.0-alpha.1...v0.1.0-alpha.2](https://github.com/sst/opencode-sdk-python/compare/v0.1.0-alpha.1...v0.1.0-alpha.2) - -### Features - -* **api:** update via SDK Studio ([a6cf7c5](https://github.com/sst/opencode-sdk-python/commit/a6cf7c5b2a411503294088428ca7918226eca161)) - -## 0.1.0-alpha.1 (2025-06-27) - -Full Changelog: [v0.0.1-alpha.0...v0.1.0-alpha.1](https://github.com/sst/opencode-sdk-python/compare/v0.0.1-alpha.0...v0.1.0-alpha.1) - -### Features - -* **api:** update via SDK Studio ([6e58c71](https://github.com/sst/opencode-sdk-python/commit/6e58c71f2372aa3d44c0d30e0309011ef22a9e04)) -* **api:** update via SDK Studio ([06a27a0](https://github.com/sst/opencode-sdk-python/commit/06a27a02713a8d7bb141e1db844c0b7466818a1d)) -* **api:** update via SDK Studio ([e77f059](https://github.com/sst/opencode-sdk-python/commit/e77f05977e808723ca9df84c481a42f601ca4fd1)) -* **api:** update via SDK Studio ([ff05a4a](https://github.com/sst/opencode-sdk-python/commit/ff05a4adf063d98b3434af29069ea513243071e0)) diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md deleted file mode 100644 index dcef083..0000000 --- a/CONTRIBUTING.md +++ /dev/null @@ -1,128 +0,0 @@ -## Setting up the environment - -### With Rye - -We use [Rye](https://rye.astral.sh/) to manage dependencies because it will automatically provision a Python environment with the expected Python version. To set it up, run: - -```sh -$ ./scripts/bootstrap -``` - -Or [install Rye manually](https://rye.astral.sh/guide/installation/) and run: - -```sh -$ rye sync --all-features -``` - -You can then run scripts using `rye run python script.py` or by activating the virtual environment: - -```sh -# Activate the virtual environment - https://docs.python.org/3/library/venv.html#how-venvs-work -$ source .venv/bin/activate - -# now you can omit the `rye run` prefix -$ python script.py -``` - -### Without Rye - -Alternatively if you don't want to install `Rye`, you can stick with the standard `pip` setup by ensuring you have the Python version specified in `.python-version`, create a virtual environment however you desire and then install dependencies using this command: - -```sh -$ pip install -r requirements-dev.lock -``` - -## Modifying/Adding code - -Most of the SDK is generated code. Modifications to code will be persisted between generations, but may -result in merge conflicts between manual patches and changes from the generator. The generator will never -modify the contents of the `src/opencode_ai/lib/` and `examples/` directories. - -## Adding and running examples - -All files in the `examples/` directory are not modified by the generator and can be freely edited or added to. - -```py -# add an example to examples/.py - -#!/usr/bin/env -S rye run python -… -``` - -```sh -$ chmod +x examples/.py -# run the example against your api -$ ./examples/.py -``` - -## Using the repository from source - -If you’d like to use the repository from source, you can either install from git or link to a cloned repository: - -To install via git: - -```sh -$ pip install git+ssh://git@github.com/sst/opencode-sdk-python.git -``` - -Alternatively, you can build from source and install the wheel file: - -Building this package will create two files in the `dist/` directory, a `.tar.gz` containing the source files and a `.whl` that can be used to install the package efficiently. - -To create a distributable version of the library, all you have to do is run this command: - -```sh -$ rye build -# or -$ python -m build -``` - -Then to install: - -```sh -$ pip install ./path-to-wheel-file.whl -``` - -## Running tests - -Most tests require you to [set up a mock server](https://github.com/stoplightio/prism) against the OpenAPI spec to run the tests. - -```sh -# you will need npm installed -$ npx prism mock path/to/your/openapi.yml -``` - -```sh -$ ./scripts/test -``` - -## Linting and formatting - -This repository uses [ruff](https://github.com/astral-sh/ruff) and -[black](https://github.com/psf/black) to format the code in the repository. - -To lint: - -```sh -$ ./scripts/lint -``` - -To format and fix all ruff issues automatically: - -```sh -$ ./scripts/format -``` - -## Publishing and releases - -Changes made to this repository via the automated release PR pipeline should publish to PyPI automatically. If -the changes aren't made through the automated pipeline, you may want to make releases manually. - -### Publish with a GitHub workflow - -You can release to package managers by using [the `Publish PyPI` GitHub action](https://www.github.com/sst/opencode-sdk-python/actions/workflows/publish-pypi.yml). This requires a setup organization or repository secret to be set up. - -### Publish manually - -If you need to manually release a package, you can run the `bin/publish-pypi` script with a `PYPI_TOKEN` set on -the environment. diff --git a/LICENSE b/LICENSE deleted file mode 100644 index 821edeb..0000000 --- a/LICENSE +++ /dev/null @@ -1,7 +0,0 @@ -Copyright 2025 opencode - -Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: - -The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. - -THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. diff --git a/README.md b/README.md index 0e9425a..e1ba7c9 100644 --- a/README.md +++ b/README.md @@ -1,374 +1 @@ -# Opencode Python API library - - -[![PyPI version](https://img.shields.io/pypi/v/opencode-ai.svg?label=pypi%20(stable))](https://pypi.org/project/opencode-ai/) - -The Opencode Python library provides convenient access to the Opencode REST API from any Python 3.8+ -application. The library includes type definitions for all request params and response fields, -and offers both synchronous and asynchronous clients powered by [httpx](https://github.com/encode/httpx). - -It is generated with [Stainless](https://www.stainless.com/). - -## Documentation - -The REST API documentation can be found on [opencode.ai](https://opencode.ai/docs). The full API of this library can be found in [api.md](api.md). - -## Installation - -```sh -# install from PyPI -pip install --pre opencode-ai -``` - -## Usage - -The full API of this library can be found in [api.md](api.md). - -```python -from opencode_ai import Opencode - -client = Opencode() - -sessions = client.session.list() -``` - -## Async usage - -Simply import `AsyncOpencode` instead of `Opencode` and use `await` with each API call: - -```python -import asyncio -from opencode_ai import AsyncOpencode - -client = AsyncOpencode() - - -async def main() -> None: - sessions = await client.session.list() - - -asyncio.run(main()) -``` - -Functionality between the synchronous and asynchronous clients is otherwise identical. - -### With aiohttp - -By default, the async client uses `httpx` for HTTP requests. However, for improved concurrency performance you may also use `aiohttp` as the HTTP backend. - -You can enable this by installing `aiohttp`: - -```sh -# install from PyPI -pip install --pre opencode-ai[aiohttp] -``` - -Then you can enable it by instantiating the client with `http_client=DefaultAioHttpClient()`: - -```python -import asyncio -from opencode_ai import DefaultAioHttpClient -from opencode_ai import AsyncOpencode - - -async def main() -> None: - async with AsyncOpencode( - http_client=DefaultAioHttpClient(), - ) as client: - sessions = await client.session.list() - - -asyncio.run(main()) -``` - -## Streaming responses - -We provide support for streaming responses using Server Side Events (SSE). - -```python -from opencode_ai import Opencode - -client = Opencode() - -stream = client.event.list() -for events in stream: - print(events) -``` - -The async client uses the exact same interface. - -```python -from opencode_ai import AsyncOpencode - -client = AsyncOpencode() - -stream = await client.event.list() -async for events in stream: - print(events) -``` - -## Using types - -Nested request parameters are [TypedDicts](https://docs.python.org/3/library/typing.html#typing.TypedDict). Responses are [Pydantic models](https://docs.pydantic.dev) which also provide helper methods for things like: - -- Serializing back into JSON, `model.to_json()` -- Converting to a dictionary, `model.to_dict()` - -Typed requests and responses provide autocomplete and documentation within your editor. If you would like to see type errors in VS Code to help catch bugs earlier, set `python.analysis.typeCheckingMode` to `basic`. - -## Handling errors - -When the library is unable to connect to the API (for example, due to network connection problems or a timeout), a subclass of `opencode_ai.APIConnectionError` is raised. - -When the API returns a non-success status code (that is, 4xx or 5xx -response), a subclass of `opencode_ai.APIStatusError` is raised, containing `status_code` and `response` properties. - -All errors inherit from `opencode_ai.APIError`. - -```python -import opencode_ai -from opencode_ai import Opencode - -client = Opencode() - -try: - client.session.list() -except opencode_ai.APIConnectionError as e: - print("The server could not be reached") - print(e.__cause__) # an underlying Exception, likely raised within httpx. -except opencode_ai.RateLimitError as e: - print("A 429 status code was received; we should back off a bit.") -except opencode_ai.APIStatusError as e: - print("Another non-200-range status code was received") - print(e.status_code) - print(e.response) -``` - -Error codes are as follows: - -| Status Code | Error Type | -| ----------- | -------------------------- | -| 400 | `BadRequestError` | -| 401 | `AuthenticationError` | -| 403 | `PermissionDeniedError` | -| 404 | `NotFoundError` | -| 422 | `UnprocessableEntityError` | -| 429 | `RateLimitError` | -| >=500 | `InternalServerError` | -| N/A | `APIConnectionError` | - -### Retries - -Certain errors are automatically retried 2 times by default, with a short exponential backoff. -Connection errors (for example, due to a network connectivity problem), 408 Request Timeout, 409 Conflict, -429 Rate Limit, and >=500 Internal errors are all retried by default. - -You can use the `max_retries` option to configure or disable retry settings: - -```python -from opencode_ai import Opencode - -# Configure the default for all requests: -client = Opencode( - # default is 2 - max_retries=0, -) - -# Or, configure per-request: -client.with_options(max_retries=5).session.list() -``` - -### Timeouts - -By default requests time out after 1 minute. You can configure this with a `timeout` option, -which accepts a float or an [`httpx.Timeout`](https://www.python-httpx.org/advanced/timeouts/#fine-tuning-the-configuration) object: - -```python -from opencode_ai import Opencode - -# Configure the default for all requests: -client = Opencode( - # 20 seconds (default is 1 minute) - timeout=20.0, -) - -# More granular control: -client = Opencode( - timeout=httpx.Timeout(60.0, read=5.0, write=10.0, connect=2.0), -) - -# Override per-request: -client.with_options(timeout=5.0).session.list() -``` - -On timeout, an `APITimeoutError` is thrown. - -Note that requests that time out are [retried twice by default](#retries). - -## Advanced - -### Logging - -We use the standard library [`logging`](https://docs.python.org/3/library/logging.html) module. - -You can enable logging by setting the environment variable `OPENCODE_LOG` to `info`. - -```shell -$ export OPENCODE_LOG=info -``` - -Or to `debug` for more verbose logging. - -### How to tell whether `None` means `null` or missing - -In an API response, a field may be explicitly `null`, or missing entirely; in either case, its value is `None` in this library. You can differentiate the two cases with `.model_fields_set`: - -```py -if response.my_field is None: - if 'my_field' not in response.model_fields_set: - print('Got json like {}, without a "my_field" key present at all.') - else: - print('Got json like {"my_field": null}.') -``` - -### Accessing raw response data (e.g. headers) - -The "raw" Response object can be accessed by prefixing `.with_raw_response.` to any HTTP method call, e.g., - -```py -from opencode_ai import Opencode - -client = Opencode() -response = client.session.with_raw_response.list() -print(response.headers.get('X-My-Header')) - -session = response.parse() # get the object that `session.list()` would have returned -print(session) -``` - -These methods return an [`APIResponse`](https://github.com/sst/opencode-sdk-python/tree/main/src/opencode_ai/_response.py) object. - -The async client returns an [`AsyncAPIResponse`](https://github.com/sst/opencode-sdk-python/tree/main/src/opencode_ai/_response.py) with the same structure, the only difference being `await`able methods for reading the response content. - -#### `.with_streaming_response` - -The above interface eagerly reads the full response body when you make the request, which may not always be what you want. - -To stream the response body, use `.with_streaming_response` instead, which requires a context manager and only reads the response body once you call `.read()`, `.text()`, `.json()`, `.iter_bytes()`, `.iter_text()`, `.iter_lines()` or `.parse()`. In the async client, these are async methods. - -```python -with client.session.with_streaming_response.list() as response: - print(response.headers.get("X-My-Header")) - - for line in response.iter_lines(): - print(line) -``` - -The context manager is required so that the response will reliably be closed. - -### Making custom/undocumented requests - -This library is typed for convenient access to the documented API. - -If you need to access undocumented endpoints, params, or response properties, the library can still be used. - -#### Undocumented endpoints - -To make requests to undocumented endpoints, you can make requests using `client.get`, `client.post`, and other -http verbs. Options on the client will be respected (such as retries) when making this request. - -```py -import httpx - -response = client.post( - "/foo", - cast_to=httpx.Response, - body={"my_param": True}, -) - -print(response.headers.get("x-foo")) -``` - -#### Undocumented request params - -If you want to explicitly send an extra param, you can do so with the `extra_query`, `extra_body`, and `extra_headers` request -options. - -#### Undocumented response properties - -To access undocumented response properties, you can access the extra fields like `response.unknown_prop`. You -can also get all the extra fields on the Pydantic model as a dict with -[`response.model_extra`](https://docs.pydantic.dev/latest/api/base_model/#pydantic.BaseModel.model_extra). - -### Configuring the HTTP client - -You can directly override the [httpx client](https://www.python-httpx.org/api/#client) to customize it for your use case, including: - -- Support for [proxies](https://www.python-httpx.org/advanced/proxies/) -- Custom [transports](https://www.python-httpx.org/advanced/transports/) -- Additional [advanced](https://www.python-httpx.org/advanced/clients/) functionality - -```python -import httpx -from opencode_ai import Opencode, DefaultHttpxClient - -client = Opencode( - # Or use the `OPENCODE_BASE_URL` env var - base_url="http://my.test.server.example.com:8083", - http_client=DefaultHttpxClient( - proxy="http://my.test.proxy.example.com", - transport=httpx.HTTPTransport(local_address="0.0.0.0"), - ), -) -``` - -You can also customize the client on a per-request basis by using `with_options()`: - -```python -client.with_options(http_client=DefaultHttpxClient(...)) -``` - -### Managing HTTP resources - -By default the library closes underlying HTTP connections whenever the client is [garbage collected](https://docs.python.org/3/reference/datamodel.html#object.__del__). You can manually close the client using the `.close()` method if desired, or with a context manager that closes when exiting. - -```py -from opencode_ai import Opencode - -with Opencode() as client: - # make requests here - ... - -# HTTP client is now closed -``` - -## Versioning - -This package generally follows [SemVer](https://semver.org/spec/v2.0.0.html) conventions, though certain backwards-incompatible changes may be released as minor versions: - -1. Changes that only affect static types, without breaking runtime behavior. -2. Changes to library internals which are technically public but not intended or documented for external use. _(Please open a GitHub issue to let us know if you are relying on such internals.)_ -3. Changes that we do not expect to impact the vast majority of users in practice. - -We take backwards-compatibility seriously and work hard to ensure you can rely on a smooth upgrade experience. - -We are keen for your feedback; please open an [issue](https://www.github.com/sst/opencode-sdk-python/issues) with questions, bugs, or suggestions. - -### Determining the installed version - -If you've upgraded to the latest version but aren't seeing any new features you were expecting then your python environment is likely still using an older version. - -You can determine the version that is being used at runtime with: - -```py -import opencode_ai -print(opencode_ai.__version__) -``` - -## Requirements - -Python 3.8 or higher. - -## Contributing - -See [the contributing documentation](./CONTRIBUTING.md). +# opencode-python \ No newline at end of file diff --git a/SECURITY.md b/SECURITY.md deleted file mode 100644 index 6912e12..0000000 --- a/SECURITY.md +++ /dev/null @@ -1,27 +0,0 @@ -# Security Policy - -## Reporting Security Issues - -This SDK is generated by [Stainless Software Inc](http://stainless.com). Stainless takes security seriously, and encourages you to report any security vulnerability promptly so that appropriate action can be taken. - -To report a security issue, please contact the Stainless team at security@stainless.com. - -## Responsible Disclosure - -We appreciate the efforts of security researchers and individuals who help us maintain the security of -SDKs we generate. If you believe you have found a security vulnerability, please adhere to responsible -disclosure practices by allowing us a reasonable amount of time to investigate and address the issue -before making any information public. - -## Reporting Non-SDK Related Security Issues - -If you encounter security issues that are not directly related to SDKs but pertain to the services -or products provided by Opencode, please follow the respective company's security reporting guidelines. - -### Opencode Terms and Policies - -Please contact support@sst.dev for any questions or concerns regarding the security of our services. - ---- - -Thank you for helping us keep the SDKs and systems they interact with secure. diff --git a/api.md b/api.md deleted file mode 100644 index a254328..0000000 --- a/api.md +++ /dev/null @@ -1,145 +0,0 @@ -# Shared Types - -```python -from opencode_ai.types import MessageAbortedError, ProviderAuthError, UnknownError -``` - -# Event - -Types: - -```python -from opencode_ai.types import EventListResponse -``` - -Methods: - -- client.event.list() -> EventListResponse - -# App - -Types: - -```python -from opencode_ai.types import ( - App, - Mode, - Model, - Provider, - AppInitResponse, - AppLogResponse, - AppModesResponse, - AppProvidersResponse, -) -``` - -Methods: - -- client.app.get() -> App -- client.app.init() -> AppInitResponse -- client.app.log(\*\*params) -> AppLogResponse -- client.app.modes() -> AppModesResponse -- client.app.providers() -> AppProvidersResponse - -# Find - -Types: - -```python -from opencode_ai.types import Symbol, FindFilesResponse, FindSymbolsResponse, FindTextResponse -``` - -Methods: - -- client.find.files(\*\*params) -> FindFilesResponse -- client.find.symbols(\*\*params) -> FindSymbolsResponse -- client.find.text(\*\*params) -> FindTextResponse - -# File - -Types: - -```python -from opencode_ai.types import File, FileReadResponse, FileStatusResponse -``` - -Methods: - -- client.file.read(\*\*params) -> FileReadResponse -- client.file.status() -> FileStatusResponse - -# Config - -Types: - -```python -from opencode_ai.types import Config, KeybindsConfig, McpLocalConfig, McpRemoteConfig, ModeConfig -``` - -Methods: - -- client.config.get() -> Config - -# Session - -Types: - -```python -from opencode_ai.types import ( - AssistantMessage, - FilePart, - FilePartInput, - FilePartSource, - FilePartSourceText, - FileSource, - Message, - Part, - Session, - SnapshotPart, - StepFinishPart, - StepStartPart, - SymbolSource, - TextPart, - TextPartInput, - ToolPart, - ToolStateCompleted, - ToolStateError, - ToolStatePending, - ToolStateRunning, - UserMessage, - SessionListResponse, - SessionDeleteResponse, - SessionAbortResponse, - SessionInitResponse, - SessionMessagesResponse, - SessionSummarizeResponse, -) -``` - -Methods: - -- client.session.create() -> Session -- client.session.list() -> SessionListResponse -- client.session.delete(id) -> SessionDeleteResponse -- client.session.abort(id) -> SessionAbortResponse -- client.session.chat(id, \*\*params) -> AssistantMessage -- client.session.init(id, \*\*params) -> SessionInitResponse -- client.session.messages(id) -> SessionMessagesResponse -- client.session.revert(id, \*\*params) -> Session -- client.session.share(id) -> Session -- client.session.summarize(id, \*\*params) -> SessionSummarizeResponse -- client.session.unrevert(id) -> Session -- client.session.unshare(id) -> Session - -# Tui - -Types: - -```python -from opencode_ai.types import TuiAppendPromptResponse, TuiOpenHelpResponse -``` - -Methods: - -- client.tui.append_prompt(\*\*params) -> TuiAppendPromptResponse -- client.tui.open_help() -> TuiOpenHelpResponse diff --git a/bin/check-release-environment b/bin/check-release-environment deleted file mode 100644 index b845b0f..0000000 --- a/bin/check-release-environment +++ /dev/null @@ -1,21 +0,0 @@ -#!/usr/bin/env bash - -errors=() - -if [ -z "${PYPI_TOKEN}" ]; then - errors+=("The PYPI_TOKEN secret has not been set. Please set it in either this repository's secrets or your organization secrets.") -fi - -lenErrors=${#errors[@]} - -if [[ lenErrors -gt 0 ]]; then - echo -e "Found the following errors in the release environment:\n" - - for error in "${errors[@]}"; do - echo -e "- $error\n" - done - - exit 1 -fi - -echo "The environment is ready to push releases!" diff --git a/bin/publish-pypi b/bin/publish-pypi deleted file mode 100644 index 826054e..0000000 --- a/bin/publish-pypi +++ /dev/null @@ -1,6 +0,0 @@ -#!/usr/bin/env bash - -set -eux -mkdir -p dist -rye build --clean -rye publish --yes --token=$PYPI_TOKEN diff --git a/examples/.keep b/examples/.keep deleted file mode 100644 index d8c73e9..0000000 --- a/examples/.keep +++ /dev/null @@ -1,4 +0,0 @@ -File generated from our OpenAPI spec by Stainless. - -This directory can be used to store example files demonstrating usage of this SDK. -It is ignored by Stainless code generation and its content (other than this keep file) won't be touched. \ No newline at end of file diff --git a/mypy.ini b/mypy.ini deleted file mode 100644 index 34af79f..0000000 --- a/mypy.ini +++ /dev/null @@ -1,50 +0,0 @@ -[mypy] -pretty = True -show_error_codes = True - -# Exclude _files.py because mypy isn't smart enough to apply -# the correct type narrowing and as this is an internal module -# it's fine to just use Pyright. -# -# We also exclude our `tests` as mypy doesn't always infer -# types correctly and Pyright will still catch any type errors. -exclude = ^(src/opencode_ai/_files\.py|_dev/.*\.py|tests/.*)$ - -strict_equality = True -implicit_reexport = True -check_untyped_defs = True -no_implicit_optional = True - -warn_return_any = True -warn_unreachable = True -warn_unused_configs = True - -# Turn these options off as it could cause conflicts -# with the Pyright options. -warn_unused_ignores = False -warn_redundant_casts = False - -disallow_any_generics = True -disallow_untyped_defs = True -disallow_untyped_calls = True -disallow_subclassing_any = True -disallow_incomplete_defs = True -disallow_untyped_decorators = True -cache_fine_grained = True - -# By default, mypy reports an error if you assign a value to the result -# of a function call that doesn't return anything. We do this in our test -# cases: -# ``` -# result = ... -# assert result is None -# ``` -# Changing this codegen to make mypy happy would increase complexity -# and would not be worth it. -disable_error_code = func-returns-value,overload-cannot-match - -# https://github.com/python/mypy/issues/12162 -[mypy.overrides] -module = "black.files.*" -ignore_errors = true -ignore_missing_imports = true diff --git a/noxfile.py b/noxfile.py deleted file mode 100644 index 53bca7f..0000000 --- a/noxfile.py +++ /dev/null @@ -1,9 +0,0 @@ -import nox - - -@nox.session(reuse_venv=True, name="test-pydantic-v1") -def test_pydantic_v1(session: nox.Session) -> None: - session.install("-r", "requirements-dev.lock") - session.install("pydantic<2") - - session.run("pytest", "--showlocals", "--ignore=tests/functional", *session.posargs) diff --git a/pyproject.toml b/pyproject.toml deleted file mode 100644 index f5c99e3..0000000 --- a/pyproject.toml +++ /dev/null @@ -1,212 +0,0 @@ -[project] -name = "opencode-ai" -version = "0.1.0-alpha.36" -description = "The official Python library for the opencode API" -dynamic = ["readme"] -license = "MIT" -authors = [ -{ name = "Opencode", email = "support@sst.dev" }, -] -dependencies = [ - "httpx>=0.23.0, <1", - "pydantic>=1.9.0, <3", - "typing-extensions>=4.10, <5", - "anyio>=3.5.0, <5", - "distro>=1.7.0, <2", - "sniffio", -] -requires-python = ">= 3.8" -classifiers = [ - "Typing :: Typed", - "Intended Audience :: Developers", - "Programming Language :: Python :: 3.8", - "Programming Language :: Python :: 3.9", - "Programming Language :: Python :: 3.10", - "Programming Language :: Python :: 3.11", - "Programming Language :: Python :: 3.12", - "Programming Language :: Python :: 3.13", - "Operating System :: OS Independent", - "Operating System :: POSIX", - "Operating System :: MacOS", - "Operating System :: POSIX :: Linux", - "Operating System :: Microsoft :: Windows", - "Topic :: Software Development :: Libraries :: Python Modules", - "License :: OSI Approved :: MIT License" -] - -[project.urls] -Homepage = "https://github.com/sst/opencode-sdk-python" -Repository = "https://github.com/sst/opencode-sdk-python" - -[project.optional-dependencies] -aiohttp = ["aiohttp", "httpx_aiohttp>=0.1.8"] - -[tool.rye] -managed = true -# version pins are in requirements-dev.lock -dev-dependencies = [ - "pyright==1.1.399", - "mypy", - "respx", - "pytest", - "pytest-asyncio", - "ruff", - "time-machine", - "nox", - "dirty-equals>=0.6.0", - "importlib-metadata>=6.7.0", - "rich>=13.7.1", - "nest_asyncio==1.6.0", - "pytest-xdist>=3.6.1", -] - -[tool.rye.scripts] -format = { chain = [ - "format:ruff", - "format:docs", - "fix:ruff", - # run formatting again to fix any inconsistencies when imports are stripped - "format:ruff", -]} -"format:docs" = "python scripts/utils/ruffen-docs.py README.md api.md" -"format:ruff" = "ruff format" - -"lint" = { chain = [ - "check:ruff", - "typecheck", - "check:importable", -]} -"check:ruff" = "ruff check ." -"fix:ruff" = "ruff check --fix ." - -"check:importable" = "python -c 'import opencode_ai'" - -typecheck = { chain = [ - "typecheck:pyright", - "typecheck:mypy" -]} -"typecheck:pyright" = "pyright" -"typecheck:verify-types" = "pyright --verifytypes opencode_ai --ignoreexternal" -"typecheck:mypy" = "mypy ." - -[build-system] -requires = ["hatchling==1.26.3", "hatch-fancy-pypi-readme"] -build-backend = "hatchling.build" - -[tool.hatch.build] -include = [ - "src/*" -] - -[tool.hatch.build.targets.wheel] -packages = ["src/opencode_ai"] - -[tool.hatch.build.targets.sdist] -# Basically everything except hidden files/directories (such as .github, .devcontainers, .python-version, etc) -include = [ - "/*.toml", - "/*.json", - "/*.lock", - "/*.md", - "/mypy.ini", - "/noxfile.py", - "bin/*", - "examples/*", - "src/*", - "tests/*", -] - -[tool.hatch.metadata.hooks.fancy-pypi-readme] -content-type = "text/markdown" - -[[tool.hatch.metadata.hooks.fancy-pypi-readme.fragments]] -path = "README.md" - -[[tool.hatch.metadata.hooks.fancy-pypi-readme.substitutions]] -# replace relative links with absolute links -pattern = '\[(.+?)\]\(((?!https?://)\S+?)\)' -replacement = '[\1](https://github.com/sst/opencode-sdk-python/tree/main/\g<2>)' - -[tool.pytest.ini_options] -testpaths = ["tests"] -addopts = "--tb=short -n auto" -xfail_strict = true -asyncio_mode = "auto" -asyncio_default_fixture_loop_scope = "session" -filterwarnings = [ - "error" -] - -[tool.pyright] -# this enables practically every flag given by pyright. -# there are a couple of flags that are still disabled by -# default in strict mode as they are experimental and niche. -typeCheckingMode = "strict" -pythonVersion = "3.8" - -exclude = [ - "_dev", - ".venv", - ".nox", - ".git", -] - -reportImplicitOverride = true -reportOverlappingOverload = false - -reportImportCycles = false -reportPrivateUsage = false - -[tool.ruff] -line-length = 120 -output-format = "grouped" -target-version = "py38" - -[tool.ruff.format] -docstring-code-format = true - -[tool.ruff.lint] -select = [ - # isort - "I", - # bugbear rules - "B", - # remove unused imports - "F401", - # bare except statements - "E722", - # unused arguments - "ARG", - # print statements - "T201", - "T203", - # misuse of typing.TYPE_CHECKING - "TC004", - # import rules - "TID251", -] -ignore = [ - # mutable defaults - "B006", -] -unfixable = [ - # disable auto fix for print statements - "T201", - "T203", -] - -[tool.ruff.lint.flake8-tidy-imports.banned-api] -"functools.lru_cache".msg = "This function does not retain type information for the wrapped function's arguments; The `lru_cache` function from `_utils` should be used instead" - -[tool.ruff.lint.isort] -length-sort = true -length-sort-straight = true -combine-as-imports = true -extra-standard-library = ["typing_extensions"] -known-first-party = ["opencode_ai", "tests"] - -[tool.ruff.lint.per-file-ignores] -"bin/**.py" = ["T201", "T203"] -"scripts/**.py" = ["T201", "T203"] -"tests/**.py" = ["T201", "T203"] -"examples/**.py" = ["T201", "T203"] diff --git a/release-please-config.json b/release-please-config.json deleted file mode 100644 index c08c065..0000000 --- a/release-please-config.json +++ /dev/null @@ -1,66 +0,0 @@ -{ - "packages": { - ".": {} - }, - "$schema": "https://raw.githubusercontent.com/stainless-api/release-please/main/schemas/config.json", - "include-v-in-tag": true, - "include-component-in-tag": false, - "versioning": "prerelease", - "prerelease": true, - "bump-minor-pre-major": true, - "bump-patch-for-minor-pre-major": false, - "pull-request-header": "Automated Release PR", - "pull-request-title-pattern": "release: ${version}", - "changelog-sections": [ - { - "type": "feat", - "section": "Features" - }, - { - "type": "fix", - "section": "Bug Fixes" - }, - { - "type": "perf", - "section": "Performance Improvements" - }, - { - "type": "revert", - "section": "Reverts" - }, - { - "type": "chore", - "section": "Chores" - }, - { - "type": "docs", - "section": "Documentation" - }, - { - "type": "style", - "section": "Styles" - }, - { - "type": "refactor", - "section": "Refactors" - }, - { - "type": "test", - "section": "Tests", - "hidden": true - }, - { - "type": "build", - "section": "Build System" - }, - { - "type": "ci", - "section": "Continuous Integration", - "hidden": true - } - ], - "release-type": "python", - "extra-files": [ - "src/opencode_ai/_version.py" - ] -} \ No newline at end of file diff --git a/requirements-dev.lock b/requirements-dev.lock deleted file mode 100644 index d88557d..0000000 --- a/requirements-dev.lock +++ /dev/null @@ -1,135 +0,0 @@ -# generated by rye -# use `rye lock` or `rye sync` to update this lockfile -# -# last locked with the following flags: -# pre: false -# features: [] -# all-features: true -# with-sources: false -# generate-hashes: false -# universal: false - --e file:. -aiohappyeyeballs==2.6.1 - # via aiohttp -aiohttp==3.12.8 - # via httpx-aiohttp - # via opencode-ai -aiosignal==1.3.2 - # via aiohttp -annotated-types==0.6.0 - # via pydantic -anyio==4.4.0 - # via httpx - # via opencode-ai -argcomplete==3.1.2 - # via nox -async-timeout==5.0.1 - # via aiohttp -attrs==25.3.0 - # via aiohttp -certifi==2023.7.22 - # via httpcore - # via httpx -colorlog==6.7.0 - # via nox -dirty-equals==0.6.0 -distlib==0.3.7 - # via virtualenv -distro==1.8.0 - # via opencode-ai -exceptiongroup==1.2.2 - # via anyio - # via pytest -execnet==2.1.1 - # via pytest-xdist -filelock==3.12.4 - # via virtualenv -frozenlist==1.6.2 - # via aiohttp - # via aiosignal -h11==0.16.0 - # via httpcore -httpcore==1.0.9 - # via httpx -httpx==0.28.1 - # via httpx-aiohttp - # via opencode-ai - # via respx -httpx-aiohttp==0.1.8 - # via opencode-ai -idna==3.4 - # via anyio - # via httpx - # via yarl -importlib-metadata==7.0.0 -iniconfig==2.0.0 - # via pytest -markdown-it-py==3.0.0 - # via rich -mdurl==0.1.2 - # via markdown-it-py -multidict==6.4.4 - # via aiohttp - # via yarl -mypy==1.14.1 -mypy-extensions==1.0.0 - # via mypy -nest-asyncio==1.6.0 -nodeenv==1.8.0 - # via pyright -nox==2023.4.22 -packaging==23.2 - # via nox - # via pytest -platformdirs==3.11.0 - # via virtualenv -pluggy==1.5.0 - # via pytest -propcache==0.3.1 - # via aiohttp - # via yarl -pydantic==2.10.3 - # via opencode-ai -pydantic-core==2.27.1 - # via pydantic -pygments==2.18.0 - # via rich -pyright==1.1.399 -pytest==8.3.3 - # via pytest-asyncio - # via pytest-xdist -pytest-asyncio==0.24.0 -pytest-xdist==3.7.0 -python-dateutil==2.8.2 - # via time-machine -pytz==2023.3.post1 - # via dirty-equals -respx==0.22.0 -rich==13.7.1 -ruff==0.9.4 -setuptools==68.2.2 - # via nodeenv -six==1.16.0 - # via python-dateutil -sniffio==1.3.0 - # via anyio - # via opencode-ai -time-machine==2.9.0 -tomli==2.0.2 - # via mypy - # via pytest -typing-extensions==4.12.2 - # via anyio - # via multidict - # via mypy - # via opencode-ai - # via pydantic - # via pydantic-core - # via pyright -virtualenv==20.24.5 - # via nox -yarl==1.20.0 - # via aiohttp -zipp==3.17.0 - # via importlib-metadata diff --git a/requirements.lock b/requirements.lock deleted file mode 100644 index 0ab22ae..0000000 --- a/requirements.lock +++ /dev/null @@ -1,72 +0,0 @@ -# generated by rye -# use `rye lock` or `rye sync` to update this lockfile -# -# last locked with the following flags: -# pre: false -# features: [] -# all-features: true -# with-sources: false -# generate-hashes: false -# universal: false - --e file:. -aiohappyeyeballs==2.6.1 - # via aiohttp -aiohttp==3.12.8 - # via httpx-aiohttp - # via opencode-ai -aiosignal==1.3.2 - # via aiohttp -annotated-types==0.6.0 - # via pydantic -anyio==4.4.0 - # via httpx - # via opencode-ai -async-timeout==5.0.1 - # via aiohttp -attrs==25.3.0 - # via aiohttp -certifi==2023.7.22 - # via httpcore - # via httpx -distro==1.8.0 - # via opencode-ai -exceptiongroup==1.2.2 - # via anyio -frozenlist==1.6.2 - # via aiohttp - # via aiosignal -h11==0.16.0 - # via httpcore -httpcore==1.0.9 - # via httpx -httpx==0.28.1 - # via httpx-aiohttp - # via opencode-ai -httpx-aiohttp==0.1.8 - # via opencode-ai -idna==3.4 - # via anyio - # via httpx - # via yarl -multidict==6.4.4 - # via aiohttp - # via yarl -propcache==0.3.1 - # via aiohttp - # via yarl -pydantic==2.10.3 - # via opencode-ai -pydantic-core==2.27.1 - # via pydantic -sniffio==1.3.0 - # via anyio - # via opencode-ai -typing-extensions==4.12.2 - # via anyio - # via multidict - # via opencode-ai - # via pydantic - # via pydantic-core -yarl==1.20.0 - # via aiohttp diff --git a/scripts/bootstrap b/scripts/bootstrap deleted file mode 100755 index e84fe62..0000000 --- a/scripts/bootstrap +++ /dev/null @@ -1,19 +0,0 @@ -#!/usr/bin/env bash - -set -e - -cd "$(dirname "$0")/.." - -if ! command -v rye >/dev/null 2>&1 && [ -f "Brewfile" ] && [ "$(uname -s)" = "Darwin" ]; then - brew bundle check >/dev/null 2>&1 || { - echo "==> Installing Homebrew dependencies…" - brew bundle - } -fi - -echo "==> Installing Python dependencies…" - -# experimental uv support makes installations significantly faster -rye config --set-bool behavior.use-uv=true - -rye sync --all-features diff --git a/scripts/format b/scripts/format deleted file mode 100755 index 667ec2d..0000000 --- a/scripts/format +++ /dev/null @@ -1,8 +0,0 @@ -#!/usr/bin/env bash - -set -e - -cd "$(dirname "$0")/.." - -echo "==> Running formatters" -rye run format diff --git a/scripts/lint b/scripts/lint deleted file mode 100755 index bac00da..0000000 --- a/scripts/lint +++ /dev/null @@ -1,11 +0,0 @@ -#!/usr/bin/env bash - -set -e - -cd "$(dirname "$0")/.." - -echo "==> Running lints" -rye run lint - -echo "==> Making sure it imports" -rye run python -c 'import opencode_ai' diff --git a/scripts/mock b/scripts/mock deleted file mode 100755 index 0b28f6e..0000000 --- a/scripts/mock +++ /dev/null @@ -1,41 +0,0 @@ -#!/usr/bin/env bash - -set -e - -cd "$(dirname "$0")/.." - -if [[ -n "$1" && "$1" != '--'* ]]; then - URL="$1" - shift -else - URL="$(grep 'openapi_spec_url' .stats.yml | cut -d' ' -f2)" -fi - -# Check if the URL is empty -if [ -z "$URL" ]; then - echo "Error: No OpenAPI spec path/url provided or found in .stats.yml" - exit 1 -fi - -echo "==> Starting mock server with URL ${URL}" - -# Run prism mock on the given spec -if [ "$1" == "--daemon" ]; then - npm exec --package=@stainless-api/prism-cli@5.15.0 -- prism mock "$URL" &> .prism.log & - - # Wait for server to come online - echo -n "Waiting for server" - while ! grep -q "✖ fatal\|Prism is listening" ".prism.log" ; do - echo -n "." - sleep 0.1 - done - - if grep -q "✖ fatal" ".prism.log"; then - cat .prism.log - exit 1 - fi - - echo -else - npm exec --package=@stainless-api/prism-cli@5.15.0 -- prism mock "$URL" -fi diff --git a/scripts/test b/scripts/test deleted file mode 100755 index dbeda2d..0000000 --- a/scripts/test +++ /dev/null @@ -1,61 +0,0 @@ -#!/usr/bin/env bash - -set -e - -cd "$(dirname "$0")/.." - -RED='\033[0;31m' -GREEN='\033[0;32m' -YELLOW='\033[0;33m' -NC='\033[0m' # No Color - -function prism_is_running() { - curl --silent "http://localhost:4010" >/dev/null 2>&1 -} - -kill_server_on_port() { - pids=$(lsof -t -i tcp:"$1" || echo "") - if [ "$pids" != "" ]; then - kill "$pids" - echo "Stopped $pids." - fi -} - -function is_overriding_api_base_url() { - [ -n "$TEST_API_BASE_URL" ] -} - -if ! is_overriding_api_base_url && ! prism_is_running ; then - # When we exit this script, make sure to kill the background mock server process - trap 'kill_server_on_port 4010' EXIT - - # Start the dev server - ./scripts/mock --daemon -fi - -if is_overriding_api_base_url ; then - echo -e "${GREEN}✔ Running tests against ${TEST_API_BASE_URL}${NC}" - echo -elif ! prism_is_running ; then - echo -e "${RED}ERROR:${NC} The test suite will not run without a mock Prism server" - echo -e "running against your OpenAPI spec." - echo - echo -e "To run the server, pass in the path or url of your OpenAPI" - echo -e "spec to the prism command:" - echo - echo -e " \$ ${YELLOW}npm exec --package=@stainless-api/prism-cli@5.15.0 -- prism mock path/to/your.openapi.yml${NC}" - echo - - exit 1 -else - echo -e "${GREEN}✔ Mock prism server is running with your OpenAPI spec${NC}" - echo -fi - -export DEFER_PYDANTIC_BUILD=false - -echo "==> Running tests" -rye run pytest "$@" - -echo "==> Running Pydantic v1 tests" -rye run nox -s test-pydantic-v1 -- "$@" diff --git a/scripts/utils/ruffen-docs.py b/scripts/utils/ruffen-docs.py deleted file mode 100644 index 0cf2bd2..0000000 --- a/scripts/utils/ruffen-docs.py +++ /dev/null @@ -1,167 +0,0 @@ -# fork of https://github.com/asottile/blacken-docs adapted for ruff -from __future__ import annotations - -import re -import sys -import argparse -import textwrap -import contextlib -import subprocess -from typing import Match, Optional, Sequence, Generator, NamedTuple, cast - -MD_RE = re.compile( - r"(?P^(?P *)```\s*python\n)" r"(?P.*?)" r"(?P^(?P=indent)```\s*$)", - re.DOTALL | re.MULTILINE, -) -MD_PYCON_RE = re.compile( - r"(?P^(?P *)```\s*pycon\n)" r"(?P.*?)" r"(?P^(?P=indent)```.*$)", - re.DOTALL | re.MULTILINE, -) -PYCON_PREFIX = ">>> " -PYCON_CONTINUATION_PREFIX = "..." -PYCON_CONTINUATION_RE = re.compile( - rf"^{re.escape(PYCON_CONTINUATION_PREFIX)}( |$)", -) -DEFAULT_LINE_LENGTH = 100 - - -class CodeBlockError(NamedTuple): - offset: int - exc: Exception - - -def format_str( - src: str, -) -> tuple[str, Sequence[CodeBlockError]]: - errors: list[CodeBlockError] = [] - - @contextlib.contextmanager - def _collect_error(match: Match[str]) -> Generator[None, None, None]: - try: - yield - except Exception as e: - errors.append(CodeBlockError(match.start(), e)) - - def _md_match(match: Match[str]) -> str: - code = textwrap.dedent(match["code"]) - with _collect_error(match): - code = format_code_block(code) - code = textwrap.indent(code, match["indent"]) - return f"{match['before']}{code}{match['after']}" - - def _pycon_match(match: Match[str]) -> str: - code = "" - fragment = cast(Optional[str], None) - - def finish_fragment() -> None: - nonlocal code - nonlocal fragment - - if fragment is not None: - with _collect_error(match): - fragment = format_code_block(fragment) - fragment_lines = fragment.splitlines() - code += f"{PYCON_PREFIX}{fragment_lines[0]}\n" - for line in fragment_lines[1:]: - # Skip blank lines to handle Black adding a blank above - # functions within blocks. A blank line would end the REPL - # continuation prompt. - # - # >>> if True: - # ... def f(): - # ... pass - # ... - if line: - code += f"{PYCON_CONTINUATION_PREFIX} {line}\n" - if fragment_lines[-1].startswith(" "): - code += f"{PYCON_CONTINUATION_PREFIX}\n" - fragment = None - - indentation = None - for line in match["code"].splitlines(): - orig_line, line = line, line.lstrip() - if indentation is None and line: - indentation = len(orig_line) - len(line) - continuation_match = PYCON_CONTINUATION_RE.match(line) - if continuation_match and fragment is not None: - fragment += line[continuation_match.end() :] + "\n" - else: - finish_fragment() - if line.startswith(PYCON_PREFIX): - fragment = line[len(PYCON_PREFIX) :] + "\n" - else: - code += orig_line[indentation:] + "\n" - finish_fragment() - return code - - def _md_pycon_match(match: Match[str]) -> str: - code = _pycon_match(match) - code = textwrap.indent(code, match["indent"]) - return f"{match['before']}{code}{match['after']}" - - src = MD_RE.sub(_md_match, src) - src = MD_PYCON_RE.sub(_md_pycon_match, src) - return src, errors - - -def format_code_block(code: str) -> str: - return subprocess.check_output( - [ - sys.executable, - "-m", - "ruff", - "format", - "--stdin-filename=script.py", - f"--line-length={DEFAULT_LINE_LENGTH}", - ], - encoding="utf-8", - input=code, - ) - - -def format_file( - filename: str, - skip_errors: bool, -) -> int: - with open(filename, encoding="UTF-8") as f: - contents = f.read() - new_contents, errors = format_str(contents) - for error in errors: - lineno = contents[: error.offset].count("\n") + 1 - print(f"{filename}:{lineno}: code block parse error {error.exc}") - if errors and not skip_errors: - return 1 - if contents != new_contents: - print(f"{filename}: Rewriting...") - with open(filename, "w", encoding="UTF-8") as f: - f.write(new_contents) - return 0 - else: - return 0 - - -def main(argv: Sequence[str] | None = None) -> int: - parser = argparse.ArgumentParser() - parser.add_argument( - "-l", - "--line-length", - type=int, - default=DEFAULT_LINE_LENGTH, - ) - parser.add_argument( - "-S", - "--skip-string-normalization", - action="store_true", - ) - parser.add_argument("-E", "--skip-errors", action="store_true") - parser.add_argument("filenames", nargs="*") - args = parser.parse_args(argv) - - retv = 0 - for filename in args.filenames: - retv |= format_file(filename, skip_errors=args.skip_errors) - return retv - - -if __name__ == "__main__": - raise SystemExit(main()) diff --git a/scripts/utils/upload-artifact.sh b/scripts/utils/upload-artifact.sh deleted file mode 100755 index c16f6af..0000000 --- a/scripts/utils/upload-artifact.sh +++ /dev/null @@ -1,27 +0,0 @@ -#!/usr/bin/env bash -set -exuo pipefail - -FILENAME=$(basename dist/*.whl) - -RESPONSE=$(curl -X POST "$URL?filename=$FILENAME" \ - -H "Authorization: Bearer $AUTH" \ - -H "Content-Type: application/json") - -SIGNED_URL=$(echo "$RESPONSE" | jq -r '.url') - -if [[ "$SIGNED_URL" == "null" ]]; then - echo -e "\033[31mFailed to get signed URL.\033[0m" - exit 1 -fi - -UPLOAD_RESPONSE=$(curl -v -X PUT \ - -H "Content-Type: binary/octet-stream" \ - --data-binary "@dist/$FILENAME" "$SIGNED_URL" 2>&1) - -if echo "$UPLOAD_RESPONSE" | grep -q "HTTP/[0-9.]* 200"; then - echo -e "\033[32mUploaded build to Stainless storage.\033[0m" - echo -e "\033[32mInstallation: pip install 'https://pkg.stainless.com/s/opencode-python/$SHA/$FILENAME'\033[0m" -else - echo -e "\033[31mFailed to upload artifact.\033[0m" - exit 1 -fi diff --git a/src/opencode/lib/.keep b/src/opencode/lib/.keep deleted file mode 100644 index 5e2c99f..0000000 --- a/src/opencode/lib/.keep +++ /dev/null @@ -1,4 +0,0 @@ -File generated from our OpenAPI spec by Stainless. - -This directory can be used to store custom files to expand the SDK. -It is ignored by Stainless code generation and its content (other than this keep file) won't be touched. \ No newline at end of file diff --git a/src/opencode_ai/__init__.py b/src/opencode_ai/__init__.py deleted file mode 100644 index 7d8c13c..0000000 --- a/src/opencode_ai/__init__.py +++ /dev/null @@ -1,100 +0,0 @@ -# File generated from our OpenAPI spec by Stainless. See CONTRIBUTING.md for details. - -import typing as _t - -from . import types -from ._types import NOT_GIVEN, Omit, NoneType, NotGiven, Transport, ProxiesTypes -from ._utils import file_from_path -from ._client import ( - Client, - Stream, - Timeout, - Opencode, - Transport, - AsyncClient, - AsyncStream, - AsyncOpencode, - RequestOptions, -) -from ._models import BaseModel -from ._version import __title__, __version__ -from ._response import APIResponse as APIResponse, AsyncAPIResponse as AsyncAPIResponse -from ._constants import DEFAULT_TIMEOUT, DEFAULT_MAX_RETRIES, DEFAULT_CONNECTION_LIMITS -from ._exceptions import ( - APIError, - ConflictError, - NotFoundError, - OpencodeError, - APIStatusError, - RateLimitError, - APITimeoutError, - BadRequestError, - APIConnectionError, - AuthenticationError, - InternalServerError, - PermissionDeniedError, - UnprocessableEntityError, - APIResponseValidationError, -) -from ._base_client import DefaultHttpxClient, DefaultAioHttpClient, DefaultAsyncHttpxClient -from ._utils._logs import setup_logging as _setup_logging - -__all__ = [ - "types", - "__version__", - "__title__", - "NoneType", - "Transport", - "ProxiesTypes", - "NotGiven", - "NOT_GIVEN", - "Omit", - "OpencodeError", - "APIError", - "APIStatusError", - "APITimeoutError", - "APIConnectionError", - "APIResponseValidationError", - "BadRequestError", - "AuthenticationError", - "PermissionDeniedError", - "NotFoundError", - "ConflictError", - "UnprocessableEntityError", - "RateLimitError", - "InternalServerError", - "Timeout", - "RequestOptions", - "Client", - "AsyncClient", - "Stream", - "AsyncStream", - "Opencode", - "AsyncOpencode", - "file_from_path", - "BaseModel", - "DEFAULT_TIMEOUT", - "DEFAULT_MAX_RETRIES", - "DEFAULT_CONNECTION_LIMITS", - "DefaultHttpxClient", - "DefaultAsyncHttpxClient", - "DefaultAioHttpClient", -] - -if not _t.TYPE_CHECKING: - from ._utils._resources_proxy import resources as resources - -_setup_logging() - -# Update the __module__ attribute for exported symbols so that -# error messages point to this module instead of the module -# it was originally defined in, e.g. -# opencode_ai._exceptions.NotFoundError -> opencode_ai.NotFoundError -__locals = locals() -for __name in __all__: - if not __name.startswith("__"): - try: - __locals[__name].__module__ = "opencode_ai" - except (TypeError, AttributeError): - # Some of our exported symbols are builtins which we can't set attributes for. - pass diff --git a/src/opencode_ai/_base_client.py b/src/opencode_ai/_base_client.py deleted file mode 100644 index eb05756..0000000 --- a/src/opencode_ai/_base_client.py +++ /dev/null @@ -1,1995 +0,0 @@ -from __future__ import annotations - -import sys -import json -import time -import uuid -import email -import asyncio -import inspect -import logging -import platform -import email.utils -from types import TracebackType -from random import random -from typing import ( - TYPE_CHECKING, - Any, - Dict, - Type, - Union, - Generic, - Mapping, - TypeVar, - Iterable, - Iterator, - Optional, - Generator, - AsyncIterator, - cast, - overload, -) -from typing_extensions import Literal, override, get_origin - -import anyio -import httpx -import distro -import pydantic -from httpx import URL -from pydantic import PrivateAttr - -from . import _exceptions -from ._qs import Querystring -from ._files import to_httpx_files, async_to_httpx_files -from ._types import ( - NOT_GIVEN, - Body, - Omit, - Query, - Headers, - Timeout, - NotGiven, - ResponseT, - AnyMapping, - PostParser, - RequestFiles, - HttpxSendArgs, - RequestOptions, - HttpxRequestFiles, - ModelBuilderProtocol, -) -from ._utils import is_dict, is_list, asyncify, is_given, lru_cache, is_mapping -from ._compat import PYDANTIC_V2, model_copy, model_dump -from ._models import GenericModel, FinalRequestOptions, validate_type, construct_type -from ._response import ( - APIResponse, - BaseAPIResponse, - AsyncAPIResponse, - extract_response_type, -) -from ._constants import ( - DEFAULT_TIMEOUT, - MAX_RETRY_DELAY, - DEFAULT_MAX_RETRIES, - INITIAL_RETRY_DELAY, - RAW_RESPONSE_HEADER, - OVERRIDE_CAST_TO_HEADER, - DEFAULT_CONNECTION_LIMITS, -) -from ._streaming import Stream, SSEDecoder, AsyncStream, SSEBytesDecoder -from ._exceptions import ( - APIStatusError, - APITimeoutError, - APIConnectionError, - APIResponseValidationError, -) - -log: logging.Logger = logging.getLogger(__name__) - -# TODO: make base page type vars covariant -SyncPageT = TypeVar("SyncPageT", bound="BaseSyncPage[Any]") -AsyncPageT = TypeVar("AsyncPageT", bound="BaseAsyncPage[Any]") - - -_T = TypeVar("_T") -_T_co = TypeVar("_T_co", covariant=True) - -_StreamT = TypeVar("_StreamT", bound=Stream[Any]) -_AsyncStreamT = TypeVar("_AsyncStreamT", bound=AsyncStream[Any]) - -if TYPE_CHECKING: - from httpx._config import ( - DEFAULT_TIMEOUT_CONFIG, # pyright: ignore[reportPrivateImportUsage] - ) - - HTTPX_DEFAULT_TIMEOUT = DEFAULT_TIMEOUT_CONFIG -else: - try: - from httpx._config import DEFAULT_TIMEOUT_CONFIG as HTTPX_DEFAULT_TIMEOUT - except ImportError: - # taken from https://github.com/encode/httpx/blob/3ba5fe0d7ac70222590e759c31442b1cab263791/httpx/_config.py#L366 - HTTPX_DEFAULT_TIMEOUT = Timeout(5.0) - - -class PageInfo: - """Stores the necessary information to build the request to retrieve the next page. - - Either `url` or `params` must be set. - """ - - url: URL | NotGiven - params: Query | NotGiven - json: Body | NotGiven - - @overload - def __init__( - self, - *, - url: URL, - ) -> None: ... - - @overload - def __init__( - self, - *, - params: Query, - ) -> None: ... - - @overload - def __init__( - self, - *, - json: Body, - ) -> None: ... - - def __init__( - self, - *, - url: URL | NotGiven = NOT_GIVEN, - json: Body | NotGiven = NOT_GIVEN, - params: Query | NotGiven = NOT_GIVEN, - ) -> None: - self.url = url - self.json = json - self.params = params - - @override - def __repr__(self) -> str: - if self.url: - return f"{self.__class__.__name__}(url={self.url})" - if self.json: - return f"{self.__class__.__name__}(json={self.json})" - return f"{self.__class__.__name__}(params={self.params})" - - -class BasePage(GenericModel, Generic[_T]): - """ - Defines the core interface for pagination. - - Type Args: - ModelT: The pydantic model that represents an item in the response. - - Methods: - has_next_page(): Check if there is another page available - next_page_info(): Get the necessary information to make a request for the next page - """ - - _options: FinalRequestOptions = PrivateAttr() - _model: Type[_T] = PrivateAttr() - - def has_next_page(self) -> bool: - items = self._get_page_items() - if not items: - return False - return self.next_page_info() is not None - - def next_page_info(self) -> Optional[PageInfo]: ... - - def _get_page_items(self) -> Iterable[_T]: # type: ignore[empty-body] - ... - - def _params_from_url(self, url: URL) -> httpx.QueryParams: - # TODO: do we have to preprocess params here? - return httpx.QueryParams(cast(Any, self._options.params)).merge(url.params) - - def _info_to_options(self, info: PageInfo) -> FinalRequestOptions: - options = model_copy(self._options) - options._strip_raw_response_header() - - if not isinstance(info.params, NotGiven): - options.params = {**options.params, **info.params} - return options - - if not isinstance(info.url, NotGiven): - params = self._params_from_url(info.url) - url = info.url.copy_with(params=params) - options.params = dict(url.params) - options.url = str(url) - return options - - if not isinstance(info.json, NotGiven): - if not is_mapping(info.json): - raise TypeError("Pagination is only supported with mappings") - - if not options.json_data: - options.json_data = {**info.json} - else: - if not is_mapping(options.json_data): - raise TypeError("Pagination is only supported with mappings") - - options.json_data = {**options.json_data, **info.json} - return options - - raise ValueError("Unexpected PageInfo state") - - -class BaseSyncPage(BasePage[_T], Generic[_T]): - _client: SyncAPIClient = pydantic.PrivateAttr() - - def _set_private_attributes( - self, - client: SyncAPIClient, - model: Type[_T], - options: FinalRequestOptions, - ) -> None: - if PYDANTIC_V2 and getattr(self, "__pydantic_private__", None) is None: - self.__pydantic_private__ = {} - - self._model = model - self._client = client - self._options = options - - # Pydantic uses a custom `__iter__` method to support casting BaseModels - # to dictionaries. e.g. dict(model). - # As we want to support `for item in page`, this is inherently incompatible - # with the default pydantic behaviour. It is not possible to support both - # use cases at once. Fortunately, this is not a big deal as all other pydantic - # methods should continue to work as expected as there is an alternative method - # to cast a model to a dictionary, model.dict(), which is used internally - # by pydantic. - def __iter__(self) -> Iterator[_T]: # type: ignore - for page in self.iter_pages(): - for item in page._get_page_items(): - yield item - - def iter_pages(self: SyncPageT) -> Iterator[SyncPageT]: - page = self - while True: - yield page - if page.has_next_page(): - page = page.get_next_page() - else: - return - - def get_next_page(self: SyncPageT) -> SyncPageT: - info = self.next_page_info() - if not info: - raise RuntimeError( - "No next page expected; please check `.has_next_page()` before calling `.get_next_page()`." - ) - - options = self._info_to_options(info) - return self._client._request_api_list(self._model, page=self.__class__, options=options) - - -class AsyncPaginator(Generic[_T, AsyncPageT]): - def __init__( - self, - client: AsyncAPIClient, - options: FinalRequestOptions, - page_cls: Type[AsyncPageT], - model: Type[_T], - ) -> None: - self._model = model - self._client = client - self._options = options - self._page_cls = page_cls - - def __await__(self) -> Generator[Any, None, AsyncPageT]: - return self._get_page().__await__() - - async def _get_page(self) -> AsyncPageT: - def _parser(resp: AsyncPageT) -> AsyncPageT: - resp._set_private_attributes( - model=self._model, - options=self._options, - client=self._client, - ) - return resp - - self._options.post_parser = _parser - - return await self._client.request(self._page_cls, self._options) - - async def __aiter__(self) -> AsyncIterator[_T]: - # https://github.com/microsoft/pyright/issues/3464 - page = cast( - AsyncPageT, - await self, # type: ignore - ) - async for item in page: - yield item - - -class BaseAsyncPage(BasePage[_T], Generic[_T]): - _client: AsyncAPIClient = pydantic.PrivateAttr() - - def _set_private_attributes( - self, - model: Type[_T], - client: AsyncAPIClient, - options: FinalRequestOptions, - ) -> None: - if PYDANTIC_V2 and getattr(self, "__pydantic_private__", None) is None: - self.__pydantic_private__ = {} - - self._model = model - self._client = client - self._options = options - - async def __aiter__(self) -> AsyncIterator[_T]: - async for page in self.iter_pages(): - for item in page._get_page_items(): - yield item - - async def iter_pages(self: AsyncPageT) -> AsyncIterator[AsyncPageT]: - page = self - while True: - yield page - if page.has_next_page(): - page = await page.get_next_page() - else: - return - - async def get_next_page(self: AsyncPageT) -> AsyncPageT: - info = self.next_page_info() - if not info: - raise RuntimeError( - "No next page expected; please check `.has_next_page()` before calling `.get_next_page()`." - ) - - options = self._info_to_options(info) - return await self._client._request_api_list(self._model, page=self.__class__, options=options) - - -_HttpxClientT = TypeVar("_HttpxClientT", bound=Union[httpx.Client, httpx.AsyncClient]) -_DefaultStreamT = TypeVar("_DefaultStreamT", bound=Union[Stream[Any], AsyncStream[Any]]) - - -class BaseClient(Generic[_HttpxClientT, _DefaultStreamT]): - _client: _HttpxClientT - _version: str - _base_url: URL - max_retries: int - timeout: Union[float, Timeout, None] - _strict_response_validation: bool - _idempotency_header: str | None - _default_stream_cls: type[_DefaultStreamT] | None = None - - def __init__( - self, - *, - version: str, - base_url: str | URL, - _strict_response_validation: bool, - max_retries: int = DEFAULT_MAX_RETRIES, - timeout: float | Timeout | None = DEFAULT_TIMEOUT, - custom_headers: Mapping[str, str] | None = None, - custom_query: Mapping[str, object] | None = None, - ) -> None: - self._version = version - self._base_url = self._enforce_trailing_slash(URL(base_url)) - self.max_retries = max_retries - self.timeout = timeout - self._custom_headers = custom_headers or {} - self._custom_query = custom_query or {} - self._strict_response_validation = _strict_response_validation - self._idempotency_header = None - self._platform: Platform | None = None - - if max_retries is None: # pyright: ignore[reportUnnecessaryComparison] - raise TypeError( - "max_retries cannot be None. If you want to disable retries, pass `0`; if you want unlimited retries, pass `math.inf` or a very high number; if you want the default behavior, pass `opencode_ai.DEFAULT_MAX_RETRIES`" - ) - - def _enforce_trailing_slash(self, url: URL) -> URL: - if url.raw_path.endswith(b"/"): - return url - return url.copy_with(raw_path=url.raw_path + b"/") - - def _make_status_error_from_response( - self, - response: httpx.Response, - ) -> APIStatusError: - if response.is_closed and not response.is_stream_consumed: - # We can't read the response body as it has been closed - # before it was read. This can happen if an event hook - # raises a status error. - body = None - err_msg = f"Error code: {response.status_code}" - else: - err_text = response.text.strip() - body = err_text - - try: - body = json.loads(err_text) - err_msg = f"Error code: {response.status_code} - {body}" - except Exception: - err_msg = err_text or f"Error code: {response.status_code}" - - return self._make_status_error(err_msg, body=body, response=response) - - def _make_status_error( - self, - err_msg: str, - *, - body: object, - response: httpx.Response, - ) -> _exceptions.APIStatusError: - raise NotImplementedError() - - def _build_headers(self, options: FinalRequestOptions, *, retries_taken: int = 0) -> httpx.Headers: - custom_headers = options.headers or {} - headers_dict = _merge_mappings(self.default_headers, custom_headers) - self._validate_headers(headers_dict, custom_headers) - - # headers are case-insensitive while dictionaries are not. - headers = httpx.Headers(headers_dict) - - idempotency_header = self._idempotency_header - if idempotency_header and options.idempotency_key and idempotency_header not in headers: - headers[idempotency_header] = options.idempotency_key - - # Don't set these headers if they were already set or removed by the caller. We check - # `custom_headers`, which can contain `Omit()`, instead of `headers` to account for the removal case. - lower_custom_headers = [header.lower() for header in custom_headers] - if "x-stainless-retry-count" not in lower_custom_headers: - headers["x-stainless-retry-count"] = str(retries_taken) - if "x-stainless-read-timeout" not in lower_custom_headers: - timeout = self.timeout if isinstance(options.timeout, NotGiven) else options.timeout - if isinstance(timeout, Timeout): - timeout = timeout.read - if timeout is not None: - headers["x-stainless-read-timeout"] = str(timeout) - - return headers - - def _prepare_url(self, url: str) -> URL: - """ - Merge a URL argument together with any 'base_url' on the client, - to create the URL used for the outgoing request. - """ - # Copied from httpx's `_merge_url` method. - merge_url = URL(url) - if merge_url.is_relative_url: - merge_raw_path = self.base_url.raw_path + merge_url.raw_path.lstrip(b"/") - return self.base_url.copy_with(raw_path=merge_raw_path) - - return merge_url - - def _make_sse_decoder(self) -> SSEDecoder | SSEBytesDecoder: - return SSEDecoder() - - def _build_request( - self, - options: FinalRequestOptions, - *, - retries_taken: int = 0, - ) -> httpx.Request: - if log.isEnabledFor(logging.DEBUG): - log.debug("Request options: %s", model_dump(options, exclude_unset=True)) - - kwargs: dict[str, Any] = {} - - json_data = options.json_data - if options.extra_json is not None: - if json_data is None: - json_data = cast(Body, options.extra_json) - elif is_mapping(json_data): - json_data = _merge_mappings(json_data, options.extra_json) - else: - raise RuntimeError(f"Unexpected JSON data type, {type(json_data)}, cannot merge with `extra_body`") - - headers = self._build_headers(options, retries_taken=retries_taken) - params = _merge_mappings(self.default_query, options.params) - content_type = headers.get("Content-Type") - files = options.files - - # If the given Content-Type header is multipart/form-data then it - # has to be removed so that httpx can generate the header with - # additional information for us as it has to be in this form - # for the server to be able to correctly parse the request: - # multipart/form-data; boundary=---abc-- - if content_type is not None and content_type.startswith("multipart/form-data"): - if "boundary" not in content_type: - # only remove the header if the boundary hasn't been explicitly set - # as the caller doesn't want httpx to come up with their own boundary - headers.pop("Content-Type") - - # As we are now sending multipart/form-data instead of application/json - # we need to tell httpx to use it, https://www.python-httpx.org/advanced/clients/#multipart-file-encoding - if json_data: - if not is_dict(json_data): - raise TypeError( - f"Expected query input to be a dictionary for multipart requests but got {type(json_data)} instead." - ) - kwargs["data"] = self._serialize_multipartform(json_data) - - # httpx determines whether or not to send a "multipart/form-data" - # request based on the truthiness of the "files" argument. - # This gets around that issue by generating a dict value that - # evaluates to true. - # - # https://github.com/encode/httpx/discussions/2399#discussioncomment-3814186 - if not files: - files = cast(HttpxRequestFiles, ForceMultipartDict()) - - prepared_url = self._prepare_url(options.url) - if "_" in prepared_url.host: - # work around https://github.com/encode/httpx/discussions/2880 - kwargs["extensions"] = {"sni_hostname": prepared_url.host.replace("_", "-")} - - is_body_allowed = options.method.lower() != "get" - - if is_body_allowed: - if isinstance(json_data, bytes): - kwargs["content"] = json_data - else: - kwargs["json"] = json_data if is_given(json_data) else None - kwargs["files"] = files - else: - headers.pop("Content-Type", None) - kwargs.pop("data", None) - - # TODO: report this error to httpx - return self._client.build_request( # pyright: ignore[reportUnknownMemberType] - headers=headers, - timeout=self.timeout if isinstance(options.timeout, NotGiven) else options.timeout, - method=options.method, - url=prepared_url, - # the `Query` type that we use is incompatible with qs' - # `Params` type as it needs to be typed as `Mapping[str, object]` - # so that passing a `TypedDict` doesn't cause an error. - # https://github.com/microsoft/pyright/issues/3526#event-6715453066 - params=self.qs.stringify(cast(Mapping[str, Any], params)) if params else None, - **kwargs, - ) - - def _serialize_multipartform(self, data: Mapping[object, object]) -> dict[str, object]: - items = self.qs.stringify_items( - # TODO: type ignore is required as stringify_items is well typed but we can't be - # well typed without heavy validation. - data, # type: ignore - array_format="brackets", - ) - serialized: dict[str, object] = {} - for key, value in items: - existing = serialized.get(key) - - if not existing: - serialized[key] = value - continue - - # If a value has already been set for this key then that - # means we're sending data like `array[]=[1, 2, 3]` and we - # need to tell httpx that we want to send multiple values with - # the same key which is done by using a list or a tuple. - # - # Note: 2d arrays should never result in the same key at both - # levels so it's safe to assume that if the value is a list, - # it was because we changed it to be a list. - if is_list(existing): - existing.append(value) - else: - serialized[key] = [existing, value] - - return serialized - - def _maybe_override_cast_to(self, cast_to: type[ResponseT], options: FinalRequestOptions) -> type[ResponseT]: - if not is_given(options.headers): - return cast_to - - # make a copy of the headers so we don't mutate user-input - headers = dict(options.headers) - - # we internally support defining a temporary header to override the - # default `cast_to` type for use with `.with_raw_response` and `.with_streaming_response` - # see _response.py for implementation details - override_cast_to = headers.pop(OVERRIDE_CAST_TO_HEADER, NOT_GIVEN) - if is_given(override_cast_to): - options.headers = headers - return cast(Type[ResponseT], override_cast_to) - - return cast_to - - def _should_stream_response_body(self, request: httpx.Request) -> bool: - return request.headers.get(RAW_RESPONSE_HEADER) == "stream" # type: ignore[no-any-return] - - def _process_response_data( - self, - *, - data: object, - cast_to: type[ResponseT], - response: httpx.Response, - ) -> ResponseT: - if data is None: - return cast(ResponseT, None) - - if cast_to is object: - return cast(ResponseT, data) - - try: - if inspect.isclass(cast_to) and issubclass(cast_to, ModelBuilderProtocol): - return cast(ResponseT, cast_to.build(response=response, data=data)) - - if self._strict_response_validation: - return cast(ResponseT, validate_type(type_=cast_to, value=data)) - - return cast(ResponseT, construct_type(type_=cast_to, value=data)) - except pydantic.ValidationError as err: - raise APIResponseValidationError(response=response, body=data) from err - - @property - def qs(self) -> Querystring: - return Querystring() - - @property - def custom_auth(self) -> httpx.Auth | None: - return None - - @property - def auth_headers(self) -> dict[str, str]: - return {} - - @property - def default_headers(self) -> dict[str, str | Omit]: - return { - "Accept": "application/json", - "Content-Type": "application/json", - "User-Agent": self.user_agent, - **self.platform_headers(), - **self.auth_headers, - **self._custom_headers, - } - - @property - def default_query(self) -> dict[str, object]: - return { - **self._custom_query, - } - - def _validate_headers( - self, - headers: Headers, # noqa: ARG002 - custom_headers: Headers, # noqa: ARG002 - ) -> None: - """Validate the given default headers and custom headers. - - Does nothing by default. - """ - return - - @property - def user_agent(self) -> str: - return f"{self.__class__.__name__}/Python {self._version}" - - @property - def base_url(self) -> URL: - return self._base_url - - @base_url.setter - def base_url(self, url: URL | str) -> None: - self._base_url = self._enforce_trailing_slash(url if isinstance(url, URL) else URL(url)) - - def platform_headers(self) -> Dict[str, str]: - # the actual implementation is in a separate `lru_cache` decorated - # function because adding `lru_cache` to methods will leak memory - # https://github.com/python/cpython/issues/88476 - return platform_headers(self._version, platform=self._platform) - - def _parse_retry_after_header(self, response_headers: Optional[httpx.Headers] = None) -> float | None: - """Returns a float of the number of seconds (not milliseconds) to wait after retrying, or None if unspecified. - - About the Retry-After header: https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Retry-After - See also https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Retry-After#syntax - """ - if response_headers is None: - return None - - # First, try the non-standard `retry-after-ms` header for milliseconds, - # which is more precise than integer-seconds `retry-after` - try: - retry_ms_header = response_headers.get("retry-after-ms", None) - return float(retry_ms_header) / 1000 - except (TypeError, ValueError): - pass - - # Next, try parsing `retry-after` header as seconds (allowing nonstandard floats). - retry_header = response_headers.get("retry-after") - try: - # note: the spec indicates that this should only ever be an integer - # but if someone sends a float there's no reason for us to not respect it - return float(retry_header) - except (TypeError, ValueError): - pass - - # Last, try parsing `retry-after` as a date. - retry_date_tuple = email.utils.parsedate_tz(retry_header) - if retry_date_tuple is None: - return None - - retry_date = email.utils.mktime_tz(retry_date_tuple) - return float(retry_date - time.time()) - - def _calculate_retry_timeout( - self, - remaining_retries: int, - options: FinalRequestOptions, - response_headers: Optional[httpx.Headers] = None, - ) -> float: - max_retries = options.get_max_retries(self.max_retries) - - # If the API asks us to wait a certain amount of time (and it's a reasonable amount), just do what it says. - retry_after = self._parse_retry_after_header(response_headers) - if retry_after is not None and 0 < retry_after <= 60: - return retry_after - - # Also cap retry count to 1000 to avoid any potential overflows with `pow` - nb_retries = min(max_retries - remaining_retries, 1000) - - # Apply exponential backoff, but not more than the max. - sleep_seconds = min(INITIAL_RETRY_DELAY * pow(2.0, nb_retries), MAX_RETRY_DELAY) - - # Apply some jitter, plus-or-minus half a second. - jitter = 1 - 0.25 * random() - timeout = sleep_seconds * jitter - return timeout if timeout >= 0 else 0 - - def _should_retry(self, response: httpx.Response) -> bool: - # Note: this is not a standard header - should_retry_header = response.headers.get("x-should-retry") - - # If the server explicitly says whether or not to retry, obey. - if should_retry_header == "true": - log.debug("Retrying as header `x-should-retry` is set to `true`") - return True - if should_retry_header == "false": - log.debug("Not retrying as header `x-should-retry` is set to `false`") - return False - - # Retry on request timeouts. - if response.status_code == 408: - log.debug("Retrying due to status code %i", response.status_code) - return True - - # Retry on lock timeouts. - if response.status_code == 409: - log.debug("Retrying due to status code %i", response.status_code) - return True - - # Retry on rate limits. - if response.status_code == 429: - log.debug("Retrying due to status code %i", response.status_code) - return True - - # Retry internal errors. - if response.status_code >= 500: - log.debug("Retrying due to status code %i", response.status_code) - return True - - log.debug("Not retrying") - return False - - def _idempotency_key(self) -> str: - return f"stainless-python-retry-{uuid.uuid4()}" - - -class _DefaultHttpxClient(httpx.Client): - def __init__(self, **kwargs: Any) -> None: - kwargs.setdefault("timeout", DEFAULT_TIMEOUT) - kwargs.setdefault("limits", DEFAULT_CONNECTION_LIMITS) - kwargs.setdefault("follow_redirects", True) - super().__init__(**kwargs) - - -if TYPE_CHECKING: - DefaultHttpxClient = httpx.Client - """An alias to `httpx.Client` that provides the same defaults that this SDK - uses internally. - - This is useful because overriding the `http_client` with your own instance of - `httpx.Client` will result in httpx's defaults being used, not ours. - """ -else: - DefaultHttpxClient = _DefaultHttpxClient - - -class SyncHttpxClientWrapper(DefaultHttpxClient): - def __del__(self) -> None: - if self.is_closed: - return - - try: - self.close() - except Exception: - pass - - -class SyncAPIClient(BaseClient[httpx.Client, Stream[Any]]): - _client: httpx.Client - _default_stream_cls: type[Stream[Any]] | None = None - - def __init__( - self, - *, - version: str, - base_url: str | URL, - max_retries: int = DEFAULT_MAX_RETRIES, - timeout: float | Timeout | None | NotGiven = NOT_GIVEN, - http_client: httpx.Client | None = None, - custom_headers: Mapping[str, str] | None = None, - custom_query: Mapping[str, object] | None = None, - _strict_response_validation: bool, - ) -> None: - if not is_given(timeout): - # if the user passed in a custom http client with a non-default - # timeout set then we use that timeout. - # - # note: there is an edge case here where the user passes in a client - # where they've explicitly set the timeout to match the default timeout - # as this check is structural, meaning that we'll think they didn't - # pass in a timeout and will ignore it - if http_client and http_client.timeout != HTTPX_DEFAULT_TIMEOUT: - timeout = http_client.timeout - else: - timeout = DEFAULT_TIMEOUT - - if http_client is not None and not isinstance(http_client, httpx.Client): # pyright: ignore[reportUnnecessaryIsInstance] - raise TypeError( - f"Invalid `http_client` argument; Expected an instance of `httpx.Client` but got {type(http_client)}" - ) - - super().__init__( - version=version, - # cast to a valid type because mypy doesn't understand our type narrowing - timeout=cast(Timeout, timeout), - base_url=base_url, - max_retries=max_retries, - custom_query=custom_query, - custom_headers=custom_headers, - _strict_response_validation=_strict_response_validation, - ) - self._client = http_client or SyncHttpxClientWrapper( - base_url=base_url, - # cast to a valid type because mypy doesn't understand our type narrowing - timeout=cast(Timeout, timeout), - ) - - def is_closed(self) -> bool: - return self._client.is_closed - - def close(self) -> None: - """Close the underlying HTTPX client. - - The client will *not* be usable after this. - """ - # If an error is thrown while constructing a client, self._client - # may not be present - if hasattr(self, "_client"): - self._client.close() - - def __enter__(self: _T) -> _T: - return self - - def __exit__( - self, - exc_type: type[BaseException] | None, - exc: BaseException | None, - exc_tb: TracebackType | None, - ) -> None: - self.close() - - def _prepare_options( - self, - options: FinalRequestOptions, # noqa: ARG002 - ) -> FinalRequestOptions: - """Hook for mutating the given options""" - return options - - def _prepare_request( - self, - request: httpx.Request, # noqa: ARG002 - ) -> None: - """This method is used as a callback for mutating the `Request` object - after it has been constructed. - This is useful for cases where you want to add certain headers based off of - the request properties, e.g. `url`, `method` etc. - """ - return None - - @overload - def request( - self, - cast_to: Type[ResponseT], - options: FinalRequestOptions, - *, - stream: Literal[True], - stream_cls: Type[_StreamT], - ) -> _StreamT: ... - - @overload - def request( - self, - cast_to: Type[ResponseT], - options: FinalRequestOptions, - *, - stream: Literal[False] = False, - ) -> ResponseT: ... - - @overload - def request( - self, - cast_to: Type[ResponseT], - options: FinalRequestOptions, - *, - stream: bool = False, - stream_cls: Type[_StreamT] | None = None, - ) -> ResponseT | _StreamT: ... - - def request( - self, - cast_to: Type[ResponseT], - options: FinalRequestOptions, - *, - stream: bool = False, - stream_cls: type[_StreamT] | None = None, - ) -> ResponseT | _StreamT: - cast_to = self._maybe_override_cast_to(cast_to, options) - - # create a copy of the options we were given so that if the - # options are mutated later & we then retry, the retries are - # given the original options - input_options = model_copy(options) - if input_options.idempotency_key is None and input_options.method.lower() != "get": - # ensure the idempotency key is reused between requests - input_options.idempotency_key = self._idempotency_key() - - response: httpx.Response | None = None - max_retries = input_options.get_max_retries(self.max_retries) - - retries_taken = 0 - for retries_taken in range(max_retries + 1): - options = model_copy(input_options) - options = self._prepare_options(options) - - remaining_retries = max_retries - retries_taken - request = self._build_request(options, retries_taken=retries_taken) - self._prepare_request(request) - - kwargs: HttpxSendArgs = {} - if self.custom_auth is not None: - kwargs["auth"] = self.custom_auth - - if options.follow_redirects is not None: - kwargs["follow_redirects"] = options.follow_redirects - - log.debug("Sending HTTP Request: %s %s", request.method, request.url) - - response = None - try: - response = self._client.send( - request, - stream=stream or self._should_stream_response_body(request=request), - **kwargs, - ) - except httpx.TimeoutException as err: - log.debug("Encountered httpx.TimeoutException", exc_info=True) - - if remaining_retries > 0: - self._sleep_for_retry( - retries_taken=retries_taken, - max_retries=max_retries, - options=input_options, - response=None, - ) - continue - - log.debug("Raising timeout error") - raise APITimeoutError(request=request) from err - except Exception as err: - log.debug("Encountered Exception", exc_info=True) - - if remaining_retries > 0: - self._sleep_for_retry( - retries_taken=retries_taken, - max_retries=max_retries, - options=input_options, - response=None, - ) - continue - - log.debug("Raising connection error") - raise APIConnectionError(request=request) from err - - log.debug( - 'HTTP Response: %s %s "%i %s" %s', - request.method, - request.url, - response.status_code, - response.reason_phrase, - response.headers, - ) - - try: - response.raise_for_status() - except httpx.HTTPStatusError as err: # thrown on 4xx and 5xx status code - log.debug("Encountered httpx.HTTPStatusError", exc_info=True) - - if remaining_retries > 0 and self._should_retry(err.response): - err.response.close() - self._sleep_for_retry( - retries_taken=retries_taken, - max_retries=max_retries, - options=input_options, - response=response, - ) - continue - - # If the response is streamed then we need to explicitly read the response - # to completion before attempting to access the response text. - if not err.response.is_closed: - err.response.read() - - log.debug("Re-raising status error") - raise self._make_status_error_from_response(err.response) from None - - break - - assert response is not None, "could not resolve response (should never happen)" - return self._process_response( - cast_to=cast_to, - options=options, - response=response, - stream=stream, - stream_cls=stream_cls, - retries_taken=retries_taken, - ) - - def _sleep_for_retry( - self, *, retries_taken: int, max_retries: int, options: FinalRequestOptions, response: httpx.Response | None - ) -> None: - remaining_retries = max_retries - retries_taken - if remaining_retries == 1: - log.debug("1 retry left") - else: - log.debug("%i retries left", remaining_retries) - - timeout = self._calculate_retry_timeout(remaining_retries, options, response.headers if response else None) - log.info("Retrying request to %s in %f seconds", options.url, timeout) - - time.sleep(timeout) - - def _process_response( - self, - *, - cast_to: Type[ResponseT], - options: FinalRequestOptions, - response: httpx.Response, - stream: bool, - stream_cls: type[Stream[Any]] | type[AsyncStream[Any]] | None, - retries_taken: int = 0, - ) -> ResponseT: - origin = get_origin(cast_to) or cast_to - - if ( - inspect.isclass(origin) - and issubclass(origin, BaseAPIResponse) - # we only want to actually return the custom BaseAPIResponse class if we're - # returning the raw response, or if we're not streaming SSE, as if we're streaming - # SSE then `cast_to` doesn't actively reflect the type we need to parse into - and (not stream or bool(response.request.headers.get(RAW_RESPONSE_HEADER))) - ): - if not issubclass(origin, APIResponse): - raise TypeError(f"API Response types must subclass {APIResponse}; Received {origin}") - - response_cls = cast("type[BaseAPIResponse[Any]]", cast_to) - return cast( - ResponseT, - response_cls( - raw=response, - client=self, - cast_to=extract_response_type(response_cls), - stream=stream, - stream_cls=stream_cls, - options=options, - retries_taken=retries_taken, - ), - ) - - if cast_to == httpx.Response: - return cast(ResponseT, response) - - api_response = APIResponse( - raw=response, - client=self, - cast_to=cast("type[ResponseT]", cast_to), # pyright: ignore[reportUnnecessaryCast] - stream=stream, - stream_cls=stream_cls, - options=options, - retries_taken=retries_taken, - ) - if bool(response.request.headers.get(RAW_RESPONSE_HEADER)): - return cast(ResponseT, api_response) - - return api_response.parse() - - def _request_api_list( - self, - model: Type[object], - page: Type[SyncPageT], - options: FinalRequestOptions, - ) -> SyncPageT: - def _parser(resp: SyncPageT) -> SyncPageT: - resp._set_private_attributes( - client=self, - model=model, - options=options, - ) - return resp - - options.post_parser = _parser - - return self.request(page, options, stream=False) - - @overload - def get( - self, - path: str, - *, - cast_to: Type[ResponseT], - options: RequestOptions = {}, - stream: Literal[False] = False, - ) -> ResponseT: ... - - @overload - def get( - self, - path: str, - *, - cast_to: Type[ResponseT], - options: RequestOptions = {}, - stream: Literal[True], - stream_cls: type[_StreamT], - ) -> _StreamT: ... - - @overload - def get( - self, - path: str, - *, - cast_to: Type[ResponseT], - options: RequestOptions = {}, - stream: bool, - stream_cls: type[_StreamT] | None = None, - ) -> ResponseT | _StreamT: ... - - def get( - self, - path: str, - *, - cast_to: Type[ResponseT], - options: RequestOptions = {}, - stream: bool = False, - stream_cls: type[_StreamT] | None = None, - ) -> ResponseT | _StreamT: - opts = FinalRequestOptions.construct(method="get", url=path, **options) - # cast is required because mypy complains about returning Any even though - # it understands the type variables - return cast(ResponseT, self.request(cast_to, opts, stream=stream, stream_cls=stream_cls)) - - @overload - def post( - self, - path: str, - *, - cast_to: Type[ResponseT], - body: Body | None = None, - options: RequestOptions = {}, - files: RequestFiles | None = None, - stream: Literal[False] = False, - ) -> ResponseT: ... - - @overload - def post( - self, - path: str, - *, - cast_to: Type[ResponseT], - body: Body | None = None, - options: RequestOptions = {}, - files: RequestFiles | None = None, - stream: Literal[True], - stream_cls: type[_StreamT], - ) -> _StreamT: ... - - @overload - def post( - self, - path: str, - *, - cast_to: Type[ResponseT], - body: Body | None = None, - options: RequestOptions = {}, - files: RequestFiles | None = None, - stream: bool, - stream_cls: type[_StreamT] | None = None, - ) -> ResponseT | _StreamT: ... - - def post( - self, - path: str, - *, - cast_to: Type[ResponseT], - body: Body | None = None, - options: RequestOptions = {}, - files: RequestFiles | None = None, - stream: bool = False, - stream_cls: type[_StreamT] | None = None, - ) -> ResponseT | _StreamT: - opts = FinalRequestOptions.construct( - method="post", url=path, json_data=body, files=to_httpx_files(files), **options - ) - return cast(ResponseT, self.request(cast_to, opts, stream=stream, stream_cls=stream_cls)) - - def patch( - self, - path: str, - *, - cast_to: Type[ResponseT], - body: Body | None = None, - options: RequestOptions = {}, - ) -> ResponseT: - opts = FinalRequestOptions.construct(method="patch", url=path, json_data=body, **options) - return self.request(cast_to, opts) - - def put( - self, - path: str, - *, - cast_to: Type[ResponseT], - body: Body | None = None, - files: RequestFiles | None = None, - options: RequestOptions = {}, - ) -> ResponseT: - opts = FinalRequestOptions.construct( - method="put", url=path, json_data=body, files=to_httpx_files(files), **options - ) - return self.request(cast_to, opts) - - def delete( - self, - path: str, - *, - cast_to: Type[ResponseT], - body: Body | None = None, - options: RequestOptions = {}, - ) -> ResponseT: - opts = FinalRequestOptions.construct(method="delete", url=path, json_data=body, **options) - return self.request(cast_to, opts) - - def get_api_list( - self, - path: str, - *, - model: Type[object], - page: Type[SyncPageT], - body: Body | None = None, - options: RequestOptions = {}, - method: str = "get", - ) -> SyncPageT: - opts = FinalRequestOptions.construct(method=method, url=path, json_data=body, **options) - return self._request_api_list(model, page, opts) - - -class _DefaultAsyncHttpxClient(httpx.AsyncClient): - def __init__(self, **kwargs: Any) -> None: - kwargs.setdefault("timeout", DEFAULT_TIMEOUT) - kwargs.setdefault("limits", DEFAULT_CONNECTION_LIMITS) - kwargs.setdefault("follow_redirects", True) - super().__init__(**kwargs) - - -try: - import httpx_aiohttp -except ImportError: - - class _DefaultAioHttpClient(httpx.AsyncClient): - def __init__(self, **_kwargs: Any) -> None: - raise RuntimeError("To use the aiohttp client you must have installed the package with the `aiohttp` extra") -else: - - class _DefaultAioHttpClient(httpx_aiohttp.HttpxAiohttpClient): # type: ignore - def __init__(self, **kwargs: Any) -> None: - kwargs.setdefault("timeout", DEFAULT_TIMEOUT) - kwargs.setdefault("limits", DEFAULT_CONNECTION_LIMITS) - kwargs.setdefault("follow_redirects", True) - - super().__init__(**kwargs) - - -if TYPE_CHECKING: - DefaultAsyncHttpxClient = httpx.AsyncClient - """An alias to `httpx.AsyncClient` that provides the same defaults that this SDK - uses internally. - - This is useful because overriding the `http_client` with your own instance of - `httpx.AsyncClient` will result in httpx's defaults being used, not ours. - """ - - DefaultAioHttpClient = httpx.AsyncClient - """An alias to `httpx.AsyncClient` that changes the default HTTP transport to `aiohttp`.""" -else: - DefaultAsyncHttpxClient = _DefaultAsyncHttpxClient - DefaultAioHttpClient = _DefaultAioHttpClient - - -class AsyncHttpxClientWrapper(DefaultAsyncHttpxClient): - def __del__(self) -> None: - if self.is_closed: - return - - try: - # TODO(someday): support non asyncio runtimes here - asyncio.get_running_loop().create_task(self.aclose()) - except Exception: - pass - - -class AsyncAPIClient(BaseClient[httpx.AsyncClient, AsyncStream[Any]]): - _client: httpx.AsyncClient - _default_stream_cls: type[AsyncStream[Any]] | None = None - - def __init__( - self, - *, - version: str, - base_url: str | URL, - _strict_response_validation: bool, - max_retries: int = DEFAULT_MAX_RETRIES, - timeout: float | Timeout | None | NotGiven = NOT_GIVEN, - http_client: httpx.AsyncClient | None = None, - custom_headers: Mapping[str, str] | None = None, - custom_query: Mapping[str, object] | None = None, - ) -> None: - if not is_given(timeout): - # if the user passed in a custom http client with a non-default - # timeout set then we use that timeout. - # - # note: there is an edge case here where the user passes in a client - # where they've explicitly set the timeout to match the default timeout - # as this check is structural, meaning that we'll think they didn't - # pass in a timeout and will ignore it - if http_client and http_client.timeout != HTTPX_DEFAULT_TIMEOUT: - timeout = http_client.timeout - else: - timeout = DEFAULT_TIMEOUT - - if http_client is not None and not isinstance(http_client, httpx.AsyncClient): # pyright: ignore[reportUnnecessaryIsInstance] - raise TypeError( - f"Invalid `http_client` argument; Expected an instance of `httpx.AsyncClient` but got {type(http_client)}" - ) - - super().__init__( - version=version, - base_url=base_url, - # cast to a valid type because mypy doesn't understand our type narrowing - timeout=cast(Timeout, timeout), - max_retries=max_retries, - custom_query=custom_query, - custom_headers=custom_headers, - _strict_response_validation=_strict_response_validation, - ) - self._client = http_client or AsyncHttpxClientWrapper( - base_url=base_url, - # cast to a valid type because mypy doesn't understand our type narrowing - timeout=cast(Timeout, timeout), - ) - - def is_closed(self) -> bool: - return self._client.is_closed - - async def close(self) -> None: - """Close the underlying HTTPX client. - - The client will *not* be usable after this. - """ - await self._client.aclose() - - async def __aenter__(self: _T) -> _T: - return self - - async def __aexit__( - self, - exc_type: type[BaseException] | None, - exc: BaseException | None, - exc_tb: TracebackType | None, - ) -> None: - await self.close() - - async def _prepare_options( - self, - options: FinalRequestOptions, # noqa: ARG002 - ) -> FinalRequestOptions: - """Hook for mutating the given options""" - return options - - async def _prepare_request( - self, - request: httpx.Request, # noqa: ARG002 - ) -> None: - """This method is used as a callback for mutating the `Request` object - after it has been constructed. - This is useful for cases where you want to add certain headers based off of - the request properties, e.g. `url`, `method` etc. - """ - return None - - @overload - async def request( - self, - cast_to: Type[ResponseT], - options: FinalRequestOptions, - *, - stream: Literal[False] = False, - ) -> ResponseT: ... - - @overload - async def request( - self, - cast_to: Type[ResponseT], - options: FinalRequestOptions, - *, - stream: Literal[True], - stream_cls: type[_AsyncStreamT], - ) -> _AsyncStreamT: ... - - @overload - async def request( - self, - cast_to: Type[ResponseT], - options: FinalRequestOptions, - *, - stream: bool, - stream_cls: type[_AsyncStreamT] | None = None, - ) -> ResponseT | _AsyncStreamT: ... - - async def request( - self, - cast_to: Type[ResponseT], - options: FinalRequestOptions, - *, - stream: bool = False, - stream_cls: type[_AsyncStreamT] | None = None, - ) -> ResponseT | _AsyncStreamT: - if self._platform is None: - # `get_platform` can make blocking IO calls so we - # execute it earlier while we are in an async context - self._platform = await asyncify(get_platform)() - - cast_to = self._maybe_override_cast_to(cast_to, options) - - # create a copy of the options we were given so that if the - # options are mutated later & we then retry, the retries are - # given the original options - input_options = model_copy(options) - if input_options.idempotency_key is None and input_options.method.lower() != "get": - # ensure the idempotency key is reused between requests - input_options.idempotency_key = self._idempotency_key() - - response: httpx.Response | None = None - max_retries = input_options.get_max_retries(self.max_retries) - - retries_taken = 0 - for retries_taken in range(max_retries + 1): - options = model_copy(input_options) - options = await self._prepare_options(options) - - remaining_retries = max_retries - retries_taken - request = self._build_request(options, retries_taken=retries_taken) - await self._prepare_request(request) - - kwargs: HttpxSendArgs = {} - if self.custom_auth is not None: - kwargs["auth"] = self.custom_auth - - if options.follow_redirects is not None: - kwargs["follow_redirects"] = options.follow_redirects - - log.debug("Sending HTTP Request: %s %s", request.method, request.url) - - response = None - try: - response = await self._client.send( - request, - stream=stream or self._should_stream_response_body(request=request), - **kwargs, - ) - except httpx.TimeoutException as err: - log.debug("Encountered httpx.TimeoutException", exc_info=True) - - if remaining_retries > 0: - await self._sleep_for_retry( - retries_taken=retries_taken, - max_retries=max_retries, - options=input_options, - response=None, - ) - continue - - log.debug("Raising timeout error") - raise APITimeoutError(request=request) from err - except Exception as err: - log.debug("Encountered Exception", exc_info=True) - - if remaining_retries > 0: - await self._sleep_for_retry( - retries_taken=retries_taken, - max_retries=max_retries, - options=input_options, - response=None, - ) - continue - - log.debug("Raising connection error") - raise APIConnectionError(request=request) from err - - log.debug( - 'HTTP Response: %s %s "%i %s" %s', - request.method, - request.url, - response.status_code, - response.reason_phrase, - response.headers, - ) - - try: - response.raise_for_status() - except httpx.HTTPStatusError as err: # thrown on 4xx and 5xx status code - log.debug("Encountered httpx.HTTPStatusError", exc_info=True) - - if remaining_retries > 0 and self._should_retry(err.response): - await err.response.aclose() - await self._sleep_for_retry( - retries_taken=retries_taken, - max_retries=max_retries, - options=input_options, - response=response, - ) - continue - - # If the response is streamed then we need to explicitly read the response - # to completion before attempting to access the response text. - if not err.response.is_closed: - await err.response.aread() - - log.debug("Re-raising status error") - raise self._make_status_error_from_response(err.response) from None - - break - - assert response is not None, "could not resolve response (should never happen)" - return await self._process_response( - cast_to=cast_to, - options=options, - response=response, - stream=stream, - stream_cls=stream_cls, - retries_taken=retries_taken, - ) - - async def _sleep_for_retry( - self, *, retries_taken: int, max_retries: int, options: FinalRequestOptions, response: httpx.Response | None - ) -> None: - remaining_retries = max_retries - retries_taken - if remaining_retries == 1: - log.debug("1 retry left") - else: - log.debug("%i retries left", remaining_retries) - - timeout = self._calculate_retry_timeout(remaining_retries, options, response.headers if response else None) - log.info("Retrying request to %s in %f seconds", options.url, timeout) - - await anyio.sleep(timeout) - - async def _process_response( - self, - *, - cast_to: Type[ResponseT], - options: FinalRequestOptions, - response: httpx.Response, - stream: bool, - stream_cls: type[Stream[Any]] | type[AsyncStream[Any]] | None, - retries_taken: int = 0, - ) -> ResponseT: - origin = get_origin(cast_to) or cast_to - - if ( - inspect.isclass(origin) - and issubclass(origin, BaseAPIResponse) - # we only want to actually return the custom BaseAPIResponse class if we're - # returning the raw response, or if we're not streaming SSE, as if we're streaming - # SSE then `cast_to` doesn't actively reflect the type we need to parse into - and (not stream or bool(response.request.headers.get(RAW_RESPONSE_HEADER))) - ): - if not issubclass(origin, AsyncAPIResponse): - raise TypeError(f"API Response types must subclass {AsyncAPIResponse}; Received {origin}") - - response_cls = cast("type[BaseAPIResponse[Any]]", cast_to) - return cast( - "ResponseT", - response_cls( - raw=response, - client=self, - cast_to=extract_response_type(response_cls), - stream=stream, - stream_cls=stream_cls, - options=options, - retries_taken=retries_taken, - ), - ) - - if cast_to == httpx.Response: - return cast(ResponseT, response) - - api_response = AsyncAPIResponse( - raw=response, - client=self, - cast_to=cast("type[ResponseT]", cast_to), # pyright: ignore[reportUnnecessaryCast] - stream=stream, - stream_cls=stream_cls, - options=options, - retries_taken=retries_taken, - ) - if bool(response.request.headers.get(RAW_RESPONSE_HEADER)): - return cast(ResponseT, api_response) - - return await api_response.parse() - - def _request_api_list( - self, - model: Type[_T], - page: Type[AsyncPageT], - options: FinalRequestOptions, - ) -> AsyncPaginator[_T, AsyncPageT]: - return AsyncPaginator(client=self, options=options, page_cls=page, model=model) - - @overload - async def get( - self, - path: str, - *, - cast_to: Type[ResponseT], - options: RequestOptions = {}, - stream: Literal[False] = False, - ) -> ResponseT: ... - - @overload - async def get( - self, - path: str, - *, - cast_to: Type[ResponseT], - options: RequestOptions = {}, - stream: Literal[True], - stream_cls: type[_AsyncStreamT], - ) -> _AsyncStreamT: ... - - @overload - async def get( - self, - path: str, - *, - cast_to: Type[ResponseT], - options: RequestOptions = {}, - stream: bool, - stream_cls: type[_AsyncStreamT] | None = None, - ) -> ResponseT | _AsyncStreamT: ... - - async def get( - self, - path: str, - *, - cast_to: Type[ResponseT], - options: RequestOptions = {}, - stream: bool = False, - stream_cls: type[_AsyncStreamT] | None = None, - ) -> ResponseT | _AsyncStreamT: - opts = FinalRequestOptions.construct(method="get", url=path, **options) - return await self.request(cast_to, opts, stream=stream, stream_cls=stream_cls) - - @overload - async def post( - self, - path: str, - *, - cast_to: Type[ResponseT], - body: Body | None = None, - files: RequestFiles | None = None, - options: RequestOptions = {}, - stream: Literal[False] = False, - ) -> ResponseT: ... - - @overload - async def post( - self, - path: str, - *, - cast_to: Type[ResponseT], - body: Body | None = None, - files: RequestFiles | None = None, - options: RequestOptions = {}, - stream: Literal[True], - stream_cls: type[_AsyncStreamT], - ) -> _AsyncStreamT: ... - - @overload - async def post( - self, - path: str, - *, - cast_to: Type[ResponseT], - body: Body | None = None, - files: RequestFiles | None = None, - options: RequestOptions = {}, - stream: bool, - stream_cls: type[_AsyncStreamT] | None = None, - ) -> ResponseT | _AsyncStreamT: ... - - async def post( - self, - path: str, - *, - cast_to: Type[ResponseT], - body: Body | None = None, - files: RequestFiles | None = None, - options: RequestOptions = {}, - stream: bool = False, - stream_cls: type[_AsyncStreamT] | None = None, - ) -> ResponseT | _AsyncStreamT: - opts = FinalRequestOptions.construct( - method="post", url=path, json_data=body, files=await async_to_httpx_files(files), **options - ) - return await self.request(cast_to, opts, stream=stream, stream_cls=stream_cls) - - async def patch( - self, - path: str, - *, - cast_to: Type[ResponseT], - body: Body | None = None, - options: RequestOptions = {}, - ) -> ResponseT: - opts = FinalRequestOptions.construct(method="patch", url=path, json_data=body, **options) - return await self.request(cast_to, opts) - - async def put( - self, - path: str, - *, - cast_to: Type[ResponseT], - body: Body | None = None, - files: RequestFiles | None = None, - options: RequestOptions = {}, - ) -> ResponseT: - opts = FinalRequestOptions.construct( - method="put", url=path, json_data=body, files=await async_to_httpx_files(files), **options - ) - return await self.request(cast_to, opts) - - async def delete( - self, - path: str, - *, - cast_to: Type[ResponseT], - body: Body | None = None, - options: RequestOptions = {}, - ) -> ResponseT: - opts = FinalRequestOptions.construct(method="delete", url=path, json_data=body, **options) - return await self.request(cast_to, opts) - - def get_api_list( - self, - path: str, - *, - model: Type[_T], - page: Type[AsyncPageT], - body: Body | None = None, - options: RequestOptions = {}, - method: str = "get", - ) -> AsyncPaginator[_T, AsyncPageT]: - opts = FinalRequestOptions.construct(method=method, url=path, json_data=body, **options) - return self._request_api_list(model, page, opts) - - -def make_request_options( - *, - query: Query | None = None, - extra_headers: Headers | None = None, - extra_query: Query | None = None, - extra_body: Body | None = None, - idempotency_key: str | None = None, - timeout: float | httpx.Timeout | None | NotGiven = NOT_GIVEN, - post_parser: PostParser | NotGiven = NOT_GIVEN, -) -> RequestOptions: - """Create a dict of type RequestOptions without keys of NotGiven values.""" - options: RequestOptions = {} - if extra_headers is not None: - options["headers"] = extra_headers - - if extra_body is not None: - options["extra_json"] = cast(AnyMapping, extra_body) - - if query is not None: - options["params"] = query - - if extra_query is not None: - options["params"] = {**options.get("params", {}), **extra_query} - - if not isinstance(timeout, NotGiven): - options["timeout"] = timeout - - if idempotency_key is not None: - options["idempotency_key"] = idempotency_key - - if is_given(post_parser): - # internal - options["post_parser"] = post_parser # type: ignore - - return options - - -class ForceMultipartDict(Dict[str, None]): - def __bool__(self) -> bool: - return True - - -class OtherPlatform: - def __init__(self, name: str) -> None: - self.name = name - - @override - def __str__(self) -> str: - return f"Other:{self.name}" - - -Platform = Union[ - OtherPlatform, - Literal[ - "MacOS", - "Linux", - "Windows", - "FreeBSD", - "OpenBSD", - "iOS", - "Android", - "Unknown", - ], -] - - -def get_platform() -> Platform: - try: - system = platform.system().lower() - platform_name = platform.platform().lower() - except Exception: - return "Unknown" - - if "iphone" in platform_name or "ipad" in platform_name: - # Tested using Python3IDE on an iPhone 11 and Pythonista on an iPad 7 - # system is Darwin and platform_name is a string like: - # - Darwin-21.6.0-iPhone12,1-64bit - # - Darwin-21.6.0-iPad7,11-64bit - return "iOS" - - if system == "darwin": - return "MacOS" - - if system == "windows": - return "Windows" - - if "android" in platform_name: - # Tested using Pydroid 3 - # system is Linux and platform_name is a string like 'Linux-5.10.81-android12-9-00001-geba40aecb3b7-ab8534902-aarch64-with-libc' - return "Android" - - if system == "linux": - # https://distro.readthedocs.io/en/latest/#distro.id - distro_id = distro.id() - if distro_id == "freebsd": - return "FreeBSD" - - if distro_id == "openbsd": - return "OpenBSD" - - return "Linux" - - if platform_name: - return OtherPlatform(platform_name) - - return "Unknown" - - -@lru_cache(maxsize=None) -def platform_headers(version: str, *, platform: Platform | None) -> Dict[str, str]: - return { - "X-Stainless-Lang": "python", - "X-Stainless-Package-Version": version, - "X-Stainless-OS": str(platform or get_platform()), - "X-Stainless-Arch": str(get_architecture()), - "X-Stainless-Runtime": get_python_runtime(), - "X-Stainless-Runtime-Version": get_python_version(), - } - - -class OtherArch: - def __init__(self, name: str) -> None: - self.name = name - - @override - def __str__(self) -> str: - return f"other:{self.name}" - - -Arch = Union[OtherArch, Literal["x32", "x64", "arm", "arm64", "unknown"]] - - -def get_python_runtime() -> str: - try: - return platform.python_implementation() - except Exception: - return "unknown" - - -def get_python_version() -> str: - try: - return platform.python_version() - except Exception: - return "unknown" - - -def get_architecture() -> Arch: - try: - machine = platform.machine().lower() - except Exception: - return "unknown" - - if machine in ("arm64", "aarch64"): - return "arm64" - - # TODO: untested - if machine == "arm": - return "arm" - - if machine == "x86_64": - return "x64" - - # TODO: untested - if sys.maxsize <= 2**32: - return "x32" - - if machine: - return OtherArch(machine) - - return "unknown" - - -def _merge_mappings( - obj1: Mapping[_T_co, Union[_T, Omit]], - obj2: Mapping[_T_co, Union[_T, Omit]], -) -> Dict[_T_co, _T]: - """Merge two mappings of the same type, removing any values that are instances of `Omit`. - - In cases with duplicate keys the second mapping takes precedence. - """ - merged = {**obj1, **obj2} - return {key: value for key, value in merged.items() if not isinstance(value, Omit)} diff --git a/src/opencode_ai/_client.py b/src/opencode_ai/_client.py deleted file mode 100644 index cabe5b7..0000000 --- a/src/opencode_ai/_client.py +++ /dev/null @@ -1,413 +0,0 @@ -# File generated from our OpenAPI spec by Stainless. See CONTRIBUTING.md for details. - -from __future__ import annotations - -import os -from typing import Any, Union, Mapping -from typing_extensions import Self, override - -import httpx - -from . import _exceptions -from ._qs import Querystring -from ._types import ( - NOT_GIVEN, - Omit, - Timeout, - NotGiven, - Transport, - ProxiesTypes, - RequestOptions, -) -from ._utils import is_given, get_async_library -from ._version import __version__ -from .resources import app, tui, file, find, event, config, session -from ._streaming import Stream as Stream, AsyncStream as AsyncStream -from ._exceptions import APIStatusError -from ._base_client import ( - DEFAULT_MAX_RETRIES, - SyncAPIClient, - AsyncAPIClient, -) - -__all__ = [ - "Timeout", - "Transport", - "ProxiesTypes", - "RequestOptions", - "Opencode", - "AsyncOpencode", - "Client", - "AsyncClient", -] - - -class Opencode(SyncAPIClient): - event: event.EventResource - app: app.AppResource - find: find.FindResource - file: file.FileResource - config: config.ConfigResource - session: session.SessionResource - tui: tui.TuiResource - with_raw_response: OpencodeWithRawResponse - with_streaming_response: OpencodeWithStreamedResponse - - # client options - - def __init__( - self, - *, - base_url: str | httpx.URL | None = None, - timeout: Union[float, Timeout, None, NotGiven] = NOT_GIVEN, - max_retries: int = DEFAULT_MAX_RETRIES, - default_headers: Mapping[str, str] | None = None, - default_query: Mapping[str, object] | None = None, - # Configure a custom httpx client. - # We provide a `DefaultHttpxClient` class that you can pass to retain the default values we use for `limits`, `timeout` & `follow_redirects`. - # See the [httpx documentation](https://www.python-httpx.org/api/#client) for more details. - http_client: httpx.Client | None = None, - # Enable or disable schema validation for data returned by the API. - # When enabled an error APIResponseValidationError is raised - # if the API responds with invalid data for the expected schema. - # - # This parameter may be removed or changed in the future. - # If you rely on this feature, please open a GitHub issue - # outlining your use-case to help us decide if it should be - # part of our public interface in the future. - _strict_response_validation: bool = False, - ) -> None: - """Construct a new synchronous Opencode client instance.""" - if base_url is None: - base_url = os.environ.get("OPENCODE_BASE_URL") - if base_url is None: - base_url = f"http://localhost:54321" - - super().__init__( - version=__version__, - base_url=base_url, - max_retries=max_retries, - timeout=timeout, - http_client=http_client, - custom_headers=default_headers, - custom_query=default_query, - _strict_response_validation=_strict_response_validation, - ) - - self._default_stream_cls = Stream - - self.event = event.EventResource(self) - self.app = app.AppResource(self) - self.find = find.FindResource(self) - self.file = file.FileResource(self) - self.config = config.ConfigResource(self) - self.session = session.SessionResource(self) - self.tui = tui.TuiResource(self) - self.with_raw_response = OpencodeWithRawResponse(self) - self.with_streaming_response = OpencodeWithStreamedResponse(self) - - @property - @override - def qs(self) -> Querystring: - return Querystring(array_format="comma") - - @property - @override - def default_headers(self) -> dict[str, str | Omit]: - return { - **super().default_headers, - "X-Stainless-Async": "false", - **self._custom_headers, - } - - def copy( - self, - *, - base_url: str | httpx.URL | None = None, - timeout: float | Timeout | None | NotGiven = NOT_GIVEN, - http_client: httpx.Client | None = None, - max_retries: int | NotGiven = NOT_GIVEN, - default_headers: Mapping[str, str] | None = None, - set_default_headers: Mapping[str, str] | None = None, - default_query: Mapping[str, object] | None = None, - set_default_query: Mapping[str, object] | None = None, - _extra_kwargs: Mapping[str, Any] = {}, - ) -> Self: - """ - Create a new client instance re-using the same options given to the current client with optional overriding. - """ - if default_headers is not None and set_default_headers is not None: - raise ValueError("The `default_headers` and `set_default_headers` arguments are mutually exclusive") - - if default_query is not None and set_default_query is not None: - raise ValueError("The `default_query` and `set_default_query` arguments are mutually exclusive") - - headers = self._custom_headers - if default_headers is not None: - headers = {**headers, **default_headers} - elif set_default_headers is not None: - headers = set_default_headers - - params = self._custom_query - if default_query is not None: - params = {**params, **default_query} - elif set_default_query is not None: - params = set_default_query - - http_client = http_client or self._client - return self.__class__( - base_url=base_url or self.base_url, - timeout=self.timeout if isinstance(timeout, NotGiven) else timeout, - http_client=http_client, - max_retries=max_retries if is_given(max_retries) else self.max_retries, - default_headers=headers, - default_query=params, - **_extra_kwargs, - ) - - # Alias for `copy` for nicer inline usage, e.g. - # client.with_options(timeout=10).foo.create(...) - with_options = copy - - @override - def _make_status_error( - self, - err_msg: str, - *, - body: object, - response: httpx.Response, - ) -> APIStatusError: - if response.status_code == 400: - return _exceptions.BadRequestError(err_msg, response=response, body=body) - - if response.status_code == 401: - return _exceptions.AuthenticationError(err_msg, response=response, body=body) - - if response.status_code == 403: - return _exceptions.PermissionDeniedError(err_msg, response=response, body=body) - - if response.status_code == 404: - return _exceptions.NotFoundError(err_msg, response=response, body=body) - - if response.status_code == 409: - return _exceptions.ConflictError(err_msg, response=response, body=body) - - if response.status_code == 422: - return _exceptions.UnprocessableEntityError(err_msg, response=response, body=body) - - if response.status_code == 429: - return _exceptions.RateLimitError(err_msg, response=response, body=body) - - if response.status_code >= 500: - return _exceptions.InternalServerError(err_msg, response=response, body=body) - return APIStatusError(err_msg, response=response, body=body) - - -class AsyncOpencode(AsyncAPIClient): - event: event.AsyncEventResource - app: app.AsyncAppResource - find: find.AsyncFindResource - file: file.AsyncFileResource - config: config.AsyncConfigResource - session: session.AsyncSessionResource - tui: tui.AsyncTuiResource - with_raw_response: AsyncOpencodeWithRawResponse - with_streaming_response: AsyncOpencodeWithStreamedResponse - - # client options - - def __init__( - self, - *, - base_url: str | httpx.URL | None = None, - timeout: Union[float, Timeout, None, NotGiven] = NOT_GIVEN, - max_retries: int = DEFAULT_MAX_RETRIES, - default_headers: Mapping[str, str] | None = None, - default_query: Mapping[str, object] | None = None, - # Configure a custom httpx client. - # We provide a `DefaultAsyncHttpxClient` class that you can pass to retain the default values we use for `limits`, `timeout` & `follow_redirects`. - # See the [httpx documentation](https://www.python-httpx.org/api/#asyncclient) for more details. - http_client: httpx.AsyncClient | None = None, - # Enable or disable schema validation for data returned by the API. - # When enabled an error APIResponseValidationError is raised - # if the API responds with invalid data for the expected schema. - # - # This parameter may be removed or changed in the future. - # If you rely on this feature, please open a GitHub issue - # outlining your use-case to help us decide if it should be - # part of our public interface in the future. - _strict_response_validation: bool = False, - ) -> None: - """Construct a new async AsyncOpencode client instance.""" - if base_url is None: - base_url = os.environ.get("OPENCODE_BASE_URL") - if base_url is None: - base_url = f"http://localhost:54321" - - super().__init__( - version=__version__, - base_url=base_url, - max_retries=max_retries, - timeout=timeout, - http_client=http_client, - custom_headers=default_headers, - custom_query=default_query, - _strict_response_validation=_strict_response_validation, - ) - - self._default_stream_cls = AsyncStream - - self.event = event.AsyncEventResource(self) - self.app = app.AsyncAppResource(self) - self.find = find.AsyncFindResource(self) - self.file = file.AsyncFileResource(self) - self.config = config.AsyncConfigResource(self) - self.session = session.AsyncSessionResource(self) - self.tui = tui.AsyncTuiResource(self) - self.with_raw_response = AsyncOpencodeWithRawResponse(self) - self.with_streaming_response = AsyncOpencodeWithStreamedResponse(self) - - @property - @override - def qs(self) -> Querystring: - return Querystring(array_format="comma") - - @property - @override - def default_headers(self) -> dict[str, str | Omit]: - return { - **super().default_headers, - "X-Stainless-Async": f"async:{get_async_library()}", - **self._custom_headers, - } - - def copy( - self, - *, - base_url: str | httpx.URL | None = None, - timeout: float | Timeout | None | NotGiven = NOT_GIVEN, - http_client: httpx.AsyncClient | None = None, - max_retries: int | NotGiven = NOT_GIVEN, - default_headers: Mapping[str, str] | None = None, - set_default_headers: Mapping[str, str] | None = None, - default_query: Mapping[str, object] | None = None, - set_default_query: Mapping[str, object] | None = None, - _extra_kwargs: Mapping[str, Any] = {}, - ) -> Self: - """ - Create a new client instance re-using the same options given to the current client with optional overriding. - """ - if default_headers is not None and set_default_headers is not None: - raise ValueError("The `default_headers` and `set_default_headers` arguments are mutually exclusive") - - if default_query is not None and set_default_query is not None: - raise ValueError("The `default_query` and `set_default_query` arguments are mutually exclusive") - - headers = self._custom_headers - if default_headers is not None: - headers = {**headers, **default_headers} - elif set_default_headers is not None: - headers = set_default_headers - - params = self._custom_query - if default_query is not None: - params = {**params, **default_query} - elif set_default_query is not None: - params = set_default_query - - http_client = http_client or self._client - return self.__class__( - base_url=base_url or self.base_url, - timeout=self.timeout if isinstance(timeout, NotGiven) else timeout, - http_client=http_client, - max_retries=max_retries if is_given(max_retries) else self.max_retries, - default_headers=headers, - default_query=params, - **_extra_kwargs, - ) - - # Alias for `copy` for nicer inline usage, e.g. - # client.with_options(timeout=10).foo.create(...) - with_options = copy - - @override - def _make_status_error( - self, - err_msg: str, - *, - body: object, - response: httpx.Response, - ) -> APIStatusError: - if response.status_code == 400: - return _exceptions.BadRequestError(err_msg, response=response, body=body) - - if response.status_code == 401: - return _exceptions.AuthenticationError(err_msg, response=response, body=body) - - if response.status_code == 403: - return _exceptions.PermissionDeniedError(err_msg, response=response, body=body) - - if response.status_code == 404: - return _exceptions.NotFoundError(err_msg, response=response, body=body) - - if response.status_code == 409: - return _exceptions.ConflictError(err_msg, response=response, body=body) - - if response.status_code == 422: - return _exceptions.UnprocessableEntityError(err_msg, response=response, body=body) - - if response.status_code == 429: - return _exceptions.RateLimitError(err_msg, response=response, body=body) - - if response.status_code >= 500: - return _exceptions.InternalServerError(err_msg, response=response, body=body) - return APIStatusError(err_msg, response=response, body=body) - - -class OpencodeWithRawResponse: - def __init__(self, client: Opencode) -> None: - self.event = event.EventResourceWithRawResponse(client.event) - self.app = app.AppResourceWithRawResponse(client.app) - self.find = find.FindResourceWithRawResponse(client.find) - self.file = file.FileResourceWithRawResponse(client.file) - self.config = config.ConfigResourceWithRawResponse(client.config) - self.session = session.SessionResourceWithRawResponse(client.session) - self.tui = tui.TuiResourceWithRawResponse(client.tui) - - -class AsyncOpencodeWithRawResponse: - def __init__(self, client: AsyncOpencode) -> None: - self.event = event.AsyncEventResourceWithRawResponse(client.event) - self.app = app.AsyncAppResourceWithRawResponse(client.app) - self.find = find.AsyncFindResourceWithRawResponse(client.find) - self.file = file.AsyncFileResourceWithRawResponse(client.file) - self.config = config.AsyncConfigResourceWithRawResponse(client.config) - self.session = session.AsyncSessionResourceWithRawResponse(client.session) - self.tui = tui.AsyncTuiResourceWithRawResponse(client.tui) - - -class OpencodeWithStreamedResponse: - def __init__(self, client: Opencode) -> None: - self.event = event.EventResourceWithStreamingResponse(client.event) - self.app = app.AppResourceWithStreamingResponse(client.app) - self.find = find.FindResourceWithStreamingResponse(client.find) - self.file = file.FileResourceWithStreamingResponse(client.file) - self.config = config.ConfigResourceWithStreamingResponse(client.config) - self.session = session.SessionResourceWithStreamingResponse(client.session) - self.tui = tui.TuiResourceWithStreamingResponse(client.tui) - - -class AsyncOpencodeWithStreamedResponse: - def __init__(self, client: AsyncOpencode) -> None: - self.event = event.AsyncEventResourceWithStreamingResponse(client.event) - self.app = app.AsyncAppResourceWithStreamingResponse(client.app) - self.find = find.AsyncFindResourceWithStreamingResponse(client.find) - self.file = file.AsyncFileResourceWithStreamingResponse(client.file) - self.config = config.AsyncConfigResourceWithStreamingResponse(client.config) - self.session = session.AsyncSessionResourceWithStreamingResponse(client.session) - self.tui = tui.AsyncTuiResourceWithStreamingResponse(client.tui) - - -Client = Opencode - -AsyncClient = AsyncOpencode diff --git a/src/opencode_ai/_compat.py b/src/opencode_ai/_compat.py deleted file mode 100644 index 92d9ee6..0000000 --- a/src/opencode_ai/_compat.py +++ /dev/null @@ -1,219 +0,0 @@ -from __future__ import annotations - -from typing import TYPE_CHECKING, Any, Union, Generic, TypeVar, Callable, cast, overload -from datetime import date, datetime -from typing_extensions import Self, Literal - -import pydantic -from pydantic.fields import FieldInfo - -from ._types import IncEx, StrBytesIntFloat - -_T = TypeVar("_T") -_ModelT = TypeVar("_ModelT", bound=pydantic.BaseModel) - -# --------------- Pydantic v2 compatibility --------------- - -# Pyright incorrectly reports some of our functions as overriding a method when they don't -# pyright: reportIncompatibleMethodOverride=false - -PYDANTIC_V2 = pydantic.VERSION.startswith("2.") - -# v1 re-exports -if TYPE_CHECKING: - - def parse_date(value: date | StrBytesIntFloat) -> date: # noqa: ARG001 - ... - - def parse_datetime(value: Union[datetime, StrBytesIntFloat]) -> datetime: # noqa: ARG001 - ... - - def get_args(t: type[Any]) -> tuple[Any, ...]: # noqa: ARG001 - ... - - def is_union(tp: type[Any] | None) -> bool: # noqa: ARG001 - ... - - def get_origin(t: type[Any]) -> type[Any] | None: # noqa: ARG001 - ... - - def is_literal_type(type_: type[Any]) -> bool: # noqa: ARG001 - ... - - def is_typeddict(type_: type[Any]) -> bool: # noqa: ARG001 - ... - -else: - if PYDANTIC_V2: - from pydantic.v1.typing import ( - get_args as get_args, - is_union as is_union, - get_origin as get_origin, - is_typeddict as is_typeddict, - is_literal_type as is_literal_type, - ) - from pydantic.v1.datetime_parse import parse_date as parse_date, parse_datetime as parse_datetime - else: - from pydantic.typing import ( - get_args as get_args, - is_union as is_union, - get_origin as get_origin, - is_typeddict as is_typeddict, - is_literal_type as is_literal_type, - ) - from pydantic.datetime_parse import parse_date as parse_date, parse_datetime as parse_datetime - - -# refactored config -if TYPE_CHECKING: - from pydantic import ConfigDict as ConfigDict -else: - if PYDANTIC_V2: - from pydantic import ConfigDict - else: - # TODO: provide an error message here? - ConfigDict = None - - -# renamed methods / properties -def parse_obj(model: type[_ModelT], value: object) -> _ModelT: - if PYDANTIC_V2: - return model.model_validate(value) - else: - return cast(_ModelT, model.parse_obj(value)) # pyright: ignore[reportDeprecated, reportUnnecessaryCast] - - -def field_is_required(field: FieldInfo) -> bool: - if PYDANTIC_V2: - return field.is_required() - return field.required # type: ignore - - -def field_get_default(field: FieldInfo) -> Any: - value = field.get_default() - if PYDANTIC_V2: - from pydantic_core import PydanticUndefined - - if value == PydanticUndefined: - return None - return value - return value - - -def field_outer_type(field: FieldInfo) -> Any: - if PYDANTIC_V2: - return field.annotation - return field.outer_type_ # type: ignore - - -def get_model_config(model: type[pydantic.BaseModel]) -> Any: - if PYDANTIC_V2: - return model.model_config - return model.__config__ # type: ignore - - -def get_model_fields(model: type[pydantic.BaseModel]) -> dict[str, FieldInfo]: - if PYDANTIC_V2: - return model.model_fields - return model.__fields__ # type: ignore - - -def model_copy(model: _ModelT, *, deep: bool = False) -> _ModelT: - if PYDANTIC_V2: - return model.model_copy(deep=deep) - return model.copy(deep=deep) # type: ignore - - -def model_json(model: pydantic.BaseModel, *, indent: int | None = None) -> str: - if PYDANTIC_V2: - return model.model_dump_json(indent=indent) - return model.json(indent=indent) # type: ignore - - -def model_dump( - model: pydantic.BaseModel, - *, - exclude: IncEx | None = None, - exclude_unset: bool = False, - exclude_defaults: bool = False, - warnings: bool = True, - mode: Literal["json", "python"] = "python", -) -> dict[str, Any]: - if PYDANTIC_V2 or hasattr(model, "model_dump"): - return model.model_dump( - mode=mode, - exclude=exclude, - exclude_unset=exclude_unset, - exclude_defaults=exclude_defaults, - # warnings are not supported in Pydantic v1 - warnings=warnings if PYDANTIC_V2 else True, - ) - return cast( - "dict[str, Any]", - model.dict( # pyright: ignore[reportDeprecated, reportUnnecessaryCast] - exclude=exclude, - exclude_unset=exclude_unset, - exclude_defaults=exclude_defaults, - ), - ) - - -def model_parse(model: type[_ModelT], data: Any) -> _ModelT: - if PYDANTIC_V2: - return model.model_validate(data) - return model.parse_obj(data) # pyright: ignore[reportDeprecated] - - -# generic models -if TYPE_CHECKING: - - class GenericModel(pydantic.BaseModel): ... - -else: - if PYDANTIC_V2: - # there no longer needs to be a distinction in v2 but - # we still have to create our own subclass to avoid - # inconsistent MRO ordering errors - class GenericModel(pydantic.BaseModel): ... - - else: - import pydantic.generics - - class GenericModel(pydantic.generics.GenericModel, pydantic.BaseModel): ... - - -# cached properties -if TYPE_CHECKING: - cached_property = property - - # we define a separate type (copied from typeshed) - # that represents that `cached_property` is `set`able - # at runtime, which differs from `@property`. - # - # this is a separate type as editors likely special case - # `@property` and we don't want to cause issues just to have - # more helpful internal types. - - class typed_cached_property(Generic[_T]): - func: Callable[[Any], _T] - attrname: str | None - - def __init__(self, func: Callable[[Any], _T]) -> None: ... - - @overload - def __get__(self, instance: None, owner: type[Any] | None = None) -> Self: ... - - @overload - def __get__(self, instance: object, owner: type[Any] | None = None) -> _T: ... - - def __get__(self, instance: object, owner: type[Any] | None = None) -> _T | Self: - raise NotImplementedError() - - def __set_name__(self, owner: type[Any], name: str) -> None: ... - - # __set__ is not defined at runtime, but @cached_property is designed to be settable - def __set__(self, instance: object, value: _T) -> None: ... -else: - from functools import cached_property as cached_property - - typed_cached_property = cached_property diff --git a/src/opencode_ai/_constants.py b/src/opencode_ai/_constants.py deleted file mode 100644 index 6ddf2c7..0000000 --- a/src/opencode_ai/_constants.py +++ /dev/null @@ -1,14 +0,0 @@ -# File generated from our OpenAPI spec by Stainless. See CONTRIBUTING.md for details. - -import httpx - -RAW_RESPONSE_HEADER = "X-Stainless-Raw-Response" -OVERRIDE_CAST_TO_HEADER = "____stainless_override_cast_to" - -# default timeout is 1 minute -DEFAULT_TIMEOUT = httpx.Timeout(timeout=60, connect=5.0) -DEFAULT_MAX_RETRIES = 2 -DEFAULT_CONNECTION_LIMITS = httpx.Limits(max_connections=100, max_keepalive_connections=20) - -INITIAL_RETRY_DELAY = 0.5 -MAX_RETRY_DELAY = 8.0 diff --git a/src/opencode_ai/_exceptions.py b/src/opencode_ai/_exceptions.py deleted file mode 100644 index 54376ee..0000000 --- a/src/opencode_ai/_exceptions.py +++ /dev/null @@ -1,108 +0,0 @@ -# File generated from our OpenAPI spec by Stainless. See CONTRIBUTING.md for details. - -from __future__ import annotations - -from typing_extensions import Literal - -import httpx - -__all__ = [ - "BadRequestError", - "AuthenticationError", - "PermissionDeniedError", - "NotFoundError", - "ConflictError", - "UnprocessableEntityError", - "RateLimitError", - "InternalServerError", -] - - -class OpencodeError(Exception): - pass - - -class APIError(OpencodeError): - message: str - request: httpx.Request - - body: object | None - """The API response body. - - If the API responded with a valid JSON structure then this property will be the - decoded result. - - If it isn't a valid JSON structure then this will be the raw response. - - If there was no response associated with this error then it will be `None`. - """ - - def __init__(self, message: str, request: httpx.Request, *, body: object | None) -> None: # noqa: ARG002 - super().__init__(message) - self.request = request - self.message = message - self.body = body - - -class APIResponseValidationError(APIError): - response: httpx.Response - status_code: int - - def __init__(self, response: httpx.Response, body: object | None, *, message: str | None = None) -> None: - super().__init__(message or "Data returned by API invalid for expected schema.", response.request, body=body) - self.response = response - self.status_code = response.status_code - - -class APIStatusError(APIError): - """Raised when an API response has a status code of 4xx or 5xx.""" - - response: httpx.Response - status_code: int - - def __init__(self, message: str, *, response: httpx.Response, body: object | None) -> None: - super().__init__(message, response.request, body=body) - self.response = response - self.status_code = response.status_code - - -class APIConnectionError(APIError): - def __init__(self, *, message: str = "Connection error.", request: httpx.Request) -> None: - super().__init__(message, request, body=None) - - -class APITimeoutError(APIConnectionError): - def __init__(self, request: httpx.Request) -> None: - super().__init__(message="Request timed out.", request=request) - - -class BadRequestError(APIStatusError): - status_code: Literal[400] = 400 # pyright: ignore[reportIncompatibleVariableOverride] - - -class AuthenticationError(APIStatusError): - status_code: Literal[401] = 401 # pyright: ignore[reportIncompatibleVariableOverride] - - -class PermissionDeniedError(APIStatusError): - status_code: Literal[403] = 403 # pyright: ignore[reportIncompatibleVariableOverride] - - -class NotFoundError(APIStatusError): - status_code: Literal[404] = 404 # pyright: ignore[reportIncompatibleVariableOverride] - - -class ConflictError(APIStatusError): - status_code: Literal[409] = 409 # pyright: ignore[reportIncompatibleVariableOverride] - - -class UnprocessableEntityError(APIStatusError): - status_code: Literal[422] = 422 # pyright: ignore[reportIncompatibleVariableOverride] - - -class RateLimitError(APIStatusError): - status_code: Literal[429] = 429 # pyright: ignore[reportIncompatibleVariableOverride] - - -class InternalServerError(APIStatusError): - pass diff --git a/src/opencode_ai/_files.py b/src/opencode_ai/_files.py deleted file mode 100644 index cc14c14..0000000 --- a/src/opencode_ai/_files.py +++ /dev/null @@ -1,123 +0,0 @@ -from __future__ import annotations - -import io -import os -import pathlib -from typing import overload -from typing_extensions import TypeGuard - -import anyio - -from ._types import ( - FileTypes, - FileContent, - RequestFiles, - HttpxFileTypes, - Base64FileInput, - HttpxFileContent, - HttpxRequestFiles, -) -from ._utils import is_tuple_t, is_mapping_t, is_sequence_t - - -def is_base64_file_input(obj: object) -> TypeGuard[Base64FileInput]: - return isinstance(obj, io.IOBase) or isinstance(obj, os.PathLike) - - -def is_file_content(obj: object) -> TypeGuard[FileContent]: - return ( - isinstance(obj, bytes) or isinstance(obj, tuple) or isinstance(obj, io.IOBase) or isinstance(obj, os.PathLike) - ) - - -def assert_is_file_content(obj: object, *, key: str | None = None) -> None: - if not is_file_content(obj): - prefix = f"Expected entry at `{key}`" if key is not None else f"Expected file input `{obj!r}`" - raise RuntimeError( - f"{prefix} to be bytes, an io.IOBase instance, PathLike or a tuple but received {type(obj)} instead." - ) from None - - -@overload -def to_httpx_files(files: None) -> None: ... - - -@overload -def to_httpx_files(files: RequestFiles) -> HttpxRequestFiles: ... - - -def to_httpx_files(files: RequestFiles | None) -> HttpxRequestFiles | None: - if files is None: - return None - - if is_mapping_t(files): - files = {key: _transform_file(file) for key, file in files.items()} - elif is_sequence_t(files): - files = [(key, _transform_file(file)) for key, file in files] - else: - raise TypeError(f"Unexpected file type input {type(files)}, expected mapping or sequence") - - return files - - -def _transform_file(file: FileTypes) -> HttpxFileTypes: - if is_file_content(file): - if isinstance(file, os.PathLike): - path = pathlib.Path(file) - return (path.name, path.read_bytes()) - - return file - - if is_tuple_t(file): - return (file[0], read_file_content(file[1]), *file[2:]) - - raise TypeError(f"Expected file types input to be a FileContent type or to be a tuple") - - -def read_file_content(file: FileContent) -> HttpxFileContent: - if isinstance(file, os.PathLike): - return pathlib.Path(file).read_bytes() - return file - - -@overload -async def async_to_httpx_files(files: None) -> None: ... - - -@overload -async def async_to_httpx_files(files: RequestFiles) -> HttpxRequestFiles: ... - - -async def async_to_httpx_files(files: RequestFiles | None) -> HttpxRequestFiles | None: - if files is None: - return None - - if is_mapping_t(files): - files = {key: await _async_transform_file(file) for key, file in files.items()} - elif is_sequence_t(files): - files = [(key, await _async_transform_file(file)) for key, file in files] - else: - raise TypeError("Unexpected file type input {type(files)}, expected mapping or sequence") - - return files - - -async def _async_transform_file(file: FileTypes) -> HttpxFileTypes: - if is_file_content(file): - if isinstance(file, os.PathLike): - path = anyio.Path(file) - return (path.name, await path.read_bytes()) - - return file - - if is_tuple_t(file): - return (file[0], await async_read_file_content(file[1]), *file[2:]) - - raise TypeError(f"Expected file types input to be a FileContent type or to be a tuple") - - -async def async_read_file_content(file: FileContent) -> HttpxFileContent: - if isinstance(file, os.PathLike): - return await anyio.Path(file).read_bytes() - - return file diff --git a/src/opencode_ai/_models.py b/src/opencode_ai/_models.py deleted file mode 100644 index 92f7c10..0000000 --- a/src/opencode_ai/_models.py +++ /dev/null @@ -1,829 +0,0 @@ -from __future__ import annotations - -import os -import inspect -from typing import TYPE_CHECKING, Any, Type, Union, Generic, TypeVar, Callable, Optional, cast -from datetime import date, datetime -from typing_extensions import ( - List, - Unpack, - Literal, - ClassVar, - Protocol, - Required, - ParamSpec, - TypedDict, - TypeGuard, - final, - override, - runtime_checkable, -) - -import pydantic -from pydantic.fields import FieldInfo - -from ._types import ( - Body, - IncEx, - Query, - ModelT, - Headers, - Timeout, - NotGiven, - AnyMapping, - HttpxRequestFiles, -) -from ._utils import ( - PropertyInfo, - is_list, - is_given, - json_safe, - lru_cache, - is_mapping, - parse_date, - coerce_boolean, - parse_datetime, - strip_not_given, - extract_type_arg, - is_annotated_type, - is_type_alias_type, - strip_annotated_type, -) -from ._compat import ( - PYDANTIC_V2, - ConfigDict, - GenericModel as BaseGenericModel, - get_args, - is_union, - parse_obj, - get_origin, - is_literal_type, - get_model_config, - get_model_fields, - field_get_default, -) -from ._constants import RAW_RESPONSE_HEADER - -if TYPE_CHECKING: - from pydantic_core.core_schema import ModelField, ModelSchema, LiteralSchema, ModelFieldsSchema - -__all__ = ["BaseModel", "GenericModel"] - -_T = TypeVar("_T") -_BaseModelT = TypeVar("_BaseModelT", bound="BaseModel") - -P = ParamSpec("P") - - -@runtime_checkable -class _ConfigProtocol(Protocol): - allow_population_by_field_name: bool - - -class BaseModel(pydantic.BaseModel): - if PYDANTIC_V2: - model_config: ClassVar[ConfigDict] = ConfigDict( - extra="allow", defer_build=coerce_boolean(os.environ.get("DEFER_PYDANTIC_BUILD", "true")) - ) - else: - - @property - @override - def model_fields_set(self) -> set[str]: - # a forwards-compat shim for pydantic v2 - return self.__fields_set__ # type: ignore - - class Config(pydantic.BaseConfig): # pyright: ignore[reportDeprecated] - extra: Any = pydantic.Extra.allow # type: ignore - - def to_dict( - self, - *, - mode: Literal["json", "python"] = "python", - use_api_names: bool = True, - exclude_unset: bool = True, - exclude_defaults: bool = False, - exclude_none: bool = False, - warnings: bool = True, - ) -> dict[str, object]: - """Recursively generate a dictionary representation of the model, optionally specifying which fields to include or exclude. - - By default, fields that were not set by the API will not be included, - and keys will match the API response, *not* the property names from the model. - - For example, if the API responds with `"fooBar": true` but we've defined a `foo_bar: bool` property, - the output will use the `"fooBar"` key (unless `use_api_names=False` is passed). - - Args: - mode: - If mode is 'json', the dictionary will only contain JSON serializable types. e.g. `datetime` will be turned into a string, `"2024-3-22T18:11:19.117000Z"`. - If mode is 'python', the dictionary may contain any Python objects. e.g. `datetime(2024, 3, 22)` - - use_api_names: Whether to use the key that the API responded with or the property name. Defaults to `True`. - exclude_unset: Whether to exclude fields that have not been explicitly set. - exclude_defaults: Whether to exclude fields that are set to their default value from the output. - exclude_none: Whether to exclude fields that have a value of `None` from the output. - warnings: Whether to log warnings when invalid fields are encountered. This is only supported in Pydantic v2. - """ - return self.model_dump( - mode=mode, - by_alias=use_api_names, - exclude_unset=exclude_unset, - exclude_defaults=exclude_defaults, - exclude_none=exclude_none, - warnings=warnings, - ) - - def to_json( - self, - *, - indent: int | None = 2, - use_api_names: bool = True, - exclude_unset: bool = True, - exclude_defaults: bool = False, - exclude_none: bool = False, - warnings: bool = True, - ) -> str: - """Generates a JSON string representing this model as it would be received from or sent to the API (but with indentation). - - By default, fields that were not set by the API will not be included, - and keys will match the API response, *not* the property names from the model. - - For example, if the API responds with `"fooBar": true` but we've defined a `foo_bar: bool` property, - the output will use the `"fooBar"` key (unless `use_api_names=False` is passed). - - Args: - indent: Indentation to use in the JSON output. If `None` is passed, the output will be compact. Defaults to `2` - use_api_names: Whether to use the key that the API responded with or the property name. Defaults to `True`. - exclude_unset: Whether to exclude fields that have not been explicitly set. - exclude_defaults: Whether to exclude fields that have the default value. - exclude_none: Whether to exclude fields that have a value of `None`. - warnings: Whether to show any warnings that occurred during serialization. This is only supported in Pydantic v2. - """ - return self.model_dump_json( - indent=indent, - by_alias=use_api_names, - exclude_unset=exclude_unset, - exclude_defaults=exclude_defaults, - exclude_none=exclude_none, - warnings=warnings, - ) - - @override - def __str__(self) -> str: - # mypy complains about an invalid self arg - return f"{self.__repr_name__()}({self.__repr_str__(', ')})" # type: ignore[misc] - - # Override the 'construct' method in a way that supports recursive parsing without validation. - # Based on https://github.com/samuelcolvin/pydantic/issues/1168#issuecomment-817742836. - @classmethod - @override - def construct( # pyright: ignore[reportIncompatibleMethodOverride] - __cls: Type[ModelT], - _fields_set: set[str] | None = None, - **values: object, - ) -> ModelT: - m = __cls.__new__(__cls) - fields_values: dict[str, object] = {} - - config = get_model_config(__cls) - populate_by_name = ( - config.allow_population_by_field_name - if isinstance(config, _ConfigProtocol) - else config.get("populate_by_name") - ) - - if _fields_set is None: - _fields_set = set() - - model_fields = get_model_fields(__cls) - for name, field in model_fields.items(): - key = field.alias - if key is None or (key not in values and populate_by_name): - key = name - - if key in values: - fields_values[name] = _construct_field(value=values[key], field=field, key=key) - _fields_set.add(name) - else: - fields_values[name] = field_get_default(field) - - extra_field_type = _get_extra_fields_type(__cls) - - _extra = {} - for key, value in values.items(): - if key not in model_fields: - parsed = construct_type(value=value, type_=extra_field_type) if extra_field_type is not None else value - - if PYDANTIC_V2: - _extra[key] = parsed - else: - _fields_set.add(key) - fields_values[key] = parsed - - object.__setattr__(m, "__dict__", fields_values) - - if PYDANTIC_V2: - # these properties are copied from Pydantic's `model_construct()` method - object.__setattr__(m, "__pydantic_private__", None) - object.__setattr__(m, "__pydantic_extra__", _extra) - object.__setattr__(m, "__pydantic_fields_set__", _fields_set) - else: - # init_private_attributes() does not exist in v2 - m._init_private_attributes() # type: ignore - - # copied from Pydantic v1's `construct()` method - object.__setattr__(m, "__fields_set__", _fields_set) - - return m - - if not TYPE_CHECKING: - # type checkers incorrectly complain about this assignment - # because the type signatures are technically different - # although not in practice - model_construct = construct - - if not PYDANTIC_V2: - # we define aliases for some of the new pydantic v2 methods so - # that we can just document these methods without having to specify - # a specific pydantic version as some users may not know which - # pydantic version they are currently using - - @override - def model_dump( - self, - *, - mode: Literal["json", "python"] | str = "python", - include: IncEx | None = None, - exclude: IncEx | None = None, - by_alias: bool = False, - exclude_unset: bool = False, - exclude_defaults: bool = False, - exclude_none: bool = False, - round_trip: bool = False, - warnings: bool | Literal["none", "warn", "error"] = True, - context: dict[str, Any] | None = None, - serialize_as_any: bool = False, - ) -> dict[str, Any]: - """Usage docs: https://docs.pydantic.dev/2.4/concepts/serialization/#modelmodel_dump - - Generate a dictionary representation of the model, optionally specifying which fields to include or exclude. - - Args: - mode: The mode in which `to_python` should run. - If mode is 'json', the dictionary will only contain JSON serializable types. - If mode is 'python', the dictionary may contain any Python objects. - include: A list of fields to include in the output. - exclude: A list of fields to exclude from the output. - by_alias: Whether to use the field's alias in the dictionary key if defined. - exclude_unset: Whether to exclude fields that are unset or None from the output. - exclude_defaults: Whether to exclude fields that are set to their default value from the output. - exclude_none: Whether to exclude fields that have a value of `None` from the output. - round_trip: Whether to enable serialization and deserialization round-trip support. - warnings: Whether to log warnings when invalid fields are encountered. - - Returns: - A dictionary representation of the model. - """ - if mode not in {"json", "python"}: - raise ValueError("mode must be either 'json' or 'python'") - if round_trip != False: - raise ValueError("round_trip is only supported in Pydantic v2") - if warnings != True: - raise ValueError("warnings is only supported in Pydantic v2") - if context is not None: - raise ValueError("context is only supported in Pydantic v2") - if serialize_as_any != False: - raise ValueError("serialize_as_any is only supported in Pydantic v2") - dumped = super().dict( # pyright: ignore[reportDeprecated] - include=include, - exclude=exclude, - by_alias=by_alias, - exclude_unset=exclude_unset, - exclude_defaults=exclude_defaults, - exclude_none=exclude_none, - ) - - return cast("dict[str, Any]", json_safe(dumped)) if mode == "json" else dumped - - @override - def model_dump_json( - self, - *, - indent: int | None = None, - include: IncEx | None = None, - exclude: IncEx | None = None, - by_alias: bool = False, - exclude_unset: bool = False, - exclude_defaults: bool = False, - exclude_none: bool = False, - round_trip: bool = False, - warnings: bool | Literal["none", "warn", "error"] = True, - context: dict[str, Any] | None = None, - serialize_as_any: bool = False, - ) -> str: - """Usage docs: https://docs.pydantic.dev/2.4/concepts/serialization/#modelmodel_dump_json - - Generates a JSON representation of the model using Pydantic's `to_json` method. - - Args: - indent: Indentation to use in the JSON output. If None is passed, the output will be compact. - include: Field(s) to include in the JSON output. Can take either a string or set of strings. - exclude: Field(s) to exclude from the JSON output. Can take either a string or set of strings. - by_alias: Whether to serialize using field aliases. - exclude_unset: Whether to exclude fields that have not been explicitly set. - exclude_defaults: Whether to exclude fields that have the default value. - exclude_none: Whether to exclude fields that have a value of `None`. - round_trip: Whether to use serialization/deserialization between JSON and class instance. - warnings: Whether to show any warnings that occurred during serialization. - - Returns: - A JSON string representation of the model. - """ - if round_trip != False: - raise ValueError("round_trip is only supported in Pydantic v2") - if warnings != True: - raise ValueError("warnings is only supported in Pydantic v2") - if context is not None: - raise ValueError("context is only supported in Pydantic v2") - if serialize_as_any != False: - raise ValueError("serialize_as_any is only supported in Pydantic v2") - return super().json( # type: ignore[reportDeprecated] - indent=indent, - include=include, - exclude=exclude, - by_alias=by_alias, - exclude_unset=exclude_unset, - exclude_defaults=exclude_defaults, - exclude_none=exclude_none, - ) - - -def _construct_field(value: object, field: FieldInfo, key: str) -> object: - if value is None: - return field_get_default(field) - - if PYDANTIC_V2: - type_ = field.annotation - else: - type_ = cast(type, field.outer_type_) # type: ignore - - if type_ is None: - raise RuntimeError(f"Unexpected field type is None for {key}") - - return construct_type(value=value, type_=type_, metadata=getattr(field, "metadata", None)) - - -def _get_extra_fields_type(cls: type[pydantic.BaseModel]) -> type | None: - if not PYDANTIC_V2: - # TODO - return None - - schema = cls.__pydantic_core_schema__ - if schema["type"] == "model": - fields = schema["schema"] - if fields["type"] == "model-fields": - extras = fields.get("extras_schema") - if extras and "cls" in extras: - # mypy can't narrow the type - return extras["cls"] # type: ignore[no-any-return] - - return None - - -def is_basemodel(type_: type) -> bool: - """Returns whether or not the given type is either a `BaseModel` or a union of `BaseModel`""" - if is_union(type_): - for variant in get_args(type_): - if is_basemodel(variant): - return True - - return False - - return is_basemodel_type(type_) - - -def is_basemodel_type(type_: type) -> TypeGuard[type[BaseModel] | type[GenericModel]]: - origin = get_origin(type_) or type_ - if not inspect.isclass(origin): - return False - return issubclass(origin, BaseModel) or issubclass(origin, GenericModel) - - -def build( - base_model_cls: Callable[P, _BaseModelT], - *args: P.args, - **kwargs: P.kwargs, -) -> _BaseModelT: - """Construct a BaseModel class without validation. - - This is useful for cases where you need to instantiate a `BaseModel` - from an API response as this provides type-safe params which isn't supported - by helpers like `construct_type()`. - - ```py - build(MyModel, my_field_a="foo", my_field_b=123) - ``` - """ - if args: - raise TypeError( - "Received positional arguments which are not supported; Keyword arguments must be used instead", - ) - - return cast(_BaseModelT, construct_type(type_=base_model_cls, value=kwargs)) - - -def construct_type_unchecked(*, value: object, type_: type[_T]) -> _T: - """Loose coercion to the expected type with construction of nested values. - - Note: the returned value from this function is not guaranteed to match the - given type. - """ - return cast(_T, construct_type(value=value, type_=type_)) - - -def construct_type(*, value: object, type_: object, metadata: Optional[List[Any]] = None) -> object: - """Loose coercion to the expected type with construction of nested values. - - If the given value does not match the expected type then it is returned as-is. - """ - - # store a reference to the original type we were given before we extract any inner - # types so that we can properly resolve forward references in `TypeAliasType` annotations - original_type = None - - # we allow `object` as the input type because otherwise, passing things like - # `Literal['value']` will be reported as a type error by type checkers - type_ = cast("type[object]", type_) - if is_type_alias_type(type_): - original_type = type_ # type: ignore[unreachable] - type_ = type_.__value__ # type: ignore[unreachable] - - # unwrap `Annotated[T, ...]` -> `T` - if metadata is not None and len(metadata) > 0: - meta: tuple[Any, ...] = tuple(metadata) - elif is_annotated_type(type_): - meta = get_args(type_)[1:] - type_ = extract_type_arg(type_, 0) - else: - meta = tuple() - - # we need to use the origin class for any types that are subscripted generics - # e.g. Dict[str, object] - origin = get_origin(type_) or type_ - args = get_args(type_) - - if is_union(origin): - try: - return validate_type(type_=cast("type[object]", original_type or type_), value=value) - except Exception: - pass - - # if the type is a discriminated union then we want to construct the right variant - # in the union, even if the data doesn't match exactly, otherwise we'd break code - # that relies on the constructed class types, e.g. - # - # class FooType: - # kind: Literal['foo'] - # value: str - # - # class BarType: - # kind: Literal['bar'] - # value: int - # - # without this block, if the data we get is something like `{'kind': 'bar', 'value': 'foo'}` then - # we'd end up constructing `FooType` when it should be `BarType`. - discriminator = _build_discriminated_union_meta(union=type_, meta_annotations=meta) - if discriminator and is_mapping(value): - variant_value = value.get(discriminator.field_alias_from or discriminator.field_name) - if variant_value and isinstance(variant_value, str): - variant_type = discriminator.mapping.get(variant_value) - if variant_type: - return construct_type(type_=variant_type, value=value) - - # if the data is not valid, use the first variant that doesn't fail while deserializing - for variant in args: - try: - return construct_type(value=value, type_=variant) - except Exception: - continue - - raise RuntimeError(f"Could not convert data into a valid instance of {type_}") - - if origin == dict: - if not is_mapping(value): - return value - - _, items_type = get_args(type_) # Dict[_, items_type] - return {key: construct_type(value=item, type_=items_type) for key, item in value.items()} - - if ( - not is_literal_type(type_) - and inspect.isclass(origin) - and (issubclass(origin, BaseModel) or issubclass(origin, GenericModel)) - ): - if is_list(value): - return [cast(Any, type_).construct(**entry) if is_mapping(entry) else entry for entry in value] - - if is_mapping(value): - if issubclass(type_, BaseModel): - return type_.construct(**value) # type: ignore[arg-type] - - return cast(Any, type_).construct(**value) - - if origin == list: - if not is_list(value): - return value - - inner_type = args[0] # List[inner_type] - return [construct_type(value=entry, type_=inner_type) for entry in value] - - if origin == float: - if isinstance(value, int): - coerced = float(value) - if coerced != value: - return value - return coerced - - return value - - if type_ == datetime: - try: - return parse_datetime(value) # type: ignore - except Exception: - return value - - if type_ == date: - try: - return parse_date(value) # type: ignore - except Exception: - return value - - return value - - -@runtime_checkable -class CachedDiscriminatorType(Protocol): - __discriminator__: DiscriminatorDetails - - -class DiscriminatorDetails: - field_name: str - """The name of the discriminator field in the variant class, e.g. - - ```py - class Foo(BaseModel): - type: Literal['foo'] - ``` - - Will result in field_name='type' - """ - - field_alias_from: str | None - """The name of the discriminator field in the API response, e.g. - - ```py - class Foo(BaseModel): - type: Literal['foo'] = Field(alias='type_from_api') - ``` - - Will result in field_alias_from='type_from_api' - """ - - mapping: dict[str, type] - """Mapping of discriminator value to variant type, e.g. - - {'foo': FooVariant, 'bar': BarVariant} - """ - - def __init__( - self, - *, - mapping: dict[str, type], - discriminator_field: str, - discriminator_alias: str | None, - ) -> None: - self.mapping = mapping - self.field_name = discriminator_field - self.field_alias_from = discriminator_alias - - -def _build_discriminated_union_meta(*, union: type, meta_annotations: tuple[Any, ...]) -> DiscriminatorDetails | None: - if isinstance(union, CachedDiscriminatorType): - return union.__discriminator__ - - discriminator_field_name: str | None = None - - for annotation in meta_annotations: - if isinstance(annotation, PropertyInfo) and annotation.discriminator is not None: - discriminator_field_name = annotation.discriminator - break - - if not discriminator_field_name: - return None - - mapping: dict[str, type] = {} - discriminator_alias: str | None = None - - for variant in get_args(union): - variant = strip_annotated_type(variant) - if is_basemodel_type(variant): - if PYDANTIC_V2: - field = _extract_field_schema_pv2(variant, discriminator_field_name) - if not field: - continue - - # Note: if one variant defines an alias then they all should - discriminator_alias = field.get("serialization_alias") - - field_schema = field["schema"] - - if field_schema["type"] == "literal": - for entry in cast("LiteralSchema", field_schema)["expected"]: - if isinstance(entry, str): - mapping[entry] = variant - else: - field_info = cast("dict[str, FieldInfo]", variant.__fields__).get(discriminator_field_name) # pyright: ignore[reportDeprecated, reportUnnecessaryCast] - if not field_info: - continue - - # Note: if one variant defines an alias then they all should - discriminator_alias = field_info.alias - - if (annotation := getattr(field_info, "annotation", None)) and is_literal_type(annotation): - for entry in get_args(annotation): - if isinstance(entry, str): - mapping[entry] = variant - - if not mapping: - return None - - details = DiscriminatorDetails( - mapping=mapping, - discriminator_field=discriminator_field_name, - discriminator_alias=discriminator_alias, - ) - cast(CachedDiscriminatorType, union).__discriminator__ = details - return details - - -def _extract_field_schema_pv2(model: type[BaseModel], field_name: str) -> ModelField | None: - schema = model.__pydantic_core_schema__ - if schema["type"] == "definitions": - schema = schema["schema"] - - if schema["type"] != "model": - return None - - schema = cast("ModelSchema", schema) - fields_schema = schema["schema"] - if fields_schema["type"] != "model-fields": - return None - - fields_schema = cast("ModelFieldsSchema", fields_schema) - field = fields_schema["fields"].get(field_name) - if not field: - return None - - return cast("ModelField", field) # pyright: ignore[reportUnnecessaryCast] - - -def validate_type(*, type_: type[_T], value: object) -> _T: - """Strict validation that the given value matches the expected type""" - if inspect.isclass(type_) and issubclass(type_, pydantic.BaseModel): - return cast(_T, parse_obj(type_, value)) - - return cast(_T, _validate_non_model_type(type_=type_, value=value)) - - -def set_pydantic_config(typ: Any, config: pydantic.ConfigDict) -> None: - """Add a pydantic config for the given type. - - Note: this is a no-op on Pydantic v1. - """ - setattr(typ, "__pydantic_config__", config) # noqa: B010 - - -# our use of subclassing here causes weirdness for type checkers, -# so we just pretend that we don't subclass -if TYPE_CHECKING: - GenericModel = BaseModel -else: - - class GenericModel(BaseGenericModel, BaseModel): - pass - - -if PYDANTIC_V2: - from pydantic import TypeAdapter as _TypeAdapter - - _CachedTypeAdapter = cast("TypeAdapter[object]", lru_cache(maxsize=None)(_TypeAdapter)) - - if TYPE_CHECKING: - from pydantic import TypeAdapter - else: - TypeAdapter = _CachedTypeAdapter - - def _validate_non_model_type(*, type_: type[_T], value: object) -> _T: - return TypeAdapter(type_).validate_python(value) - -elif not TYPE_CHECKING: # TODO: condition is weird - - class RootModel(GenericModel, Generic[_T]): - """Used as a placeholder to easily convert runtime types to a Pydantic format - to provide validation. - - For example: - ```py - validated = RootModel[int](__root__="5").__root__ - # validated: 5 - ``` - """ - - __root__: _T - - def _validate_non_model_type(*, type_: type[_T], value: object) -> _T: - model = _create_pydantic_model(type_).validate(value) - return cast(_T, model.__root__) - - def _create_pydantic_model(type_: _T) -> Type[RootModel[_T]]: - return RootModel[type_] # type: ignore - - -class FinalRequestOptionsInput(TypedDict, total=False): - method: Required[str] - url: Required[str] - params: Query - headers: Headers - max_retries: int - timeout: float | Timeout | None - files: HttpxRequestFiles | None - idempotency_key: str - json_data: Body - extra_json: AnyMapping - follow_redirects: bool - - -@final -class FinalRequestOptions(pydantic.BaseModel): - method: str - url: str - params: Query = {} - headers: Union[Headers, NotGiven] = NotGiven() - max_retries: Union[int, NotGiven] = NotGiven() - timeout: Union[float, Timeout, None, NotGiven] = NotGiven() - files: Union[HttpxRequestFiles, None] = None - idempotency_key: Union[str, None] = None - post_parser: Union[Callable[[Any], Any], NotGiven] = NotGiven() - follow_redirects: Union[bool, None] = None - - # It should be noted that we cannot use `json` here as that would override - # a BaseModel method in an incompatible fashion. - json_data: Union[Body, None] = None - extra_json: Union[AnyMapping, None] = None - - if PYDANTIC_V2: - model_config: ClassVar[ConfigDict] = ConfigDict(arbitrary_types_allowed=True) - else: - - class Config(pydantic.BaseConfig): # pyright: ignore[reportDeprecated] - arbitrary_types_allowed: bool = True - - def get_max_retries(self, max_retries: int) -> int: - if isinstance(self.max_retries, NotGiven): - return max_retries - return self.max_retries - - def _strip_raw_response_header(self) -> None: - if not is_given(self.headers): - return - - if self.headers.get(RAW_RESPONSE_HEADER): - self.headers = {**self.headers} - self.headers.pop(RAW_RESPONSE_HEADER) - - # override the `construct` method so that we can run custom transformations. - # this is necessary as we don't want to do any actual runtime type checking - # (which means we can't use validators) but we do want to ensure that `NotGiven` - # values are not present - # - # type ignore required because we're adding explicit types to `**values` - @classmethod - def construct( # type: ignore - cls, - _fields_set: set[str] | None = None, - **values: Unpack[FinalRequestOptionsInput], - ) -> FinalRequestOptions: - kwargs: dict[str, Any] = { - # we unconditionally call `strip_not_given` on any value - # as it will just ignore any non-mapping types - key: strip_not_given(value) - for key, value in values.items() - } - if PYDANTIC_V2: - return super().model_construct(_fields_set, **kwargs) - return cast(FinalRequestOptions, super().construct(_fields_set, **kwargs)) # pyright: ignore[reportDeprecated] - - if not TYPE_CHECKING: - # type checkers incorrectly complain about this assignment - model_construct = construct diff --git a/src/opencode_ai/_qs.py b/src/opencode_ai/_qs.py deleted file mode 100644 index 274320c..0000000 --- a/src/opencode_ai/_qs.py +++ /dev/null @@ -1,150 +0,0 @@ -from __future__ import annotations - -from typing import Any, List, Tuple, Union, Mapping, TypeVar -from urllib.parse import parse_qs, urlencode -from typing_extensions import Literal, get_args - -from ._types import NOT_GIVEN, NotGiven, NotGivenOr -from ._utils import flatten - -_T = TypeVar("_T") - - -ArrayFormat = Literal["comma", "repeat", "indices", "brackets"] -NestedFormat = Literal["dots", "brackets"] - -PrimitiveData = Union[str, int, float, bool, None] -# this should be Data = Union[PrimitiveData, "List[Data]", "Tuple[Data]", "Mapping[str, Data]"] -# https://github.com/microsoft/pyright/issues/3555 -Data = Union[PrimitiveData, List[Any], Tuple[Any], "Mapping[str, Any]"] -Params = Mapping[str, Data] - - -class Querystring: - array_format: ArrayFormat - nested_format: NestedFormat - - def __init__( - self, - *, - array_format: ArrayFormat = "repeat", - nested_format: NestedFormat = "brackets", - ) -> None: - self.array_format = array_format - self.nested_format = nested_format - - def parse(self, query: str) -> Mapping[str, object]: - # Note: custom format syntax is not supported yet - return parse_qs(query) - - def stringify( - self, - params: Params, - *, - array_format: NotGivenOr[ArrayFormat] = NOT_GIVEN, - nested_format: NotGivenOr[NestedFormat] = NOT_GIVEN, - ) -> str: - return urlencode( - self.stringify_items( - params, - array_format=array_format, - nested_format=nested_format, - ) - ) - - def stringify_items( - self, - params: Params, - *, - array_format: NotGivenOr[ArrayFormat] = NOT_GIVEN, - nested_format: NotGivenOr[NestedFormat] = NOT_GIVEN, - ) -> list[tuple[str, str]]: - opts = Options( - qs=self, - array_format=array_format, - nested_format=nested_format, - ) - return flatten([self._stringify_item(key, value, opts) for key, value in params.items()]) - - def _stringify_item( - self, - key: str, - value: Data, - opts: Options, - ) -> list[tuple[str, str]]: - if isinstance(value, Mapping): - items: list[tuple[str, str]] = [] - nested_format = opts.nested_format - for subkey, subvalue in value.items(): - items.extend( - self._stringify_item( - # TODO: error if unknown format - f"{key}.{subkey}" if nested_format == "dots" else f"{key}[{subkey}]", - subvalue, - opts, - ) - ) - return items - - if isinstance(value, (list, tuple)): - array_format = opts.array_format - if array_format == "comma": - return [ - ( - key, - ",".join(self._primitive_value_to_str(item) for item in value if item is not None), - ), - ] - elif array_format == "repeat": - items = [] - for item in value: - items.extend(self._stringify_item(key, item, opts)) - return items - elif array_format == "indices": - raise NotImplementedError("The array indices format is not supported yet") - elif array_format == "brackets": - items = [] - key = key + "[]" - for item in value: - items.extend(self._stringify_item(key, item, opts)) - return items - else: - raise NotImplementedError( - f"Unknown array_format value: {array_format}, choose from {', '.join(get_args(ArrayFormat))}" - ) - - serialised = self._primitive_value_to_str(value) - if not serialised: - return [] - return [(key, serialised)] - - def _primitive_value_to_str(self, value: PrimitiveData) -> str: - # copied from httpx - if value is True: - return "true" - elif value is False: - return "false" - elif value is None: - return "" - return str(value) - - -_qs = Querystring() -parse = _qs.parse -stringify = _qs.stringify -stringify_items = _qs.stringify_items - - -class Options: - array_format: ArrayFormat - nested_format: NestedFormat - - def __init__( - self, - qs: Querystring = _qs, - *, - array_format: NotGivenOr[ArrayFormat] = NOT_GIVEN, - nested_format: NotGivenOr[NestedFormat] = NOT_GIVEN, - ) -> None: - self.array_format = qs.array_format if isinstance(array_format, NotGiven) else array_format - self.nested_format = qs.nested_format if isinstance(nested_format, NotGiven) else nested_format diff --git a/src/opencode_ai/_resource.py b/src/opencode_ai/_resource.py deleted file mode 100644 index 80802c9..0000000 --- a/src/opencode_ai/_resource.py +++ /dev/null @@ -1,43 +0,0 @@ -# File generated from our OpenAPI spec by Stainless. See CONTRIBUTING.md for details. - -from __future__ import annotations - -import time -from typing import TYPE_CHECKING - -import anyio - -if TYPE_CHECKING: - from ._client import Opencode, AsyncOpencode - - -class SyncAPIResource: - _client: Opencode - - def __init__(self, client: Opencode) -> None: - self._client = client - self._get = client.get - self._post = client.post - self._patch = client.patch - self._put = client.put - self._delete = client.delete - self._get_api_list = client.get_api_list - - def _sleep(self, seconds: float) -> None: - time.sleep(seconds) - - -class AsyncAPIResource: - _client: AsyncOpencode - - def __init__(self, client: AsyncOpencode) -> None: - self._client = client - self._get = client.get - self._post = client.post - self._patch = client.patch - self._put = client.put - self._delete = client.delete - self._get_api_list = client.get_api_list - - async def _sleep(self, seconds: float) -> None: - await anyio.sleep(seconds) diff --git a/src/opencode_ai/_response.py b/src/opencode_ai/_response.py deleted file mode 100644 index 203be5d..0000000 --- a/src/opencode_ai/_response.py +++ /dev/null @@ -1,832 +0,0 @@ -from __future__ import annotations - -import os -import inspect -import logging -import datetime -import functools -from types import TracebackType -from typing import ( - TYPE_CHECKING, - Any, - Union, - Generic, - TypeVar, - Callable, - Iterator, - AsyncIterator, - cast, - overload, -) -from typing_extensions import Awaitable, ParamSpec, override, get_origin - -import anyio -import httpx -import pydantic - -from ._types import NoneType -from ._utils import is_given, extract_type_arg, is_annotated_type, is_type_alias_type, extract_type_var_from_base -from ._models import BaseModel, is_basemodel -from ._constants import RAW_RESPONSE_HEADER, OVERRIDE_CAST_TO_HEADER -from ._streaming import Stream, AsyncStream, is_stream_class_type, extract_stream_chunk_type -from ._exceptions import OpencodeError, APIResponseValidationError - -if TYPE_CHECKING: - from ._models import FinalRequestOptions - from ._base_client import BaseClient - - -P = ParamSpec("P") -R = TypeVar("R") -_T = TypeVar("_T") -_APIResponseT = TypeVar("_APIResponseT", bound="APIResponse[Any]") -_AsyncAPIResponseT = TypeVar("_AsyncAPIResponseT", bound="AsyncAPIResponse[Any]") - -log: logging.Logger = logging.getLogger(__name__) - - -class BaseAPIResponse(Generic[R]): - _cast_to: type[R] - _client: BaseClient[Any, Any] - _parsed_by_type: dict[type[Any], Any] - _is_sse_stream: bool - _stream_cls: type[Stream[Any]] | type[AsyncStream[Any]] | None - _options: FinalRequestOptions - - http_response: httpx.Response - - retries_taken: int - """The number of retries made. If no retries happened this will be `0`""" - - def __init__( - self, - *, - raw: httpx.Response, - cast_to: type[R], - client: BaseClient[Any, Any], - stream: bool, - stream_cls: type[Stream[Any]] | type[AsyncStream[Any]] | None, - options: FinalRequestOptions, - retries_taken: int = 0, - ) -> None: - self._cast_to = cast_to - self._client = client - self._parsed_by_type = {} - self._is_sse_stream = stream - self._stream_cls = stream_cls - self._options = options - self.http_response = raw - self.retries_taken = retries_taken - - @property - def headers(self) -> httpx.Headers: - return self.http_response.headers - - @property - def http_request(self) -> httpx.Request: - """Returns the httpx Request instance associated with the current response.""" - return self.http_response.request - - @property - def status_code(self) -> int: - return self.http_response.status_code - - @property - def url(self) -> httpx.URL: - """Returns the URL for which the request was made.""" - return self.http_response.url - - @property - def method(self) -> str: - return self.http_request.method - - @property - def http_version(self) -> str: - return self.http_response.http_version - - @property - def elapsed(self) -> datetime.timedelta: - """The time taken for the complete request/response cycle to complete.""" - return self.http_response.elapsed - - @property - def is_closed(self) -> bool: - """Whether or not the response body has been closed. - - If this is False then there is response data that has not been read yet. - You must either fully consume the response body or call `.close()` - before discarding the response to prevent resource leaks. - """ - return self.http_response.is_closed - - @override - def __repr__(self) -> str: - return ( - f"<{self.__class__.__name__} [{self.status_code} {self.http_response.reason_phrase}] type={self._cast_to}>" - ) - - def _parse(self, *, to: type[_T] | None = None) -> R | _T: - cast_to = to if to is not None else self._cast_to - - # unwrap `TypeAlias('Name', T)` -> `T` - if is_type_alias_type(cast_to): - cast_to = cast_to.__value__ # type: ignore[unreachable] - - # unwrap `Annotated[T, ...]` -> `T` - if cast_to and is_annotated_type(cast_to): - cast_to = extract_type_arg(cast_to, 0) - - origin = get_origin(cast_to) or cast_to - - if self._is_sse_stream: - if to: - if not is_stream_class_type(to): - raise TypeError(f"Expected custom parse type to be a subclass of {Stream} or {AsyncStream}") - - return cast( - _T, - to( - cast_to=extract_stream_chunk_type( - to, - failure_message="Expected custom stream type to be passed with a type argument, e.g. Stream[ChunkType]", - ), - response=self.http_response, - client=cast(Any, self._client), - ), - ) - - if self._stream_cls: - return cast( - R, - self._stream_cls( - cast_to=extract_stream_chunk_type(self._stream_cls), - response=self.http_response, - client=cast(Any, self._client), - ), - ) - - stream_cls = cast("type[Stream[Any]] | type[AsyncStream[Any]] | None", self._client._default_stream_cls) - if stream_cls is None: - raise MissingStreamClassError() - - return cast( - R, - stream_cls( - cast_to=cast_to, - response=self.http_response, - client=cast(Any, self._client), - ), - ) - - if cast_to is NoneType: - return cast(R, None) - - response = self.http_response - if cast_to == str: - return cast(R, response.text) - - if cast_to == bytes: - return cast(R, response.content) - - if cast_to == int: - return cast(R, int(response.text)) - - if cast_to == float: - return cast(R, float(response.text)) - - if cast_to == bool: - return cast(R, response.text.lower() == "true") - - if origin == APIResponse: - raise RuntimeError("Unexpected state - cast_to is `APIResponse`") - - if inspect.isclass(origin) and issubclass(origin, httpx.Response): - # Because of the invariance of our ResponseT TypeVar, users can subclass httpx.Response - # and pass that class to our request functions. We cannot change the variance to be either - # covariant or contravariant as that makes our usage of ResponseT illegal. We could construct - # the response class ourselves but that is something that should be supported directly in httpx - # as it would be easy to incorrectly construct the Response object due to the multitude of arguments. - if cast_to != httpx.Response: - raise ValueError(f"Subclasses of httpx.Response cannot be passed to `cast_to`") - return cast(R, response) - - if ( - inspect.isclass( - origin # pyright: ignore[reportUnknownArgumentType] - ) - and not issubclass(origin, BaseModel) - and issubclass(origin, pydantic.BaseModel) - ): - raise TypeError( - "Pydantic models must subclass our base model type, e.g. `from opencode_ai import BaseModel`" - ) - - if ( - cast_to is not object - and not origin is list - and not origin is dict - and not origin is Union - and not issubclass(origin, BaseModel) - ): - raise RuntimeError( - f"Unsupported type, expected {cast_to} to be a subclass of {BaseModel}, {dict}, {list}, {Union}, {NoneType}, {str} or {httpx.Response}." - ) - - # split is required to handle cases where additional information is included - # in the response, e.g. application/json; charset=utf-8 - content_type, *_ = response.headers.get("content-type", "*").split(";") - if not content_type.endswith("json"): - if is_basemodel(cast_to): - try: - data = response.json() - except Exception as exc: - log.debug("Could not read JSON from response data due to %s - %s", type(exc), exc) - else: - return self._client._process_response_data( - data=data, - cast_to=cast_to, # type: ignore - response=response, - ) - - if self._client._strict_response_validation: - raise APIResponseValidationError( - response=response, - message=f"Expected Content-Type response header to be `application/json` but received `{content_type}` instead.", - body=response.text, - ) - - # If the API responds with content that isn't JSON then we just return - # the (decoded) text without performing any parsing so that you can still - # handle the response however you need to. - return response.text # type: ignore - - data = response.json() - - return self._client._process_response_data( - data=data, - cast_to=cast_to, # type: ignore - response=response, - ) - - -class APIResponse(BaseAPIResponse[R]): - @overload - def parse(self, *, to: type[_T]) -> _T: ... - - @overload - def parse(self) -> R: ... - - def parse(self, *, to: type[_T] | None = None) -> R | _T: - """Returns the rich python representation of this response's data. - - For lower-level control, see `.read()`, `.json()`, `.iter_bytes()`. - - You can customise the type that the response is parsed into through - the `to` argument, e.g. - - ```py - from opencode_ai import BaseModel - - - class MyModel(BaseModel): - foo: str - - - obj = response.parse(to=MyModel) - print(obj.foo) - ``` - - We support parsing: - - `BaseModel` - - `dict` - - `list` - - `Union` - - `str` - - `int` - - `float` - - `httpx.Response` - """ - cache_key = to if to is not None else self._cast_to - cached = self._parsed_by_type.get(cache_key) - if cached is not None: - return cached # type: ignore[no-any-return] - - if not self._is_sse_stream: - self.read() - - parsed = self._parse(to=to) - if is_given(self._options.post_parser): - parsed = self._options.post_parser(parsed) - - self._parsed_by_type[cache_key] = parsed - return parsed - - def read(self) -> bytes: - """Read and return the binary response content.""" - try: - return self.http_response.read() - except httpx.StreamConsumed as exc: - # The default error raised by httpx isn't very - # helpful in our case so we re-raise it with - # a different error message. - raise StreamAlreadyConsumed() from exc - - def text(self) -> str: - """Read and decode the response content into a string.""" - self.read() - return self.http_response.text - - def json(self) -> object: - """Read and decode the JSON response content.""" - self.read() - return self.http_response.json() - - def close(self) -> None: - """Close the response and release the connection. - - Automatically called if the response body is read to completion. - """ - self.http_response.close() - - def iter_bytes(self, chunk_size: int | None = None) -> Iterator[bytes]: - """ - A byte-iterator over the decoded response content. - - This automatically handles gzip, deflate and brotli encoded responses. - """ - for chunk in self.http_response.iter_bytes(chunk_size): - yield chunk - - def iter_text(self, chunk_size: int | None = None) -> Iterator[str]: - """A str-iterator over the decoded response content - that handles both gzip, deflate, etc but also detects the content's - string encoding. - """ - for chunk in self.http_response.iter_text(chunk_size): - yield chunk - - def iter_lines(self) -> Iterator[str]: - """Like `iter_text()` but will only yield chunks for each line""" - for chunk in self.http_response.iter_lines(): - yield chunk - - -class AsyncAPIResponse(BaseAPIResponse[R]): - @overload - async def parse(self, *, to: type[_T]) -> _T: ... - - @overload - async def parse(self) -> R: ... - - async def parse(self, *, to: type[_T] | None = None) -> R | _T: - """Returns the rich python representation of this response's data. - - For lower-level control, see `.read()`, `.json()`, `.iter_bytes()`. - - You can customise the type that the response is parsed into through - the `to` argument, e.g. - - ```py - from opencode_ai import BaseModel - - - class MyModel(BaseModel): - foo: str - - - obj = response.parse(to=MyModel) - print(obj.foo) - ``` - - We support parsing: - - `BaseModel` - - `dict` - - `list` - - `Union` - - `str` - - `httpx.Response` - """ - cache_key = to if to is not None else self._cast_to - cached = self._parsed_by_type.get(cache_key) - if cached is not None: - return cached # type: ignore[no-any-return] - - if not self._is_sse_stream: - await self.read() - - parsed = self._parse(to=to) - if is_given(self._options.post_parser): - parsed = self._options.post_parser(parsed) - - self._parsed_by_type[cache_key] = parsed - return parsed - - async def read(self) -> bytes: - """Read and return the binary response content.""" - try: - return await self.http_response.aread() - except httpx.StreamConsumed as exc: - # the default error raised by httpx isn't very - # helpful in our case so we re-raise it with - # a different error message - raise StreamAlreadyConsumed() from exc - - async def text(self) -> str: - """Read and decode the response content into a string.""" - await self.read() - return self.http_response.text - - async def json(self) -> object: - """Read and decode the JSON response content.""" - await self.read() - return self.http_response.json() - - async def close(self) -> None: - """Close the response and release the connection. - - Automatically called if the response body is read to completion. - """ - await self.http_response.aclose() - - async def iter_bytes(self, chunk_size: int | None = None) -> AsyncIterator[bytes]: - """ - A byte-iterator over the decoded response content. - - This automatically handles gzip, deflate and brotli encoded responses. - """ - async for chunk in self.http_response.aiter_bytes(chunk_size): - yield chunk - - async def iter_text(self, chunk_size: int | None = None) -> AsyncIterator[str]: - """A str-iterator over the decoded response content - that handles both gzip, deflate, etc but also detects the content's - string encoding. - """ - async for chunk in self.http_response.aiter_text(chunk_size): - yield chunk - - async def iter_lines(self) -> AsyncIterator[str]: - """Like `iter_text()` but will only yield chunks for each line""" - async for chunk in self.http_response.aiter_lines(): - yield chunk - - -class BinaryAPIResponse(APIResponse[bytes]): - """Subclass of APIResponse providing helpers for dealing with binary data. - - Note: If you want to stream the response data instead of eagerly reading it - all at once then you should use `.with_streaming_response` when making - the API request, e.g. `.with_streaming_response.get_binary_response()` - """ - - def write_to_file( - self, - file: str | os.PathLike[str], - ) -> None: - """Write the output to the given file. - - Accepts a filename or any path-like object, e.g. pathlib.Path - - Note: if you want to stream the data to the file instead of writing - all at once then you should use `.with_streaming_response` when making - the API request, e.g. `.with_streaming_response.get_binary_response()` - """ - with open(file, mode="wb") as f: - for data in self.iter_bytes(): - f.write(data) - - -class AsyncBinaryAPIResponse(AsyncAPIResponse[bytes]): - """Subclass of APIResponse providing helpers for dealing with binary data. - - Note: If you want to stream the response data instead of eagerly reading it - all at once then you should use `.with_streaming_response` when making - the API request, e.g. `.with_streaming_response.get_binary_response()` - """ - - async def write_to_file( - self, - file: str | os.PathLike[str], - ) -> None: - """Write the output to the given file. - - Accepts a filename or any path-like object, e.g. pathlib.Path - - Note: if you want to stream the data to the file instead of writing - all at once then you should use `.with_streaming_response` when making - the API request, e.g. `.with_streaming_response.get_binary_response()` - """ - path = anyio.Path(file) - async with await path.open(mode="wb") as f: - async for data in self.iter_bytes(): - await f.write(data) - - -class StreamedBinaryAPIResponse(APIResponse[bytes]): - def stream_to_file( - self, - file: str | os.PathLike[str], - *, - chunk_size: int | None = None, - ) -> None: - """Streams the output to the given file. - - Accepts a filename or any path-like object, e.g. pathlib.Path - """ - with open(file, mode="wb") as f: - for data in self.iter_bytes(chunk_size): - f.write(data) - - -class AsyncStreamedBinaryAPIResponse(AsyncAPIResponse[bytes]): - async def stream_to_file( - self, - file: str | os.PathLike[str], - *, - chunk_size: int | None = None, - ) -> None: - """Streams the output to the given file. - - Accepts a filename or any path-like object, e.g. pathlib.Path - """ - path = anyio.Path(file) - async with await path.open(mode="wb") as f: - async for data in self.iter_bytes(chunk_size): - await f.write(data) - - -class MissingStreamClassError(TypeError): - def __init__(self) -> None: - super().__init__( - "The `stream` argument was set to `True` but the `stream_cls` argument was not given. See `opencode_ai._streaming` for reference", - ) - - -class StreamAlreadyConsumed(OpencodeError): - """ - Attempted to read or stream content, but the content has already - been streamed. - - This can happen if you use a method like `.iter_lines()` and then attempt - to read th entire response body afterwards, e.g. - - ```py - response = await client.post(...) - async for line in response.iter_lines(): - ... # do something with `line` - - content = await response.read() - # ^ error - ``` - - If you want this behaviour you'll need to either manually accumulate the response - content or call `await response.read()` before iterating over the stream. - """ - - def __init__(self) -> None: - message = ( - "Attempted to read or stream some content, but the content has " - "already been streamed. " - "This could be due to attempting to stream the response " - "content more than once." - "\n\n" - "You can fix this by manually accumulating the response content while streaming " - "or by calling `.read()` before starting to stream." - ) - super().__init__(message) - - -class ResponseContextManager(Generic[_APIResponseT]): - """Context manager for ensuring that a request is not made - until it is entered and that the response will always be closed - when the context manager exits - """ - - def __init__(self, request_func: Callable[[], _APIResponseT]) -> None: - self._request_func = request_func - self.__response: _APIResponseT | None = None - - def __enter__(self) -> _APIResponseT: - self.__response = self._request_func() - return self.__response - - def __exit__( - self, - exc_type: type[BaseException] | None, - exc: BaseException | None, - exc_tb: TracebackType | None, - ) -> None: - if self.__response is not None: - self.__response.close() - - -class AsyncResponseContextManager(Generic[_AsyncAPIResponseT]): - """Context manager for ensuring that a request is not made - until it is entered and that the response will always be closed - when the context manager exits - """ - - def __init__(self, api_request: Awaitable[_AsyncAPIResponseT]) -> None: - self._api_request = api_request - self.__response: _AsyncAPIResponseT | None = None - - async def __aenter__(self) -> _AsyncAPIResponseT: - self.__response = await self._api_request - return self.__response - - async def __aexit__( - self, - exc_type: type[BaseException] | None, - exc: BaseException | None, - exc_tb: TracebackType | None, - ) -> None: - if self.__response is not None: - await self.__response.close() - - -def to_streamed_response_wrapper(func: Callable[P, R]) -> Callable[P, ResponseContextManager[APIResponse[R]]]: - """Higher order function that takes one of our bound API methods and wraps it - to support streaming and returning the raw `APIResponse` object directly. - """ - - @functools.wraps(func) - def wrapped(*args: P.args, **kwargs: P.kwargs) -> ResponseContextManager[APIResponse[R]]: - extra_headers: dict[str, str] = {**(cast(Any, kwargs.get("extra_headers")) or {})} - extra_headers[RAW_RESPONSE_HEADER] = "stream" - - kwargs["extra_headers"] = extra_headers - - make_request = functools.partial(func, *args, **kwargs) - - return ResponseContextManager(cast(Callable[[], APIResponse[R]], make_request)) - - return wrapped - - -def async_to_streamed_response_wrapper( - func: Callable[P, Awaitable[R]], -) -> Callable[P, AsyncResponseContextManager[AsyncAPIResponse[R]]]: - """Higher order function that takes one of our bound API methods and wraps it - to support streaming and returning the raw `APIResponse` object directly. - """ - - @functools.wraps(func) - def wrapped(*args: P.args, **kwargs: P.kwargs) -> AsyncResponseContextManager[AsyncAPIResponse[R]]: - extra_headers: dict[str, str] = {**(cast(Any, kwargs.get("extra_headers")) or {})} - extra_headers[RAW_RESPONSE_HEADER] = "stream" - - kwargs["extra_headers"] = extra_headers - - make_request = func(*args, **kwargs) - - return AsyncResponseContextManager(cast(Awaitable[AsyncAPIResponse[R]], make_request)) - - return wrapped - - -def to_custom_streamed_response_wrapper( - func: Callable[P, object], - response_cls: type[_APIResponseT], -) -> Callable[P, ResponseContextManager[_APIResponseT]]: - """Higher order function that takes one of our bound API methods and an `APIResponse` class - and wraps the method to support streaming and returning the given response class directly. - - Note: the given `response_cls` *must* be concrete, e.g. `class BinaryAPIResponse(APIResponse[bytes])` - """ - - @functools.wraps(func) - def wrapped(*args: P.args, **kwargs: P.kwargs) -> ResponseContextManager[_APIResponseT]: - extra_headers: dict[str, Any] = {**(cast(Any, kwargs.get("extra_headers")) or {})} - extra_headers[RAW_RESPONSE_HEADER] = "stream" - extra_headers[OVERRIDE_CAST_TO_HEADER] = response_cls - - kwargs["extra_headers"] = extra_headers - - make_request = functools.partial(func, *args, **kwargs) - - return ResponseContextManager(cast(Callable[[], _APIResponseT], make_request)) - - return wrapped - - -def async_to_custom_streamed_response_wrapper( - func: Callable[P, Awaitable[object]], - response_cls: type[_AsyncAPIResponseT], -) -> Callable[P, AsyncResponseContextManager[_AsyncAPIResponseT]]: - """Higher order function that takes one of our bound API methods and an `APIResponse` class - and wraps the method to support streaming and returning the given response class directly. - - Note: the given `response_cls` *must* be concrete, e.g. `class BinaryAPIResponse(APIResponse[bytes])` - """ - - @functools.wraps(func) - def wrapped(*args: P.args, **kwargs: P.kwargs) -> AsyncResponseContextManager[_AsyncAPIResponseT]: - extra_headers: dict[str, Any] = {**(cast(Any, kwargs.get("extra_headers")) or {})} - extra_headers[RAW_RESPONSE_HEADER] = "stream" - extra_headers[OVERRIDE_CAST_TO_HEADER] = response_cls - - kwargs["extra_headers"] = extra_headers - - make_request = func(*args, **kwargs) - - return AsyncResponseContextManager(cast(Awaitable[_AsyncAPIResponseT], make_request)) - - return wrapped - - -def to_raw_response_wrapper(func: Callable[P, R]) -> Callable[P, APIResponse[R]]: - """Higher order function that takes one of our bound API methods and wraps it - to support returning the raw `APIResponse` object directly. - """ - - @functools.wraps(func) - def wrapped(*args: P.args, **kwargs: P.kwargs) -> APIResponse[R]: - extra_headers: dict[str, str] = {**(cast(Any, kwargs.get("extra_headers")) or {})} - extra_headers[RAW_RESPONSE_HEADER] = "raw" - - kwargs["extra_headers"] = extra_headers - - return cast(APIResponse[R], func(*args, **kwargs)) - - return wrapped - - -def async_to_raw_response_wrapper(func: Callable[P, Awaitable[R]]) -> Callable[P, Awaitable[AsyncAPIResponse[R]]]: - """Higher order function that takes one of our bound API methods and wraps it - to support returning the raw `APIResponse` object directly. - """ - - @functools.wraps(func) - async def wrapped(*args: P.args, **kwargs: P.kwargs) -> AsyncAPIResponse[R]: - extra_headers: dict[str, str] = {**(cast(Any, kwargs.get("extra_headers")) or {})} - extra_headers[RAW_RESPONSE_HEADER] = "raw" - - kwargs["extra_headers"] = extra_headers - - return cast(AsyncAPIResponse[R], await func(*args, **kwargs)) - - return wrapped - - -def to_custom_raw_response_wrapper( - func: Callable[P, object], - response_cls: type[_APIResponseT], -) -> Callable[P, _APIResponseT]: - """Higher order function that takes one of our bound API methods and an `APIResponse` class - and wraps the method to support returning the given response class directly. - - Note: the given `response_cls` *must* be concrete, e.g. `class BinaryAPIResponse(APIResponse[bytes])` - """ - - @functools.wraps(func) - def wrapped(*args: P.args, **kwargs: P.kwargs) -> _APIResponseT: - extra_headers: dict[str, Any] = {**(cast(Any, kwargs.get("extra_headers")) or {})} - extra_headers[RAW_RESPONSE_HEADER] = "raw" - extra_headers[OVERRIDE_CAST_TO_HEADER] = response_cls - - kwargs["extra_headers"] = extra_headers - - return cast(_APIResponseT, func(*args, **kwargs)) - - return wrapped - - -def async_to_custom_raw_response_wrapper( - func: Callable[P, Awaitable[object]], - response_cls: type[_AsyncAPIResponseT], -) -> Callable[P, Awaitable[_AsyncAPIResponseT]]: - """Higher order function that takes one of our bound API methods and an `APIResponse` class - and wraps the method to support returning the given response class directly. - - Note: the given `response_cls` *must* be concrete, e.g. `class BinaryAPIResponse(APIResponse[bytes])` - """ - - @functools.wraps(func) - def wrapped(*args: P.args, **kwargs: P.kwargs) -> Awaitable[_AsyncAPIResponseT]: - extra_headers: dict[str, Any] = {**(cast(Any, kwargs.get("extra_headers")) or {})} - extra_headers[RAW_RESPONSE_HEADER] = "raw" - extra_headers[OVERRIDE_CAST_TO_HEADER] = response_cls - - kwargs["extra_headers"] = extra_headers - - return cast(Awaitable[_AsyncAPIResponseT], func(*args, **kwargs)) - - return wrapped - - -def extract_response_type(typ: type[BaseAPIResponse[Any]]) -> type: - """Given a type like `APIResponse[T]`, returns the generic type variable `T`. - - This also handles the case where a concrete subclass is given, e.g. - ```py - class MyResponse(APIResponse[bytes]): - ... - - extract_response_type(MyResponse) -> bytes - ``` - """ - return extract_type_var_from_base( - typ, - generic_bases=cast("tuple[type, ...]", (BaseAPIResponse, APIResponse, AsyncAPIResponse)), - index=0, - ) diff --git a/src/opencode_ai/_streaming.py b/src/opencode_ai/_streaming.py deleted file mode 100644 index 34499b5..0000000 --- a/src/opencode_ai/_streaming.py +++ /dev/null @@ -1,333 +0,0 @@ -# Note: initially copied from https://github.com/florimondmanca/httpx-sse/blob/master/src/httpx_sse/_decoders.py -from __future__ import annotations - -import json -import inspect -from types import TracebackType -from typing import TYPE_CHECKING, Any, Generic, TypeVar, Iterator, AsyncIterator, cast -from typing_extensions import Self, Protocol, TypeGuard, override, get_origin, runtime_checkable - -import httpx - -from ._utils import extract_type_var_from_base - -if TYPE_CHECKING: - from ._client import Opencode, AsyncOpencode - - -_T = TypeVar("_T") - - -class Stream(Generic[_T]): - """Provides the core interface to iterate over a synchronous stream response.""" - - response: httpx.Response - - _decoder: SSEBytesDecoder - - def __init__( - self, - *, - cast_to: type[_T], - response: httpx.Response, - client: Opencode, - ) -> None: - self.response = response - self._cast_to = cast_to - self._client = client - self._decoder = client._make_sse_decoder() - self._iterator = self.__stream__() - - def __next__(self) -> _T: - return self._iterator.__next__() - - def __iter__(self) -> Iterator[_T]: - for item in self._iterator: - yield item - - def _iter_events(self) -> Iterator[ServerSentEvent]: - yield from self._decoder.iter_bytes(self.response.iter_bytes()) - - def __stream__(self) -> Iterator[_T]: - cast_to = cast(Any, self._cast_to) - response = self.response - process_data = self._client._process_response_data - iterator = self._iter_events() - - for sse in iterator: - yield process_data(data=sse.json(), cast_to=cast_to, response=response) - - # Ensure the entire stream is consumed - for _sse in iterator: - ... - - def __enter__(self) -> Self: - return self - - def __exit__( - self, - exc_type: type[BaseException] | None, - exc: BaseException | None, - exc_tb: TracebackType | None, - ) -> None: - self.close() - - def close(self) -> None: - """ - Close the response and release the connection. - - Automatically called if the response body is read to completion. - """ - self.response.close() - - -class AsyncStream(Generic[_T]): - """Provides the core interface to iterate over an asynchronous stream response.""" - - response: httpx.Response - - _decoder: SSEDecoder | SSEBytesDecoder - - def __init__( - self, - *, - cast_to: type[_T], - response: httpx.Response, - client: AsyncOpencode, - ) -> None: - self.response = response - self._cast_to = cast_to - self._client = client - self._decoder = client._make_sse_decoder() - self._iterator = self.__stream__() - - async def __anext__(self) -> _T: - return await self._iterator.__anext__() - - async def __aiter__(self) -> AsyncIterator[_T]: - async for item in self._iterator: - yield item - - async def _iter_events(self) -> AsyncIterator[ServerSentEvent]: - async for sse in self._decoder.aiter_bytes(self.response.aiter_bytes()): - yield sse - - async def __stream__(self) -> AsyncIterator[_T]: - cast_to = cast(Any, self._cast_to) - response = self.response - process_data = self._client._process_response_data - iterator = self._iter_events() - - async for sse in iterator: - yield process_data(data=sse.json(), cast_to=cast_to, response=response) - - # Ensure the entire stream is consumed - async for _sse in iterator: - ... - - async def __aenter__(self) -> Self: - return self - - async def __aexit__( - self, - exc_type: type[BaseException] | None, - exc: BaseException | None, - exc_tb: TracebackType | None, - ) -> None: - await self.close() - - async def close(self) -> None: - """ - Close the response and release the connection. - - Automatically called if the response body is read to completion. - """ - await self.response.aclose() - - -class ServerSentEvent: - def __init__( - self, - *, - event: str | None = None, - data: str | None = None, - id: str | None = None, - retry: int | None = None, - ) -> None: - if data is None: - data = "" - - self._id = id - self._data = data - self._event = event or None - self._retry = retry - - @property - def event(self) -> str | None: - return self._event - - @property - def id(self) -> str | None: - return self._id - - @property - def retry(self) -> int | None: - return self._retry - - @property - def data(self) -> str: - return self._data - - def json(self) -> Any: - return json.loads(self.data) - - @override - def __repr__(self) -> str: - return f"ServerSentEvent(event={self.event}, data={self.data}, id={self.id}, retry={self.retry})" - - -class SSEDecoder: - _data: list[str] - _event: str | None - _retry: int | None - _last_event_id: str | None - - def __init__(self) -> None: - self._event = None - self._data = [] - self._last_event_id = None - self._retry = None - - def iter_bytes(self, iterator: Iterator[bytes]) -> Iterator[ServerSentEvent]: - """Given an iterator that yields raw binary data, iterate over it & yield every event encountered""" - for chunk in self._iter_chunks(iterator): - # Split before decoding so splitlines() only uses \r and \n - for raw_line in chunk.splitlines(): - line = raw_line.decode("utf-8") - sse = self.decode(line) - if sse: - yield sse - - def _iter_chunks(self, iterator: Iterator[bytes]) -> Iterator[bytes]: - """Given an iterator that yields raw binary data, iterate over it and yield individual SSE chunks""" - data = b"" - for chunk in iterator: - for line in chunk.splitlines(keepends=True): - data += line - if data.endswith((b"\r\r", b"\n\n", b"\r\n\r\n")): - yield data - data = b"" - if data: - yield data - - async def aiter_bytes(self, iterator: AsyncIterator[bytes]) -> AsyncIterator[ServerSentEvent]: - """Given an iterator that yields raw binary data, iterate over it & yield every event encountered""" - async for chunk in self._aiter_chunks(iterator): - # Split before decoding so splitlines() only uses \r and \n - for raw_line in chunk.splitlines(): - line = raw_line.decode("utf-8") - sse = self.decode(line) - if sse: - yield sse - - async def _aiter_chunks(self, iterator: AsyncIterator[bytes]) -> AsyncIterator[bytes]: - """Given an iterator that yields raw binary data, iterate over it and yield individual SSE chunks""" - data = b"" - async for chunk in iterator: - for line in chunk.splitlines(keepends=True): - data += line - if data.endswith((b"\r\r", b"\n\n", b"\r\n\r\n")): - yield data - data = b"" - if data: - yield data - - def decode(self, line: str) -> ServerSentEvent | None: - # See: https://html.spec.whatwg.org/multipage/server-sent-events.html#event-stream-interpretation # noqa: E501 - - if not line: - if not self._event and not self._data and not self._last_event_id and self._retry is None: - return None - - sse = ServerSentEvent( - event=self._event, - data="\n".join(self._data), - id=self._last_event_id, - retry=self._retry, - ) - - # NOTE: as per the SSE spec, do not reset last_event_id. - self._event = None - self._data = [] - self._retry = None - - return sse - - if line.startswith(":"): - return None - - fieldname, _, value = line.partition(":") - - if value.startswith(" "): - value = value[1:] - - if fieldname == "event": - self._event = value - elif fieldname == "data": - self._data.append(value) - elif fieldname == "id": - if "\0" in value: - pass - else: - self._last_event_id = value - elif fieldname == "retry": - try: - self._retry = int(value) - except (TypeError, ValueError): - pass - else: - pass # Field is ignored. - - return None - - -@runtime_checkable -class SSEBytesDecoder(Protocol): - def iter_bytes(self, iterator: Iterator[bytes]) -> Iterator[ServerSentEvent]: - """Given an iterator that yields raw binary data, iterate over it & yield every event encountered""" - ... - - def aiter_bytes(self, iterator: AsyncIterator[bytes]) -> AsyncIterator[ServerSentEvent]: - """Given an async iterator that yields raw binary data, iterate over it & yield every event encountered""" - ... - - -def is_stream_class_type(typ: type) -> TypeGuard[type[Stream[object]] | type[AsyncStream[object]]]: - """TypeGuard for determining whether or not the given type is a subclass of `Stream` / `AsyncStream`""" - origin = get_origin(typ) or typ - return inspect.isclass(origin) and issubclass(origin, (Stream, AsyncStream)) - - -def extract_stream_chunk_type( - stream_cls: type, - *, - failure_message: str | None = None, -) -> type: - """Given a type like `Stream[T]`, returns the generic type variable `T`. - - This also handles the case where a concrete subclass is given, e.g. - ```py - class MyStream(Stream[bytes]): - ... - - extract_stream_chunk_type(MyStream) -> bytes - ``` - """ - from ._base_client import Stream, AsyncStream - - return extract_type_var_from_base( - stream_cls, - index=0, - generic_bases=cast("tuple[type, ...]", (Stream, AsyncStream)), - failure_message=failure_message, - ) diff --git a/src/opencode_ai/_types.py b/src/opencode_ai/_types.py deleted file mode 100644 index e9ba7eb..0000000 --- a/src/opencode_ai/_types.py +++ /dev/null @@ -1,219 +0,0 @@ -from __future__ import annotations - -from os import PathLike -from typing import ( - IO, - TYPE_CHECKING, - Any, - Dict, - List, - Type, - Tuple, - Union, - Mapping, - TypeVar, - Callable, - Optional, - Sequence, -) -from typing_extensions import Set, Literal, Protocol, TypeAlias, TypedDict, override, runtime_checkable - -import httpx -import pydantic -from httpx import URL, Proxy, Timeout, Response, BaseTransport, AsyncBaseTransport - -if TYPE_CHECKING: - from ._models import BaseModel - from ._response import APIResponse, AsyncAPIResponse - -Transport = BaseTransport -AsyncTransport = AsyncBaseTransport -Query = Mapping[str, object] -Body = object -AnyMapping = Mapping[str, object] -ModelT = TypeVar("ModelT", bound=pydantic.BaseModel) -_T = TypeVar("_T") - - -# Approximates httpx internal ProxiesTypes and RequestFiles types -# while adding support for `PathLike` instances -ProxiesDict = Dict["str | URL", Union[None, str, URL, Proxy]] -ProxiesTypes = Union[str, Proxy, ProxiesDict] -if TYPE_CHECKING: - Base64FileInput = Union[IO[bytes], PathLike[str]] - FileContent = Union[IO[bytes], bytes, PathLike[str]] -else: - Base64FileInput = Union[IO[bytes], PathLike] - FileContent = Union[IO[bytes], bytes, PathLike] # PathLike is not subscriptable in Python 3.8. -FileTypes = Union[ - # file (or bytes) - FileContent, - # (filename, file (or bytes)) - Tuple[Optional[str], FileContent], - # (filename, file (or bytes), content_type) - Tuple[Optional[str], FileContent, Optional[str]], - # (filename, file (or bytes), content_type, headers) - Tuple[Optional[str], FileContent, Optional[str], Mapping[str, str]], -] -RequestFiles = Union[Mapping[str, FileTypes], Sequence[Tuple[str, FileTypes]]] - -# duplicate of the above but without our custom file support -HttpxFileContent = Union[IO[bytes], bytes] -HttpxFileTypes = Union[ - # file (or bytes) - HttpxFileContent, - # (filename, file (or bytes)) - Tuple[Optional[str], HttpxFileContent], - # (filename, file (or bytes), content_type) - Tuple[Optional[str], HttpxFileContent, Optional[str]], - # (filename, file (or bytes), content_type, headers) - Tuple[Optional[str], HttpxFileContent, Optional[str], Mapping[str, str]], -] -HttpxRequestFiles = Union[Mapping[str, HttpxFileTypes], Sequence[Tuple[str, HttpxFileTypes]]] - -# Workaround to support (cast_to: Type[ResponseT]) -> ResponseT -# where ResponseT includes `None`. In order to support directly -# passing `None`, overloads would have to be defined for every -# method that uses `ResponseT` which would lead to an unacceptable -# amount of code duplication and make it unreadable. See _base_client.py -# for example usage. -# -# This unfortunately means that you will either have -# to import this type and pass it explicitly: -# -# from opencode_ai import NoneType -# client.get('/foo', cast_to=NoneType) -# -# or build it yourself: -# -# client.get('/foo', cast_to=type(None)) -if TYPE_CHECKING: - NoneType: Type[None] -else: - NoneType = type(None) - - -class RequestOptions(TypedDict, total=False): - headers: Headers - max_retries: int - timeout: float | Timeout | None - params: Query - extra_json: AnyMapping - idempotency_key: str - follow_redirects: bool - - -# Sentinel class used until PEP 0661 is accepted -class NotGiven: - """ - A sentinel singleton class used to distinguish omitted keyword arguments - from those passed in with the value None (which may have different behavior). - - For example: - - ```py - def get(timeout: Union[int, NotGiven, None] = NotGiven()) -> Response: ... - - - get(timeout=1) # 1s timeout - get(timeout=None) # No timeout - get() # Default timeout behavior, which may not be statically known at the method definition. - ``` - """ - - def __bool__(self) -> Literal[False]: - return False - - @override - def __repr__(self) -> str: - return "NOT_GIVEN" - - -NotGivenOr = Union[_T, NotGiven] -NOT_GIVEN = NotGiven() - - -class Omit: - """In certain situations you need to be able to represent a case where a default value has - to be explicitly removed and `None` is not an appropriate substitute, for example: - - ```py - # as the default `Content-Type` header is `application/json` that will be sent - client.post("/upload/files", files={"file": b"my raw file content"}) - - # you can't explicitly override the header as it has to be dynamically generated - # to look something like: 'multipart/form-data; boundary=0d8382fcf5f8c3be01ca2e11002d2983' - client.post(..., headers={"Content-Type": "multipart/form-data"}) - - # instead you can remove the default `application/json` header by passing Omit - client.post(..., headers={"Content-Type": Omit()}) - ``` - """ - - def __bool__(self) -> Literal[False]: - return False - - -@runtime_checkable -class ModelBuilderProtocol(Protocol): - @classmethod - def build( - cls: type[_T], - *, - response: Response, - data: object, - ) -> _T: ... - - -Headers = Mapping[str, Union[str, Omit]] - - -class HeadersLikeProtocol(Protocol): - def get(self, __key: str) -> str | None: ... - - -HeadersLike = Union[Headers, HeadersLikeProtocol] - -ResponseT = TypeVar( - "ResponseT", - bound=Union[ - object, - str, - None, - "BaseModel", - List[Any], - Dict[str, Any], - Response, - ModelBuilderProtocol, - "APIResponse[Any]", - "AsyncAPIResponse[Any]", - ], -) - -StrBytesIntFloat = Union[str, bytes, int, float] - -# Note: copied from Pydantic -# https://github.com/pydantic/pydantic/blob/6f31f8f68ef011f84357330186f603ff295312fd/pydantic/main.py#L79 -IncEx: TypeAlias = Union[Set[int], Set[str], Mapping[int, Union["IncEx", bool]], Mapping[str, Union["IncEx", bool]]] - -PostParser = Callable[[Any], Any] - - -@runtime_checkable -class InheritsGeneric(Protocol): - """Represents a type that has inherited from `Generic` - - The `__orig_bases__` property can be used to determine the resolved - type variable for a given base class. - """ - - __orig_bases__: tuple[_GenericAlias] - - -class _GenericAlias(Protocol): - __origin__: type[object] - - -class HttpxSendArgs(TypedDict, total=False): - auth: httpx.Auth - follow_redirects: bool diff --git a/src/opencode_ai/_utils/__init__.py b/src/opencode_ai/_utils/__init__.py deleted file mode 100644 index d4fda26..0000000 --- a/src/opencode_ai/_utils/__init__.py +++ /dev/null @@ -1,57 +0,0 @@ -from ._sync import asyncify as asyncify -from ._proxy import LazyProxy as LazyProxy -from ._utils import ( - flatten as flatten, - is_dict as is_dict, - is_list as is_list, - is_given as is_given, - is_tuple as is_tuple, - json_safe as json_safe, - lru_cache as lru_cache, - is_mapping as is_mapping, - is_tuple_t as is_tuple_t, - parse_date as parse_date, - is_iterable as is_iterable, - is_sequence as is_sequence, - coerce_float as coerce_float, - is_mapping_t as is_mapping_t, - removeprefix as removeprefix, - removesuffix as removesuffix, - extract_files as extract_files, - is_sequence_t as is_sequence_t, - required_args as required_args, - coerce_boolean as coerce_boolean, - coerce_integer as coerce_integer, - file_from_path as file_from_path, - parse_datetime as parse_datetime, - strip_not_given as strip_not_given, - deepcopy_minimal as deepcopy_minimal, - get_async_library as get_async_library, - maybe_coerce_float as maybe_coerce_float, - get_required_header as get_required_header, - maybe_coerce_boolean as maybe_coerce_boolean, - maybe_coerce_integer as maybe_coerce_integer, -) -from ._typing import ( - is_list_type as is_list_type, - is_union_type as is_union_type, - extract_type_arg as extract_type_arg, - is_iterable_type as is_iterable_type, - is_required_type as is_required_type, - is_annotated_type as is_annotated_type, - is_type_alias_type as is_type_alias_type, - strip_annotated_type as strip_annotated_type, - extract_type_var_from_base as extract_type_var_from_base, -) -from ._streams import consume_sync_iterator as consume_sync_iterator, consume_async_iterator as consume_async_iterator -from ._transform import ( - PropertyInfo as PropertyInfo, - transform as transform, - async_transform as async_transform, - maybe_transform as maybe_transform, - async_maybe_transform as async_maybe_transform, -) -from ._reflection import ( - function_has_argument as function_has_argument, - assert_signatures_in_sync as assert_signatures_in_sync, -) diff --git a/src/opencode_ai/_utils/_logs.py b/src/opencode_ai/_utils/_logs.py deleted file mode 100644 index 4cbc451..0000000 --- a/src/opencode_ai/_utils/_logs.py +++ /dev/null @@ -1,25 +0,0 @@ -import os -import logging - -logger: logging.Logger = logging.getLogger("opencode_ai") -httpx_logger: logging.Logger = logging.getLogger("httpx") - - -def _basic_config() -> None: - # e.g. [2023-10-05 14:12:26 - opencode_ai._base_client:818 - DEBUG] HTTP Request: POST http://127.0.0.1:4010/foo/bar "200 OK" - logging.basicConfig( - format="[%(asctime)s - %(name)s:%(lineno)d - %(levelname)s] %(message)s", - datefmt="%Y-%m-%d %H:%M:%S", - ) - - -def setup_logging() -> None: - env = os.environ.get("OPENCODE_LOG") - if env == "debug": - _basic_config() - logger.setLevel(logging.DEBUG) - httpx_logger.setLevel(logging.DEBUG) - elif env == "info": - _basic_config() - logger.setLevel(logging.INFO) - httpx_logger.setLevel(logging.INFO) diff --git a/src/opencode_ai/_utils/_proxy.py b/src/opencode_ai/_utils/_proxy.py deleted file mode 100644 index 0f239a3..0000000 --- a/src/opencode_ai/_utils/_proxy.py +++ /dev/null @@ -1,65 +0,0 @@ -from __future__ import annotations - -from abc import ABC, abstractmethod -from typing import Generic, TypeVar, Iterable, cast -from typing_extensions import override - -T = TypeVar("T") - - -class LazyProxy(Generic[T], ABC): - """Implements data methods to pretend that an instance is another instance. - - This includes forwarding attribute access and other methods. - """ - - # Note: we have to special case proxies that themselves return proxies - # to support using a proxy as a catch-all for any random access, e.g. `proxy.foo.bar.baz` - - def __getattr__(self, attr: str) -> object: - proxied = self.__get_proxied__() - if isinstance(proxied, LazyProxy): - return proxied # pyright: ignore - return getattr(proxied, attr) - - @override - def __repr__(self) -> str: - proxied = self.__get_proxied__() - if isinstance(proxied, LazyProxy): - return proxied.__class__.__name__ - return repr(self.__get_proxied__()) - - @override - def __str__(self) -> str: - proxied = self.__get_proxied__() - if isinstance(proxied, LazyProxy): - return proxied.__class__.__name__ - return str(proxied) - - @override - def __dir__(self) -> Iterable[str]: - proxied = self.__get_proxied__() - if isinstance(proxied, LazyProxy): - return [] - return proxied.__dir__() - - @property # type: ignore - @override - def __class__(self) -> type: # pyright: ignore - try: - proxied = self.__get_proxied__() - except Exception: - return type(self) - if issubclass(type(proxied), LazyProxy): - return type(proxied) - return proxied.__class__ - - def __get_proxied__(self) -> T: - return self.__load__() - - def __as_proxied__(self) -> T: - """Helper method that returns the current proxy, typed as the loaded object""" - return cast(T, self) - - @abstractmethod - def __load__(self) -> T: ... diff --git a/src/opencode_ai/_utils/_reflection.py b/src/opencode_ai/_utils/_reflection.py deleted file mode 100644 index 89aa712..0000000 --- a/src/opencode_ai/_utils/_reflection.py +++ /dev/null @@ -1,42 +0,0 @@ -from __future__ import annotations - -import inspect -from typing import Any, Callable - - -def function_has_argument(func: Callable[..., Any], arg_name: str) -> bool: - """Returns whether or not the given function has a specific parameter""" - sig = inspect.signature(func) - return arg_name in sig.parameters - - -def assert_signatures_in_sync( - source_func: Callable[..., Any], - check_func: Callable[..., Any], - *, - exclude_params: set[str] = set(), -) -> None: - """Ensure that the signature of the second function matches the first.""" - - check_sig = inspect.signature(check_func) - source_sig = inspect.signature(source_func) - - errors: list[str] = [] - - for name, source_param in source_sig.parameters.items(): - if name in exclude_params: - continue - - custom_param = check_sig.parameters.get(name) - if not custom_param: - errors.append(f"the `{name}` param is missing") - continue - - if custom_param.annotation != source_param.annotation: - errors.append( - f"types for the `{name}` param are do not match; source={repr(source_param.annotation)} checking={repr(custom_param.annotation)}" - ) - continue - - if errors: - raise AssertionError(f"{len(errors)} errors encountered when comparing signatures:\n\n" + "\n\n".join(errors)) diff --git a/src/opencode_ai/_utils/_resources_proxy.py b/src/opencode_ai/_utils/_resources_proxy.py deleted file mode 100644 index 0e17312..0000000 --- a/src/opencode_ai/_utils/_resources_proxy.py +++ /dev/null @@ -1,24 +0,0 @@ -from __future__ import annotations - -from typing import Any -from typing_extensions import override - -from ._proxy import LazyProxy - - -class ResourcesProxy(LazyProxy[Any]): - """A proxy for the `opencode_ai.resources` module. - - This is used so that we can lazily import `opencode_ai.resources` only when - needed *and* so that users can just import `opencode_ai` and reference `opencode_ai.resources` - """ - - @override - def __load__(self) -> Any: - import importlib - - mod = importlib.import_module("opencode_ai.resources") - return mod - - -resources = ResourcesProxy().__as_proxied__() diff --git a/src/opencode_ai/_utils/_streams.py b/src/opencode_ai/_utils/_streams.py deleted file mode 100644 index f4a0208..0000000 --- a/src/opencode_ai/_utils/_streams.py +++ /dev/null @@ -1,12 +0,0 @@ -from typing import Any -from typing_extensions import Iterator, AsyncIterator - - -def consume_sync_iterator(iterator: Iterator[Any]) -> None: - for _ in iterator: - ... - - -async def consume_async_iterator(iterator: AsyncIterator[Any]) -> None: - async for _ in iterator: - ... diff --git a/src/opencode_ai/_utils/_sync.py b/src/opencode_ai/_utils/_sync.py deleted file mode 100644 index ad7ec71..0000000 --- a/src/opencode_ai/_utils/_sync.py +++ /dev/null @@ -1,86 +0,0 @@ -from __future__ import annotations - -import sys -import asyncio -import functools -import contextvars -from typing import Any, TypeVar, Callable, Awaitable -from typing_extensions import ParamSpec - -import anyio -import sniffio -import anyio.to_thread - -T_Retval = TypeVar("T_Retval") -T_ParamSpec = ParamSpec("T_ParamSpec") - - -if sys.version_info >= (3, 9): - _asyncio_to_thread = asyncio.to_thread -else: - # backport of https://docs.python.org/3/library/asyncio-task.html#asyncio.to_thread - # for Python 3.8 support - async def _asyncio_to_thread( - func: Callable[T_ParamSpec, T_Retval], /, *args: T_ParamSpec.args, **kwargs: T_ParamSpec.kwargs - ) -> Any: - """Asynchronously run function *func* in a separate thread. - - Any *args and **kwargs supplied for this function are directly passed - to *func*. Also, the current :class:`contextvars.Context` is propagated, - allowing context variables from the main thread to be accessed in the - separate thread. - - Returns a coroutine that can be awaited to get the eventual result of *func*. - """ - loop = asyncio.events.get_running_loop() - ctx = contextvars.copy_context() - func_call = functools.partial(ctx.run, func, *args, **kwargs) - return await loop.run_in_executor(None, func_call) - - -async def to_thread( - func: Callable[T_ParamSpec, T_Retval], /, *args: T_ParamSpec.args, **kwargs: T_ParamSpec.kwargs -) -> T_Retval: - if sniffio.current_async_library() == "asyncio": - return await _asyncio_to_thread(func, *args, **kwargs) - - return await anyio.to_thread.run_sync( - functools.partial(func, *args, **kwargs), - ) - - -# inspired by `asyncer`, https://github.com/tiangolo/asyncer -def asyncify(function: Callable[T_ParamSpec, T_Retval]) -> Callable[T_ParamSpec, Awaitable[T_Retval]]: - """ - Take a blocking function and create an async one that receives the same - positional and keyword arguments. For python version 3.9 and above, it uses - asyncio.to_thread to run the function in a separate thread. For python version - 3.8, it uses locally defined copy of the asyncio.to_thread function which was - introduced in python 3.9. - - Usage: - - ```python - def blocking_func(arg1, arg2, kwarg1=None): - # blocking code - return result - - - result = asyncify(blocking_function)(arg1, arg2, kwarg1=value1) - ``` - - ## Arguments - - `function`: a blocking regular callable (e.g. a function) - - ## Return - - An async function that takes the same positional and keyword arguments as the - original one, that when called runs the same original function in a thread worker - and returns the result. - """ - - async def wrapper(*args: T_ParamSpec.args, **kwargs: T_ParamSpec.kwargs) -> T_Retval: - return await to_thread(function, *args, **kwargs) - - return wrapper diff --git a/src/opencode_ai/_utils/_transform.py b/src/opencode_ai/_utils/_transform.py deleted file mode 100644 index b0cc20a..0000000 --- a/src/opencode_ai/_utils/_transform.py +++ /dev/null @@ -1,447 +0,0 @@ -from __future__ import annotations - -import io -import base64 -import pathlib -from typing import Any, Mapping, TypeVar, cast -from datetime import date, datetime -from typing_extensions import Literal, get_args, override, get_type_hints as _get_type_hints - -import anyio -import pydantic - -from ._utils import ( - is_list, - is_given, - lru_cache, - is_mapping, - is_iterable, -) -from .._files import is_base64_file_input -from ._typing import ( - is_list_type, - is_union_type, - extract_type_arg, - is_iterable_type, - is_required_type, - is_annotated_type, - strip_annotated_type, -) -from .._compat import get_origin, model_dump, is_typeddict - -_T = TypeVar("_T") - - -# TODO: support for drilling globals() and locals() -# TODO: ensure works correctly with forward references in all cases - - -PropertyFormat = Literal["iso8601", "base64", "custom"] - - -class PropertyInfo: - """Metadata class to be used in Annotated types to provide information about a given type. - - For example: - - class MyParams(TypedDict): - account_holder_name: Annotated[str, PropertyInfo(alias='accountHolderName')] - - This means that {'account_holder_name': 'Robert'} will be transformed to {'accountHolderName': 'Robert'} before being sent to the API. - """ - - alias: str | None - format: PropertyFormat | None - format_template: str | None - discriminator: str | None - - def __init__( - self, - *, - alias: str | None = None, - format: PropertyFormat | None = None, - format_template: str | None = None, - discriminator: str | None = None, - ) -> None: - self.alias = alias - self.format = format - self.format_template = format_template - self.discriminator = discriminator - - @override - def __repr__(self) -> str: - return f"{self.__class__.__name__}(alias='{self.alias}', format={self.format}, format_template='{self.format_template}', discriminator='{self.discriminator}')" - - -def maybe_transform( - data: object, - expected_type: object, -) -> Any | None: - """Wrapper over `transform()` that allows `None` to be passed. - - See `transform()` for more details. - """ - if data is None: - return None - return transform(data, expected_type) - - -# Wrapper over _transform_recursive providing fake types -def transform( - data: _T, - expected_type: object, -) -> _T: - """Transform dictionaries based off of type information from the given type, for example: - - ```py - class Params(TypedDict, total=False): - card_id: Required[Annotated[str, PropertyInfo(alias="cardID")]] - - - transformed = transform({"card_id": ""}, Params) - # {'cardID': ''} - ``` - - Any keys / data that does not have type information given will be included as is. - - It should be noted that the transformations that this function does are not represented in the type system. - """ - transformed = _transform_recursive(data, annotation=cast(type, expected_type)) - return cast(_T, transformed) - - -@lru_cache(maxsize=8096) -def _get_annotated_type(type_: type) -> type | None: - """If the given type is an `Annotated` type then it is returned, if not `None` is returned. - - This also unwraps the type when applicable, e.g. `Required[Annotated[T, ...]]` - """ - if is_required_type(type_): - # Unwrap `Required[Annotated[T, ...]]` to `Annotated[T, ...]` - type_ = get_args(type_)[0] - - if is_annotated_type(type_): - return type_ - - return None - - -def _maybe_transform_key(key: str, type_: type) -> str: - """Transform the given `data` based on the annotations provided in `type_`. - - Note: this function only looks at `Annotated` types that contain `PropertyInfo` metadata. - """ - annotated_type = _get_annotated_type(type_) - if annotated_type is None: - # no `Annotated` definition for this type, no transformation needed - return key - - # ignore the first argument as it is the actual type - annotations = get_args(annotated_type)[1:] - for annotation in annotations: - if isinstance(annotation, PropertyInfo) and annotation.alias is not None: - return annotation.alias - - return key - - -def _no_transform_needed(annotation: type) -> bool: - return annotation == float or annotation == int - - -def _transform_recursive( - data: object, - *, - annotation: type, - inner_type: type | None = None, -) -> object: - """Transform the given data against the expected type. - - Args: - annotation: The direct type annotation given to the particular piece of data. - This may or may not be wrapped in metadata types, e.g. `Required[T]`, `Annotated[T, ...]` etc - - inner_type: If applicable, this is the "inside" type. This is useful in certain cases where the outside type - is a container type such as `List[T]`. In that case `inner_type` should be set to `T` so that each entry in - the list can be transformed using the metadata from the container type. - - Defaults to the same value as the `annotation` argument. - """ - if inner_type is None: - inner_type = annotation - - stripped_type = strip_annotated_type(inner_type) - origin = get_origin(stripped_type) or stripped_type - if is_typeddict(stripped_type) and is_mapping(data): - return _transform_typeddict(data, stripped_type) - - if origin == dict and is_mapping(data): - items_type = get_args(stripped_type)[1] - return {key: _transform_recursive(value, annotation=items_type) for key, value in data.items()} - - if ( - # List[T] - (is_list_type(stripped_type) and is_list(data)) - # Iterable[T] - or (is_iterable_type(stripped_type) and is_iterable(data) and not isinstance(data, str)) - ): - # dicts are technically iterable, but it is an iterable on the keys of the dict and is not usually - # intended as an iterable, so we don't transform it. - if isinstance(data, dict): - return cast(object, data) - - inner_type = extract_type_arg(stripped_type, 0) - if _no_transform_needed(inner_type): - # for some types there is no need to transform anything, so we can get a small - # perf boost from skipping that work. - # - # but we still need to convert to a list to ensure the data is json-serializable - if is_list(data): - return data - return list(data) - - return [_transform_recursive(d, annotation=annotation, inner_type=inner_type) for d in data] - - if is_union_type(stripped_type): - # For union types we run the transformation against all subtypes to ensure that everything is transformed. - # - # TODO: there may be edge cases where the same normalized field name will transform to two different names - # in different subtypes. - for subtype in get_args(stripped_type): - data = _transform_recursive(data, annotation=annotation, inner_type=subtype) - return data - - if isinstance(data, pydantic.BaseModel): - return model_dump(data, exclude_unset=True, mode="json") - - annotated_type = _get_annotated_type(annotation) - if annotated_type is None: - return data - - # ignore the first argument as it is the actual type - annotations = get_args(annotated_type)[1:] - for annotation in annotations: - if isinstance(annotation, PropertyInfo) and annotation.format is not None: - return _format_data(data, annotation.format, annotation.format_template) - - return data - - -def _format_data(data: object, format_: PropertyFormat, format_template: str | None) -> object: - if isinstance(data, (date, datetime)): - if format_ == "iso8601": - return data.isoformat() - - if format_ == "custom" and format_template is not None: - return data.strftime(format_template) - - if format_ == "base64" and is_base64_file_input(data): - binary: str | bytes | None = None - - if isinstance(data, pathlib.Path): - binary = data.read_bytes() - elif isinstance(data, io.IOBase): - binary = data.read() - - if isinstance(binary, str): # type: ignore[unreachable] - binary = binary.encode() - - if not isinstance(binary, bytes): - raise RuntimeError(f"Could not read bytes from {data}; Received {type(binary)}") - - return base64.b64encode(binary).decode("ascii") - - return data - - -def _transform_typeddict( - data: Mapping[str, object], - expected_type: type, -) -> Mapping[str, object]: - result: dict[str, object] = {} - annotations = get_type_hints(expected_type, include_extras=True) - for key, value in data.items(): - if not is_given(value): - # we don't need to include `NotGiven` values here as they'll - # be stripped out before the request is sent anyway - continue - - type_ = annotations.get(key) - if type_ is None: - # we do not have a type annotation for this field, leave it as is - result[key] = value - else: - result[_maybe_transform_key(key, type_)] = _transform_recursive(value, annotation=type_) - return result - - -async def async_maybe_transform( - data: object, - expected_type: object, -) -> Any | None: - """Wrapper over `async_transform()` that allows `None` to be passed. - - See `async_transform()` for more details. - """ - if data is None: - return None - return await async_transform(data, expected_type) - - -async def async_transform( - data: _T, - expected_type: object, -) -> _T: - """Transform dictionaries based off of type information from the given type, for example: - - ```py - class Params(TypedDict, total=False): - card_id: Required[Annotated[str, PropertyInfo(alias="cardID")]] - - - transformed = transform({"card_id": ""}, Params) - # {'cardID': ''} - ``` - - Any keys / data that does not have type information given will be included as is. - - It should be noted that the transformations that this function does are not represented in the type system. - """ - transformed = await _async_transform_recursive(data, annotation=cast(type, expected_type)) - return cast(_T, transformed) - - -async def _async_transform_recursive( - data: object, - *, - annotation: type, - inner_type: type | None = None, -) -> object: - """Transform the given data against the expected type. - - Args: - annotation: The direct type annotation given to the particular piece of data. - This may or may not be wrapped in metadata types, e.g. `Required[T]`, `Annotated[T, ...]` etc - - inner_type: If applicable, this is the "inside" type. This is useful in certain cases where the outside type - is a container type such as `List[T]`. In that case `inner_type` should be set to `T` so that each entry in - the list can be transformed using the metadata from the container type. - - Defaults to the same value as the `annotation` argument. - """ - if inner_type is None: - inner_type = annotation - - stripped_type = strip_annotated_type(inner_type) - origin = get_origin(stripped_type) or stripped_type - if is_typeddict(stripped_type) and is_mapping(data): - return await _async_transform_typeddict(data, stripped_type) - - if origin == dict and is_mapping(data): - items_type = get_args(stripped_type)[1] - return {key: _transform_recursive(value, annotation=items_type) for key, value in data.items()} - - if ( - # List[T] - (is_list_type(stripped_type) and is_list(data)) - # Iterable[T] - or (is_iterable_type(stripped_type) and is_iterable(data) and not isinstance(data, str)) - ): - # dicts are technically iterable, but it is an iterable on the keys of the dict and is not usually - # intended as an iterable, so we don't transform it. - if isinstance(data, dict): - return cast(object, data) - - inner_type = extract_type_arg(stripped_type, 0) - if _no_transform_needed(inner_type): - # for some types there is no need to transform anything, so we can get a small - # perf boost from skipping that work. - # - # but we still need to convert to a list to ensure the data is json-serializable - if is_list(data): - return data - return list(data) - - return [await _async_transform_recursive(d, annotation=annotation, inner_type=inner_type) for d in data] - - if is_union_type(stripped_type): - # For union types we run the transformation against all subtypes to ensure that everything is transformed. - # - # TODO: there may be edge cases where the same normalized field name will transform to two different names - # in different subtypes. - for subtype in get_args(stripped_type): - data = await _async_transform_recursive(data, annotation=annotation, inner_type=subtype) - return data - - if isinstance(data, pydantic.BaseModel): - return model_dump(data, exclude_unset=True, mode="json") - - annotated_type = _get_annotated_type(annotation) - if annotated_type is None: - return data - - # ignore the first argument as it is the actual type - annotations = get_args(annotated_type)[1:] - for annotation in annotations: - if isinstance(annotation, PropertyInfo) and annotation.format is not None: - return await _async_format_data(data, annotation.format, annotation.format_template) - - return data - - -async def _async_format_data(data: object, format_: PropertyFormat, format_template: str | None) -> object: - if isinstance(data, (date, datetime)): - if format_ == "iso8601": - return data.isoformat() - - if format_ == "custom" and format_template is not None: - return data.strftime(format_template) - - if format_ == "base64" and is_base64_file_input(data): - binary: str | bytes | None = None - - if isinstance(data, pathlib.Path): - binary = await anyio.Path(data).read_bytes() - elif isinstance(data, io.IOBase): - binary = data.read() - - if isinstance(binary, str): # type: ignore[unreachable] - binary = binary.encode() - - if not isinstance(binary, bytes): - raise RuntimeError(f"Could not read bytes from {data}; Received {type(binary)}") - - return base64.b64encode(binary).decode("ascii") - - return data - - -async def _async_transform_typeddict( - data: Mapping[str, object], - expected_type: type, -) -> Mapping[str, object]: - result: dict[str, object] = {} - annotations = get_type_hints(expected_type, include_extras=True) - for key, value in data.items(): - if not is_given(value): - # we don't need to include `NotGiven` values here as they'll - # be stripped out before the request is sent anyway - continue - - type_ = annotations.get(key) - if type_ is None: - # we do not have a type annotation for this field, leave it as is - result[key] = value - else: - result[_maybe_transform_key(key, type_)] = await _async_transform_recursive(value, annotation=type_) - return result - - -@lru_cache(maxsize=8096) -def get_type_hints( - obj: Any, - globalns: dict[str, Any] | None = None, - localns: Mapping[str, Any] | None = None, - include_extras: bool = False, -) -> dict[str, Any]: - return _get_type_hints(obj, globalns=globalns, localns=localns, include_extras=include_extras) diff --git a/src/opencode_ai/_utils/_typing.py b/src/opencode_ai/_utils/_typing.py deleted file mode 100644 index 1bac954..0000000 --- a/src/opencode_ai/_utils/_typing.py +++ /dev/null @@ -1,151 +0,0 @@ -from __future__ import annotations - -import sys -import typing -import typing_extensions -from typing import Any, TypeVar, Iterable, cast -from collections import abc as _c_abc -from typing_extensions import ( - TypeIs, - Required, - Annotated, - get_args, - get_origin, -) - -from ._utils import lru_cache -from .._types import InheritsGeneric -from .._compat import is_union as _is_union - - -def is_annotated_type(typ: type) -> bool: - return get_origin(typ) == Annotated - - -def is_list_type(typ: type) -> bool: - return (get_origin(typ) or typ) == list - - -def is_iterable_type(typ: type) -> bool: - """If the given type is `typing.Iterable[T]`""" - origin = get_origin(typ) or typ - return origin == Iterable or origin == _c_abc.Iterable - - -def is_union_type(typ: type) -> bool: - return _is_union(get_origin(typ)) - - -def is_required_type(typ: type) -> bool: - return get_origin(typ) == Required - - -def is_typevar(typ: type) -> bool: - # type ignore is required because type checkers - # think this expression will always return False - return type(typ) == TypeVar # type: ignore - - -_TYPE_ALIAS_TYPES: tuple[type[typing_extensions.TypeAliasType], ...] = (typing_extensions.TypeAliasType,) -if sys.version_info >= (3, 12): - _TYPE_ALIAS_TYPES = (*_TYPE_ALIAS_TYPES, typing.TypeAliasType) - - -def is_type_alias_type(tp: Any, /) -> TypeIs[typing_extensions.TypeAliasType]: - """Return whether the provided argument is an instance of `TypeAliasType`. - - ```python - type Int = int - is_type_alias_type(Int) - # > True - Str = TypeAliasType("Str", str) - is_type_alias_type(Str) - # > True - ``` - """ - return isinstance(tp, _TYPE_ALIAS_TYPES) - - -# Extracts T from Annotated[T, ...] or from Required[Annotated[T, ...]] -@lru_cache(maxsize=8096) -def strip_annotated_type(typ: type) -> type: - if is_required_type(typ) or is_annotated_type(typ): - return strip_annotated_type(cast(type, get_args(typ)[0])) - - return typ - - -def extract_type_arg(typ: type, index: int) -> type: - args = get_args(typ) - try: - return cast(type, args[index]) - except IndexError as err: - raise RuntimeError(f"Expected type {typ} to have a type argument at index {index} but it did not") from err - - -def extract_type_var_from_base( - typ: type, - *, - generic_bases: tuple[type, ...], - index: int, - failure_message: str | None = None, -) -> type: - """Given a type like `Foo[T]`, returns the generic type variable `T`. - - This also handles the case where a concrete subclass is given, e.g. - ```py - class MyResponse(Foo[bytes]): - ... - - extract_type_var(MyResponse, bases=(Foo,), index=0) -> bytes - ``` - - And where a generic subclass is given: - ```py - _T = TypeVar('_T') - class MyResponse(Foo[_T]): - ... - - extract_type_var(MyResponse[bytes], bases=(Foo,), index=0) -> bytes - ``` - """ - cls = cast(object, get_origin(typ) or typ) - if cls in generic_bases: # pyright: ignore[reportUnnecessaryContains] - # we're given the class directly - return extract_type_arg(typ, index) - - # if a subclass is given - # --- - # this is needed as __orig_bases__ is not present in the typeshed stubs - # because it is intended to be for internal use only, however there does - # not seem to be a way to resolve generic TypeVars for inherited subclasses - # without using it. - if isinstance(cls, InheritsGeneric): - target_base_class: Any | None = None - for base in cls.__orig_bases__: - if base.__origin__ in generic_bases: - target_base_class = base - break - - if target_base_class is None: - raise RuntimeError( - "Could not find the generic base class;\n" - "This should never happen;\n" - f"Does {cls} inherit from one of {generic_bases} ?" - ) - - extracted = extract_type_arg(target_base_class, index) - if is_typevar(extracted): - # If the extracted type argument is itself a type variable - # then that means the subclass itself is generic, so we have - # to resolve the type argument from the class itself, not - # the base class. - # - # Note: if there is more than 1 type argument, the subclass could - # change the ordering of the type arguments, this is not currently - # supported. - return extract_type_arg(typ, index) - - return extracted - - raise RuntimeError(failure_message or f"Could not resolve inner type variable at index {index} for {typ}") diff --git a/src/opencode_ai/_utils/_utils.py b/src/opencode_ai/_utils/_utils.py deleted file mode 100644 index ea3cf3f..0000000 --- a/src/opencode_ai/_utils/_utils.py +++ /dev/null @@ -1,422 +0,0 @@ -from __future__ import annotations - -import os -import re -import inspect -import functools -from typing import ( - Any, - Tuple, - Mapping, - TypeVar, - Callable, - Iterable, - Sequence, - cast, - overload, -) -from pathlib import Path -from datetime import date, datetime -from typing_extensions import TypeGuard - -import sniffio - -from .._types import NotGiven, FileTypes, NotGivenOr, HeadersLike -from .._compat import parse_date as parse_date, parse_datetime as parse_datetime - -_T = TypeVar("_T") -_TupleT = TypeVar("_TupleT", bound=Tuple[object, ...]) -_MappingT = TypeVar("_MappingT", bound=Mapping[str, object]) -_SequenceT = TypeVar("_SequenceT", bound=Sequence[object]) -CallableT = TypeVar("CallableT", bound=Callable[..., Any]) - - -def flatten(t: Iterable[Iterable[_T]]) -> list[_T]: - return [item for sublist in t for item in sublist] - - -def extract_files( - # TODO: this needs to take Dict but variance issues..... - # create protocol type ? - query: Mapping[str, object], - *, - paths: Sequence[Sequence[str]], -) -> list[tuple[str, FileTypes]]: - """Recursively extract files from the given dictionary based on specified paths. - - A path may look like this ['foo', 'files', '', 'data']. - - Note: this mutates the given dictionary. - """ - files: list[tuple[str, FileTypes]] = [] - for path in paths: - files.extend(_extract_items(query, path, index=0, flattened_key=None)) - return files - - -def _extract_items( - obj: object, - path: Sequence[str], - *, - index: int, - flattened_key: str | None, -) -> list[tuple[str, FileTypes]]: - try: - key = path[index] - except IndexError: - if isinstance(obj, NotGiven): - # no value was provided - we can safely ignore - return [] - - # cyclical import - from .._files import assert_is_file_content - - # We have exhausted the path, return the entry we found. - assert flattened_key is not None - - if is_list(obj): - files: list[tuple[str, FileTypes]] = [] - for entry in obj: - assert_is_file_content(entry, key=flattened_key + "[]" if flattened_key else "") - files.append((flattened_key + "[]", cast(FileTypes, entry))) - return files - - assert_is_file_content(obj, key=flattened_key) - return [(flattened_key, cast(FileTypes, obj))] - - index += 1 - if is_dict(obj): - try: - # We are at the last entry in the path so we must remove the field - if (len(path)) == index: - item = obj.pop(key) - else: - item = obj[key] - except KeyError: - # Key was not present in the dictionary, this is not indicative of an error - # as the given path may not point to a required field. We also do not want - # to enforce required fields as the API may differ from the spec in some cases. - return [] - if flattened_key is None: - flattened_key = key - else: - flattened_key += f"[{key}]" - return _extract_items( - item, - path, - index=index, - flattened_key=flattened_key, - ) - elif is_list(obj): - if key != "": - return [] - - return flatten( - [ - _extract_items( - item, - path, - index=index, - flattened_key=flattened_key + "[]" if flattened_key is not None else "[]", - ) - for item in obj - ] - ) - - # Something unexpected was passed, just ignore it. - return [] - - -def is_given(obj: NotGivenOr[_T]) -> TypeGuard[_T]: - return not isinstance(obj, NotGiven) - - -# Type safe methods for narrowing types with TypeVars. -# The default narrowing for isinstance(obj, dict) is dict[unknown, unknown], -# however this cause Pyright to rightfully report errors. As we know we don't -# care about the contained types we can safely use `object` in it's place. -# -# There are two separate functions defined, `is_*` and `is_*_t` for different use cases. -# `is_*` is for when you're dealing with an unknown input -# `is_*_t` is for when you're narrowing a known union type to a specific subset - - -def is_tuple(obj: object) -> TypeGuard[tuple[object, ...]]: - return isinstance(obj, tuple) - - -def is_tuple_t(obj: _TupleT | object) -> TypeGuard[_TupleT]: - return isinstance(obj, tuple) - - -def is_sequence(obj: object) -> TypeGuard[Sequence[object]]: - return isinstance(obj, Sequence) - - -def is_sequence_t(obj: _SequenceT | object) -> TypeGuard[_SequenceT]: - return isinstance(obj, Sequence) - - -def is_mapping(obj: object) -> TypeGuard[Mapping[str, object]]: - return isinstance(obj, Mapping) - - -def is_mapping_t(obj: _MappingT | object) -> TypeGuard[_MappingT]: - return isinstance(obj, Mapping) - - -def is_dict(obj: object) -> TypeGuard[dict[object, object]]: - return isinstance(obj, dict) - - -def is_list(obj: object) -> TypeGuard[list[object]]: - return isinstance(obj, list) - - -def is_iterable(obj: object) -> TypeGuard[Iterable[object]]: - return isinstance(obj, Iterable) - - -def deepcopy_minimal(item: _T) -> _T: - """Minimal reimplementation of copy.deepcopy() that will only copy certain object types: - - - mappings, e.g. `dict` - - list - - This is done for performance reasons. - """ - if is_mapping(item): - return cast(_T, {k: deepcopy_minimal(v) for k, v in item.items()}) - if is_list(item): - return cast(_T, [deepcopy_minimal(entry) for entry in item]) - return item - - -# copied from https://github.com/Rapptz/RoboDanny -def human_join(seq: Sequence[str], *, delim: str = ", ", final: str = "or") -> str: - size = len(seq) - if size == 0: - return "" - - if size == 1: - return seq[0] - - if size == 2: - return f"{seq[0]} {final} {seq[1]}" - - return delim.join(seq[:-1]) + f" {final} {seq[-1]}" - - -def quote(string: str) -> str: - """Add single quotation marks around the given string. Does *not* do any escaping.""" - return f"'{string}'" - - -def required_args(*variants: Sequence[str]) -> Callable[[CallableT], CallableT]: - """Decorator to enforce a given set of arguments or variants of arguments are passed to the decorated function. - - Useful for enforcing runtime validation of overloaded functions. - - Example usage: - ```py - @overload - def foo(*, a: str) -> str: ... - - - @overload - def foo(*, b: bool) -> str: ... - - - # This enforces the same constraints that a static type checker would - # i.e. that either a or b must be passed to the function - @required_args(["a"], ["b"]) - def foo(*, a: str | None = None, b: bool | None = None) -> str: ... - ``` - """ - - def inner(func: CallableT) -> CallableT: - params = inspect.signature(func).parameters - positional = [ - name - for name, param in params.items() - if param.kind - in { - param.POSITIONAL_ONLY, - param.POSITIONAL_OR_KEYWORD, - } - ] - - @functools.wraps(func) - def wrapper(*args: object, **kwargs: object) -> object: - given_params: set[str] = set() - for i, _ in enumerate(args): - try: - given_params.add(positional[i]) - except IndexError: - raise TypeError( - f"{func.__name__}() takes {len(positional)} argument(s) but {len(args)} were given" - ) from None - - for key in kwargs.keys(): - given_params.add(key) - - for variant in variants: - matches = all((param in given_params for param in variant)) - if matches: - break - else: # no break - if len(variants) > 1: - variations = human_join( - ["(" + human_join([quote(arg) for arg in variant], final="and") + ")" for variant in variants] - ) - msg = f"Missing required arguments; Expected either {variations} arguments to be given" - else: - assert len(variants) > 0 - - # TODO: this error message is not deterministic - missing = list(set(variants[0]) - given_params) - if len(missing) > 1: - msg = f"Missing required arguments: {human_join([quote(arg) for arg in missing])}" - else: - msg = f"Missing required argument: {quote(missing[0])}" - raise TypeError(msg) - return func(*args, **kwargs) - - return wrapper # type: ignore - - return inner - - -_K = TypeVar("_K") -_V = TypeVar("_V") - - -@overload -def strip_not_given(obj: None) -> None: ... - - -@overload -def strip_not_given(obj: Mapping[_K, _V | NotGiven]) -> dict[_K, _V]: ... - - -@overload -def strip_not_given(obj: object) -> object: ... - - -def strip_not_given(obj: object | None) -> object: - """Remove all top-level keys where their values are instances of `NotGiven`""" - if obj is None: - return None - - if not is_mapping(obj): - return obj - - return {key: value for key, value in obj.items() if not isinstance(value, NotGiven)} - - -def coerce_integer(val: str) -> int: - return int(val, base=10) - - -def coerce_float(val: str) -> float: - return float(val) - - -def coerce_boolean(val: str) -> bool: - return val == "true" or val == "1" or val == "on" - - -def maybe_coerce_integer(val: str | None) -> int | None: - if val is None: - return None - return coerce_integer(val) - - -def maybe_coerce_float(val: str | None) -> float | None: - if val is None: - return None - return coerce_float(val) - - -def maybe_coerce_boolean(val: str | None) -> bool | None: - if val is None: - return None - return coerce_boolean(val) - - -def removeprefix(string: str, prefix: str) -> str: - """Remove a prefix from a string. - - Backport of `str.removeprefix` for Python < 3.9 - """ - if string.startswith(prefix): - return string[len(prefix) :] - return string - - -def removesuffix(string: str, suffix: str) -> str: - """Remove a suffix from a string. - - Backport of `str.removesuffix` for Python < 3.9 - """ - if string.endswith(suffix): - return string[: -len(suffix)] - return string - - -def file_from_path(path: str) -> FileTypes: - contents = Path(path).read_bytes() - file_name = os.path.basename(path) - return (file_name, contents) - - -def get_required_header(headers: HeadersLike, header: str) -> str: - lower_header = header.lower() - if is_mapping_t(headers): - # mypy doesn't understand the type narrowing here - for k, v in headers.items(): # type: ignore - if k.lower() == lower_header and isinstance(v, str): - return v - - # to deal with the case where the header looks like Stainless-Event-Id - intercaps_header = re.sub(r"([^\w])(\w)", lambda pat: pat.group(1) + pat.group(2).upper(), header.capitalize()) - - for normalized_header in [header, lower_header, header.upper(), intercaps_header]: - value = headers.get(normalized_header) - if value: - return value - - raise ValueError(f"Could not find {header} header") - - -def get_async_library() -> str: - try: - return sniffio.current_async_library() - except Exception: - return "false" - - -def lru_cache(*, maxsize: int | None = 128) -> Callable[[CallableT], CallableT]: - """A version of functools.lru_cache that retains the type signature - for the wrapped function arguments. - """ - wrapper = functools.lru_cache( # noqa: TID251 - maxsize=maxsize, - ) - return cast(Any, wrapper) # type: ignore[no-any-return] - - -def json_safe(data: object) -> object: - """Translates a mapping / sequence recursively in the same fashion - as `pydantic` v2's `model_dump(mode="json")`. - """ - if is_mapping(data): - return {json_safe(key): json_safe(value) for key, value in data.items()} - - if is_iterable(data) and not isinstance(data, (str, bytes, bytearray)): - return [json_safe(item) for item in data] - - if isinstance(data, (datetime, date)): - return data.isoformat() - - return data diff --git a/src/opencode_ai/_version.py b/src/opencode_ai/_version.py deleted file mode 100644 index 3e57475..0000000 --- a/src/opencode_ai/_version.py +++ /dev/null @@ -1,4 +0,0 @@ -# File generated from our OpenAPI spec by Stainless. See CONTRIBUTING.md for details. - -__title__ = "opencode_ai" -__version__ = "0.1.0-alpha.36" # x-release-please-version diff --git a/src/opencode_ai/lib/.keep b/src/opencode_ai/lib/.keep deleted file mode 100644 index 5e2c99f..0000000 --- a/src/opencode_ai/lib/.keep +++ /dev/null @@ -1,4 +0,0 @@ -File generated from our OpenAPI spec by Stainless. - -This directory can be used to store custom files to expand the SDK. -It is ignored by Stainless code generation and its content (other than this keep file) won't be touched. \ No newline at end of file diff --git a/src/opencode_ai/py.typed b/src/opencode_ai/py.typed deleted file mode 100644 index e69de29..0000000 diff --git a/src/opencode_ai/resources/__init__.py b/src/opencode_ai/resources/__init__.py deleted file mode 100644 index ee6a647..0000000 --- a/src/opencode_ai/resources/__init__.py +++ /dev/null @@ -1,103 +0,0 @@ -# File generated from our OpenAPI spec by Stainless. See CONTRIBUTING.md for details. - -from .app import ( - AppResource, - AsyncAppResource, - AppResourceWithRawResponse, - AsyncAppResourceWithRawResponse, - AppResourceWithStreamingResponse, - AsyncAppResourceWithStreamingResponse, -) -from .tui import ( - TuiResource, - AsyncTuiResource, - TuiResourceWithRawResponse, - AsyncTuiResourceWithRawResponse, - TuiResourceWithStreamingResponse, - AsyncTuiResourceWithStreamingResponse, -) -from .file import ( - FileResource, - AsyncFileResource, - FileResourceWithRawResponse, - AsyncFileResourceWithRawResponse, - FileResourceWithStreamingResponse, - AsyncFileResourceWithStreamingResponse, -) -from .find import ( - FindResource, - AsyncFindResource, - FindResourceWithRawResponse, - AsyncFindResourceWithRawResponse, - FindResourceWithStreamingResponse, - AsyncFindResourceWithStreamingResponse, -) -from .event import ( - EventResource, - AsyncEventResource, - EventResourceWithRawResponse, - AsyncEventResourceWithRawResponse, - EventResourceWithStreamingResponse, - AsyncEventResourceWithStreamingResponse, -) -from .config import ( - ConfigResource, - AsyncConfigResource, - ConfigResourceWithRawResponse, - AsyncConfigResourceWithRawResponse, - ConfigResourceWithStreamingResponse, - AsyncConfigResourceWithStreamingResponse, -) -from .session import ( - SessionResource, - AsyncSessionResource, - SessionResourceWithRawResponse, - AsyncSessionResourceWithRawResponse, - SessionResourceWithStreamingResponse, - AsyncSessionResourceWithStreamingResponse, -) - -__all__ = [ - "EventResource", - "AsyncEventResource", - "EventResourceWithRawResponse", - "AsyncEventResourceWithRawResponse", - "EventResourceWithStreamingResponse", - "AsyncEventResourceWithStreamingResponse", - "AppResource", - "AsyncAppResource", - "AppResourceWithRawResponse", - "AsyncAppResourceWithRawResponse", - "AppResourceWithStreamingResponse", - "AsyncAppResourceWithStreamingResponse", - "FindResource", - "AsyncFindResource", - "FindResourceWithRawResponse", - "AsyncFindResourceWithRawResponse", - "FindResourceWithStreamingResponse", - "AsyncFindResourceWithStreamingResponse", - "FileResource", - "AsyncFileResource", - "FileResourceWithRawResponse", - "AsyncFileResourceWithRawResponse", - "FileResourceWithStreamingResponse", - "AsyncFileResourceWithStreamingResponse", - "ConfigResource", - "AsyncConfigResource", - "ConfigResourceWithRawResponse", - "AsyncConfigResourceWithRawResponse", - "ConfigResourceWithStreamingResponse", - "AsyncConfigResourceWithStreamingResponse", - "SessionResource", - "AsyncSessionResource", - "SessionResourceWithRawResponse", - "AsyncSessionResourceWithRawResponse", - "SessionResourceWithStreamingResponse", - "AsyncSessionResourceWithStreamingResponse", - "TuiResource", - "AsyncTuiResource", - "TuiResourceWithRawResponse", - "AsyncTuiResourceWithRawResponse", - "TuiResourceWithStreamingResponse", - "AsyncTuiResourceWithStreamingResponse", -] diff --git a/src/opencode_ai/resources/app.py b/src/opencode_ai/resources/app.py deleted file mode 100644 index e9505ce..0000000 --- a/src/opencode_ai/resources/app.py +++ /dev/null @@ -1,408 +0,0 @@ -# File generated from our OpenAPI spec by Stainless. See CONTRIBUTING.md for details. - -from __future__ import annotations - -from typing import Dict -from typing_extensions import Literal - -import httpx - -from ..types import app_log_params -from .._types import NOT_GIVEN, Body, Query, Headers, NotGiven -from .._utils import maybe_transform, async_maybe_transform -from .._compat import cached_property -from .._resource import SyncAPIResource, AsyncAPIResource -from .._response import ( - to_raw_response_wrapper, - to_streamed_response_wrapper, - async_to_raw_response_wrapper, - async_to_streamed_response_wrapper, -) -from ..types.app import App -from .._base_client import make_request_options -from ..types.app_log_response import AppLogResponse -from ..types.app_init_response import AppInitResponse -from ..types.app_modes_response import AppModesResponse -from ..types.app_providers_response import AppProvidersResponse - -__all__ = ["AppResource", "AsyncAppResource"] - - -class AppResource(SyncAPIResource): - @cached_property - def with_raw_response(self) -> AppResourceWithRawResponse: - """ - This property can be used as a prefix for any HTTP method call to return - the raw response object instead of the parsed content. - - For more information, see https://www.github.com/sst/opencode-sdk-python#accessing-raw-response-data-eg-headers - """ - return AppResourceWithRawResponse(self) - - @cached_property - def with_streaming_response(self) -> AppResourceWithStreamingResponse: - """ - An alternative to `.with_raw_response` that doesn't eagerly read the response body. - - For more information, see https://www.github.com/sst/opencode-sdk-python#with_streaming_response - """ - return AppResourceWithStreamingResponse(self) - - def get( - self, - *, - # Use the following arguments if you need to pass additional parameters to the API that aren't available via kwargs. - # The extra values given here take precedence over values defined on the client or passed to this method. - extra_headers: Headers | None = None, - extra_query: Query | None = None, - extra_body: Body | None = None, - timeout: float | httpx.Timeout | None | NotGiven = NOT_GIVEN, - ) -> App: - """Get app info""" - return self._get( - "/app", - options=make_request_options( - extra_headers=extra_headers, extra_query=extra_query, extra_body=extra_body, timeout=timeout - ), - cast_to=App, - ) - - def init( - self, - *, - # Use the following arguments if you need to pass additional parameters to the API that aren't available via kwargs. - # The extra values given here take precedence over values defined on the client or passed to this method. - extra_headers: Headers | None = None, - extra_query: Query | None = None, - extra_body: Body | None = None, - timeout: float | httpx.Timeout | None | NotGiven = NOT_GIVEN, - ) -> AppInitResponse: - """Initialize the app""" - return self._post( - "/app/init", - options=make_request_options( - extra_headers=extra_headers, extra_query=extra_query, extra_body=extra_body, timeout=timeout - ), - cast_to=AppInitResponse, - ) - - def log( - self, - *, - level: Literal["debug", "info", "error", "warn"], - message: str, - service: str, - extra: Dict[str, object] | NotGiven = NOT_GIVEN, - # Use the following arguments if you need to pass additional parameters to the API that aren't available via kwargs. - # The extra values given here take precedence over values defined on the client or passed to this method. - extra_headers: Headers | None = None, - extra_query: Query | None = None, - extra_body: Body | None = None, - timeout: float | httpx.Timeout | None | NotGiven = NOT_GIVEN, - ) -> AppLogResponse: - """ - Write a log entry to the server logs - - Args: - level: Log level - - message: Log message - - service: Service name for the log entry - - extra: Additional metadata for the log entry - - extra_headers: Send extra headers - - extra_query: Add additional query parameters to the request - - extra_body: Add additional JSON properties to the request - - timeout: Override the client-level default timeout for this request, in seconds - """ - return self._post( - "/log", - body=maybe_transform( - { - "level": level, - "message": message, - "service": service, - "extra": extra, - }, - app_log_params.AppLogParams, - ), - options=make_request_options( - extra_headers=extra_headers, extra_query=extra_query, extra_body=extra_body, timeout=timeout - ), - cast_to=AppLogResponse, - ) - - def modes( - self, - *, - # Use the following arguments if you need to pass additional parameters to the API that aren't available via kwargs. - # The extra values given here take precedence over values defined on the client or passed to this method. - extra_headers: Headers | None = None, - extra_query: Query | None = None, - extra_body: Body | None = None, - timeout: float | httpx.Timeout | None | NotGiven = NOT_GIVEN, - ) -> AppModesResponse: - """List all modes""" - return self._get( - "/mode", - options=make_request_options( - extra_headers=extra_headers, extra_query=extra_query, extra_body=extra_body, timeout=timeout - ), - cast_to=AppModesResponse, - ) - - def providers( - self, - *, - # Use the following arguments if you need to pass additional parameters to the API that aren't available via kwargs. - # The extra values given here take precedence over values defined on the client or passed to this method. - extra_headers: Headers | None = None, - extra_query: Query | None = None, - extra_body: Body | None = None, - timeout: float | httpx.Timeout | None | NotGiven = NOT_GIVEN, - ) -> AppProvidersResponse: - """List all providers""" - return self._get( - "/config/providers", - options=make_request_options( - extra_headers=extra_headers, extra_query=extra_query, extra_body=extra_body, timeout=timeout - ), - cast_to=AppProvidersResponse, - ) - - -class AsyncAppResource(AsyncAPIResource): - @cached_property - def with_raw_response(self) -> AsyncAppResourceWithRawResponse: - """ - This property can be used as a prefix for any HTTP method call to return - the raw response object instead of the parsed content. - - For more information, see https://www.github.com/sst/opencode-sdk-python#accessing-raw-response-data-eg-headers - """ - return AsyncAppResourceWithRawResponse(self) - - @cached_property - def with_streaming_response(self) -> AsyncAppResourceWithStreamingResponse: - """ - An alternative to `.with_raw_response` that doesn't eagerly read the response body. - - For more information, see https://www.github.com/sst/opencode-sdk-python#with_streaming_response - """ - return AsyncAppResourceWithStreamingResponse(self) - - async def get( - self, - *, - # Use the following arguments if you need to pass additional parameters to the API that aren't available via kwargs. - # The extra values given here take precedence over values defined on the client or passed to this method. - extra_headers: Headers | None = None, - extra_query: Query | None = None, - extra_body: Body | None = None, - timeout: float | httpx.Timeout | None | NotGiven = NOT_GIVEN, - ) -> App: - """Get app info""" - return await self._get( - "/app", - options=make_request_options( - extra_headers=extra_headers, extra_query=extra_query, extra_body=extra_body, timeout=timeout - ), - cast_to=App, - ) - - async def init( - self, - *, - # Use the following arguments if you need to pass additional parameters to the API that aren't available via kwargs. - # The extra values given here take precedence over values defined on the client or passed to this method. - extra_headers: Headers | None = None, - extra_query: Query | None = None, - extra_body: Body | None = None, - timeout: float | httpx.Timeout | None | NotGiven = NOT_GIVEN, - ) -> AppInitResponse: - """Initialize the app""" - return await self._post( - "/app/init", - options=make_request_options( - extra_headers=extra_headers, extra_query=extra_query, extra_body=extra_body, timeout=timeout - ), - cast_to=AppInitResponse, - ) - - async def log( - self, - *, - level: Literal["debug", "info", "error", "warn"], - message: str, - service: str, - extra: Dict[str, object] | NotGiven = NOT_GIVEN, - # Use the following arguments if you need to pass additional parameters to the API that aren't available via kwargs. - # The extra values given here take precedence over values defined on the client or passed to this method. - extra_headers: Headers | None = None, - extra_query: Query | None = None, - extra_body: Body | None = None, - timeout: float | httpx.Timeout | None | NotGiven = NOT_GIVEN, - ) -> AppLogResponse: - """ - Write a log entry to the server logs - - Args: - level: Log level - - message: Log message - - service: Service name for the log entry - - extra: Additional metadata for the log entry - - extra_headers: Send extra headers - - extra_query: Add additional query parameters to the request - - extra_body: Add additional JSON properties to the request - - timeout: Override the client-level default timeout for this request, in seconds - """ - return await self._post( - "/log", - body=await async_maybe_transform( - { - "level": level, - "message": message, - "service": service, - "extra": extra, - }, - app_log_params.AppLogParams, - ), - options=make_request_options( - extra_headers=extra_headers, extra_query=extra_query, extra_body=extra_body, timeout=timeout - ), - cast_to=AppLogResponse, - ) - - async def modes( - self, - *, - # Use the following arguments if you need to pass additional parameters to the API that aren't available via kwargs. - # The extra values given here take precedence over values defined on the client or passed to this method. - extra_headers: Headers | None = None, - extra_query: Query | None = None, - extra_body: Body | None = None, - timeout: float | httpx.Timeout | None | NotGiven = NOT_GIVEN, - ) -> AppModesResponse: - """List all modes""" - return await self._get( - "/mode", - options=make_request_options( - extra_headers=extra_headers, extra_query=extra_query, extra_body=extra_body, timeout=timeout - ), - cast_to=AppModesResponse, - ) - - async def providers( - self, - *, - # Use the following arguments if you need to pass additional parameters to the API that aren't available via kwargs. - # The extra values given here take precedence over values defined on the client or passed to this method. - extra_headers: Headers | None = None, - extra_query: Query | None = None, - extra_body: Body | None = None, - timeout: float | httpx.Timeout | None | NotGiven = NOT_GIVEN, - ) -> AppProvidersResponse: - """List all providers""" - return await self._get( - "/config/providers", - options=make_request_options( - extra_headers=extra_headers, extra_query=extra_query, extra_body=extra_body, timeout=timeout - ), - cast_to=AppProvidersResponse, - ) - - -class AppResourceWithRawResponse: - def __init__(self, app: AppResource) -> None: - self._app = app - - self.get = to_raw_response_wrapper( - app.get, - ) - self.init = to_raw_response_wrapper( - app.init, - ) - self.log = to_raw_response_wrapper( - app.log, - ) - self.modes = to_raw_response_wrapper( - app.modes, - ) - self.providers = to_raw_response_wrapper( - app.providers, - ) - - -class AsyncAppResourceWithRawResponse: - def __init__(self, app: AsyncAppResource) -> None: - self._app = app - - self.get = async_to_raw_response_wrapper( - app.get, - ) - self.init = async_to_raw_response_wrapper( - app.init, - ) - self.log = async_to_raw_response_wrapper( - app.log, - ) - self.modes = async_to_raw_response_wrapper( - app.modes, - ) - self.providers = async_to_raw_response_wrapper( - app.providers, - ) - - -class AppResourceWithStreamingResponse: - def __init__(self, app: AppResource) -> None: - self._app = app - - self.get = to_streamed_response_wrapper( - app.get, - ) - self.init = to_streamed_response_wrapper( - app.init, - ) - self.log = to_streamed_response_wrapper( - app.log, - ) - self.modes = to_streamed_response_wrapper( - app.modes, - ) - self.providers = to_streamed_response_wrapper( - app.providers, - ) - - -class AsyncAppResourceWithStreamingResponse: - def __init__(self, app: AsyncAppResource) -> None: - self._app = app - - self.get = async_to_streamed_response_wrapper( - app.get, - ) - self.init = async_to_streamed_response_wrapper( - app.init, - ) - self.log = async_to_streamed_response_wrapper( - app.log, - ) - self.modes = async_to_streamed_response_wrapper( - app.modes, - ) - self.providers = async_to_streamed_response_wrapper( - app.providers, - ) diff --git a/src/opencode_ai/resources/config.py b/src/opencode_ai/resources/config.py deleted file mode 100644 index 88ff3a3..0000000 --- a/src/opencode_ai/resources/config.py +++ /dev/null @@ -1,135 +0,0 @@ -# File generated from our OpenAPI spec by Stainless. See CONTRIBUTING.md for details. - -from __future__ import annotations - -import httpx - -from .._types import NOT_GIVEN, Body, Query, Headers, NotGiven -from .._compat import cached_property -from .._resource import SyncAPIResource, AsyncAPIResource -from .._response import ( - to_raw_response_wrapper, - to_streamed_response_wrapper, - async_to_raw_response_wrapper, - async_to_streamed_response_wrapper, -) -from .._base_client import make_request_options -from ..types.config import Config - -__all__ = ["ConfigResource", "AsyncConfigResource"] - - -class ConfigResource(SyncAPIResource): - @cached_property - def with_raw_response(self) -> ConfigResourceWithRawResponse: - """ - This property can be used as a prefix for any HTTP method call to return - the raw response object instead of the parsed content. - - For more information, see https://www.github.com/sst/opencode-sdk-python#accessing-raw-response-data-eg-headers - """ - return ConfigResourceWithRawResponse(self) - - @cached_property - def with_streaming_response(self) -> ConfigResourceWithStreamingResponse: - """ - An alternative to `.with_raw_response` that doesn't eagerly read the response body. - - For more information, see https://www.github.com/sst/opencode-sdk-python#with_streaming_response - """ - return ConfigResourceWithStreamingResponse(self) - - def get( - self, - *, - # Use the following arguments if you need to pass additional parameters to the API that aren't available via kwargs. - # The extra values given here take precedence over values defined on the client or passed to this method. - extra_headers: Headers | None = None, - extra_query: Query | None = None, - extra_body: Body | None = None, - timeout: float | httpx.Timeout | None | NotGiven = NOT_GIVEN, - ) -> Config: - """Get config info""" - return self._get( - "/config", - options=make_request_options( - extra_headers=extra_headers, extra_query=extra_query, extra_body=extra_body, timeout=timeout - ), - cast_to=Config, - ) - - -class AsyncConfigResource(AsyncAPIResource): - @cached_property - def with_raw_response(self) -> AsyncConfigResourceWithRawResponse: - """ - This property can be used as a prefix for any HTTP method call to return - the raw response object instead of the parsed content. - - For more information, see https://www.github.com/sst/opencode-sdk-python#accessing-raw-response-data-eg-headers - """ - return AsyncConfigResourceWithRawResponse(self) - - @cached_property - def with_streaming_response(self) -> AsyncConfigResourceWithStreamingResponse: - """ - An alternative to `.with_raw_response` that doesn't eagerly read the response body. - - For more information, see https://www.github.com/sst/opencode-sdk-python#with_streaming_response - """ - return AsyncConfigResourceWithStreamingResponse(self) - - async def get( - self, - *, - # Use the following arguments if you need to pass additional parameters to the API that aren't available via kwargs. - # The extra values given here take precedence over values defined on the client or passed to this method. - extra_headers: Headers | None = None, - extra_query: Query | None = None, - extra_body: Body | None = None, - timeout: float | httpx.Timeout | None | NotGiven = NOT_GIVEN, - ) -> Config: - """Get config info""" - return await self._get( - "/config", - options=make_request_options( - extra_headers=extra_headers, extra_query=extra_query, extra_body=extra_body, timeout=timeout - ), - cast_to=Config, - ) - - -class ConfigResourceWithRawResponse: - def __init__(self, config: ConfigResource) -> None: - self._config = config - - self.get = to_raw_response_wrapper( - config.get, - ) - - -class AsyncConfigResourceWithRawResponse: - def __init__(self, config: AsyncConfigResource) -> None: - self._config = config - - self.get = async_to_raw_response_wrapper( - config.get, - ) - - -class ConfigResourceWithStreamingResponse: - def __init__(self, config: ConfigResource) -> None: - self._config = config - - self.get = to_streamed_response_wrapper( - config.get, - ) - - -class AsyncConfigResourceWithStreamingResponse: - def __init__(self, config: AsyncConfigResource) -> None: - self._config = config - - self.get = async_to_streamed_response_wrapper( - config.get, - ) diff --git a/src/opencode_ai/resources/event.py b/src/opencode_ai/resources/event.py deleted file mode 100644 index 3e9baa3..0000000 --- a/src/opencode_ai/resources/event.py +++ /dev/null @@ -1,142 +0,0 @@ -# File generated from our OpenAPI spec by Stainless. See CONTRIBUTING.md for details. - -from __future__ import annotations - -from typing import Any, cast - -import httpx - -from .._types import NOT_GIVEN, Body, Query, Headers, NotGiven -from .._compat import cached_property -from .._resource import SyncAPIResource, AsyncAPIResource -from .._response import ( - to_raw_response_wrapper, - to_streamed_response_wrapper, - async_to_raw_response_wrapper, - async_to_streamed_response_wrapper, -) -from .._streaming import Stream, AsyncStream -from .._base_client import make_request_options -from ..types.event_list_response import EventListResponse - -__all__ = ["EventResource", "AsyncEventResource"] - - -class EventResource(SyncAPIResource): - @cached_property - def with_raw_response(self) -> EventResourceWithRawResponse: - """ - This property can be used as a prefix for any HTTP method call to return - the raw response object instead of the parsed content. - - For more information, see https://www.github.com/sst/opencode-sdk-python#accessing-raw-response-data-eg-headers - """ - return EventResourceWithRawResponse(self) - - @cached_property - def with_streaming_response(self) -> EventResourceWithStreamingResponse: - """ - An alternative to `.with_raw_response` that doesn't eagerly read the response body. - - For more information, see https://www.github.com/sst/opencode-sdk-python#with_streaming_response - """ - return EventResourceWithStreamingResponse(self) - - def list( - self, - *, - # Use the following arguments if you need to pass additional parameters to the API that aren't available via kwargs. - # The extra values given here take precedence over values defined on the client or passed to this method. - extra_headers: Headers | None = None, - extra_query: Query | None = None, - extra_body: Body | None = None, - timeout: float | httpx.Timeout | None | NotGiven = NOT_GIVEN, - ) -> Stream[EventListResponse]: - """Get events""" - return self._get( - "/event", - options=make_request_options( - extra_headers=extra_headers, extra_query=extra_query, extra_body=extra_body, timeout=timeout - ), - cast_to=cast(Any, EventListResponse), # Union types cannot be passed in as arguments in the type system - stream=True, - stream_cls=Stream[EventListResponse], - ) - - -class AsyncEventResource(AsyncAPIResource): - @cached_property - def with_raw_response(self) -> AsyncEventResourceWithRawResponse: - """ - This property can be used as a prefix for any HTTP method call to return - the raw response object instead of the parsed content. - - For more information, see https://www.github.com/sst/opencode-sdk-python#accessing-raw-response-data-eg-headers - """ - return AsyncEventResourceWithRawResponse(self) - - @cached_property - def with_streaming_response(self) -> AsyncEventResourceWithStreamingResponse: - """ - An alternative to `.with_raw_response` that doesn't eagerly read the response body. - - For more information, see https://www.github.com/sst/opencode-sdk-python#with_streaming_response - """ - return AsyncEventResourceWithStreamingResponse(self) - - async def list( - self, - *, - # Use the following arguments if you need to pass additional parameters to the API that aren't available via kwargs. - # The extra values given here take precedence over values defined on the client or passed to this method. - extra_headers: Headers | None = None, - extra_query: Query | None = None, - extra_body: Body | None = None, - timeout: float | httpx.Timeout | None | NotGiven = NOT_GIVEN, - ) -> AsyncStream[EventListResponse]: - """Get events""" - return await self._get( - "/event", - options=make_request_options( - extra_headers=extra_headers, extra_query=extra_query, extra_body=extra_body, timeout=timeout - ), - cast_to=cast(Any, EventListResponse), # Union types cannot be passed in as arguments in the type system - stream=True, - stream_cls=AsyncStream[EventListResponse], - ) - - -class EventResourceWithRawResponse: - def __init__(self, event: EventResource) -> None: - self._event = event - - self.list = to_raw_response_wrapper( - event.list, - ) - - -class AsyncEventResourceWithRawResponse: - def __init__(self, event: AsyncEventResource) -> None: - self._event = event - - self.list = async_to_raw_response_wrapper( - event.list, - ) - - -class EventResourceWithStreamingResponse: - def __init__(self, event: EventResource) -> None: - self._event = event - - self.list = to_streamed_response_wrapper( - event.list, - ) - - -class AsyncEventResourceWithStreamingResponse: - def __init__(self, event: AsyncEventResource) -> None: - self._event = event - - self.list = async_to_streamed_response_wrapper( - event.list, - ) diff --git a/src/opencode_ai/resources/file.py b/src/opencode_ai/resources/file.py deleted file mode 100644 index 464bb93..0000000 --- a/src/opencode_ai/resources/file.py +++ /dev/null @@ -1,220 +0,0 @@ -# File generated from our OpenAPI spec by Stainless. See CONTRIBUTING.md for details. - -from __future__ import annotations - -import httpx - -from ..types import file_read_params -from .._types import NOT_GIVEN, Body, Query, Headers, NotGiven -from .._utils import maybe_transform, async_maybe_transform -from .._compat import cached_property -from .._resource import SyncAPIResource, AsyncAPIResource -from .._response import ( - to_raw_response_wrapper, - to_streamed_response_wrapper, - async_to_raw_response_wrapper, - async_to_streamed_response_wrapper, -) -from .._base_client import make_request_options -from ..types.file_read_response import FileReadResponse -from ..types.file_status_response import FileStatusResponse - -__all__ = ["FileResource", "AsyncFileResource"] - - -class FileResource(SyncAPIResource): - @cached_property - def with_raw_response(self) -> FileResourceWithRawResponse: - """ - This property can be used as a prefix for any HTTP method call to return - the raw response object instead of the parsed content. - - For more information, see https://www.github.com/sst/opencode-sdk-python#accessing-raw-response-data-eg-headers - """ - return FileResourceWithRawResponse(self) - - @cached_property - def with_streaming_response(self) -> FileResourceWithStreamingResponse: - """ - An alternative to `.with_raw_response` that doesn't eagerly read the response body. - - For more information, see https://www.github.com/sst/opencode-sdk-python#with_streaming_response - """ - return FileResourceWithStreamingResponse(self) - - def read( - self, - *, - path: str, - # Use the following arguments if you need to pass additional parameters to the API that aren't available via kwargs. - # The extra values given here take precedence over values defined on the client or passed to this method. - extra_headers: Headers | None = None, - extra_query: Query | None = None, - extra_body: Body | None = None, - timeout: float | httpx.Timeout | None | NotGiven = NOT_GIVEN, - ) -> FileReadResponse: - """ - Read a file - - Args: - extra_headers: Send extra headers - - extra_query: Add additional query parameters to the request - - extra_body: Add additional JSON properties to the request - - timeout: Override the client-level default timeout for this request, in seconds - """ - return self._get( - "/file", - options=make_request_options( - extra_headers=extra_headers, - extra_query=extra_query, - extra_body=extra_body, - timeout=timeout, - query=maybe_transform({"path": path}, file_read_params.FileReadParams), - ), - cast_to=FileReadResponse, - ) - - def status( - self, - *, - # Use the following arguments if you need to pass additional parameters to the API that aren't available via kwargs. - # The extra values given here take precedence over values defined on the client or passed to this method. - extra_headers: Headers | None = None, - extra_query: Query | None = None, - extra_body: Body | None = None, - timeout: float | httpx.Timeout | None | NotGiven = NOT_GIVEN, - ) -> FileStatusResponse: - """Get file status""" - return self._get( - "/file/status", - options=make_request_options( - extra_headers=extra_headers, extra_query=extra_query, extra_body=extra_body, timeout=timeout - ), - cast_to=FileStatusResponse, - ) - - -class AsyncFileResource(AsyncAPIResource): - @cached_property - def with_raw_response(self) -> AsyncFileResourceWithRawResponse: - """ - This property can be used as a prefix for any HTTP method call to return - the raw response object instead of the parsed content. - - For more information, see https://www.github.com/sst/opencode-sdk-python#accessing-raw-response-data-eg-headers - """ - return AsyncFileResourceWithRawResponse(self) - - @cached_property - def with_streaming_response(self) -> AsyncFileResourceWithStreamingResponse: - """ - An alternative to `.with_raw_response` that doesn't eagerly read the response body. - - For more information, see https://www.github.com/sst/opencode-sdk-python#with_streaming_response - """ - return AsyncFileResourceWithStreamingResponse(self) - - async def read( - self, - *, - path: str, - # Use the following arguments if you need to pass additional parameters to the API that aren't available via kwargs. - # The extra values given here take precedence over values defined on the client or passed to this method. - extra_headers: Headers | None = None, - extra_query: Query | None = None, - extra_body: Body | None = None, - timeout: float | httpx.Timeout | None | NotGiven = NOT_GIVEN, - ) -> FileReadResponse: - """ - Read a file - - Args: - extra_headers: Send extra headers - - extra_query: Add additional query parameters to the request - - extra_body: Add additional JSON properties to the request - - timeout: Override the client-level default timeout for this request, in seconds - """ - return await self._get( - "/file", - options=make_request_options( - extra_headers=extra_headers, - extra_query=extra_query, - extra_body=extra_body, - timeout=timeout, - query=await async_maybe_transform({"path": path}, file_read_params.FileReadParams), - ), - cast_to=FileReadResponse, - ) - - async def status( - self, - *, - # Use the following arguments if you need to pass additional parameters to the API that aren't available via kwargs. - # The extra values given here take precedence over values defined on the client or passed to this method. - extra_headers: Headers | None = None, - extra_query: Query | None = None, - extra_body: Body | None = None, - timeout: float | httpx.Timeout | None | NotGiven = NOT_GIVEN, - ) -> FileStatusResponse: - """Get file status""" - return await self._get( - "/file/status", - options=make_request_options( - extra_headers=extra_headers, extra_query=extra_query, extra_body=extra_body, timeout=timeout - ), - cast_to=FileStatusResponse, - ) - - -class FileResourceWithRawResponse: - def __init__(self, file: FileResource) -> None: - self._file = file - - self.read = to_raw_response_wrapper( - file.read, - ) - self.status = to_raw_response_wrapper( - file.status, - ) - - -class AsyncFileResourceWithRawResponse: - def __init__(self, file: AsyncFileResource) -> None: - self._file = file - - self.read = async_to_raw_response_wrapper( - file.read, - ) - self.status = async_to_raw_response_wrapper( - file.status, - ) - - -class FileResourceWithStreamingResponse: - def __init__(self, file: FileResource) -> None: - self._file = file - - self.read = to_streamed_response_wrapper( - file.read, - ) - self.status = to_streamed_response_wrapper( - file.status, - ) - - -class AsyncFileResourceWithStreamingResponse: - def __init__(self, file: AsyncFileResource) -> None: - self._file = file - - self.read = async_to_streamed_response_wrapper( - file.read, - ) - self.status = async_to_streamed_response_wrapper( - file.status, - ) diff --git a/src/opencode_ai/resources/find.py b/src/opencode_ai/resources/find.py deleted file mode 100644 index 1558e7c..0000000 --- a/src/opencode_ai/resources/find.py +++ /dev/null @@ -1,335 +0,0 @@ -# File generated from our OpenAPI spec by Stainless. See CONTRIBUTING.md for details. - -from __future__ import annotations - -import httpx - -from ..types import find_text_params, find_files_params, find_symbols_params -from .._types import NOT_GIVEN, Body, Query, Headers, NotGiven -from .._utils import maybe_transform, async_maybe_transform -from .._compat import cached_property -from .._resource import SyncAPIResource, AsyncAPIResource -from .._response import ( - to_raw_response_wrapper, - to_streamed_response_wrapper, - async_to_raw_response_wrapper, - async_to_streamed_response_wrapper, -) -from .._base_client import make_request_options -from ..types.find_text_response import FindTextResponse -from ..types.find_files_response import FindFilesResponse -from ..types.find_symbols_response import FindSymbolsResponse - -__all__ = ["FindResource", "AsyncFindResource"] - - -class FindResource(SyncAPIResource): - @cached_property - def with_raw_response(self) -> FindResourceWithRawResponse: - """ - This property can be used as a prefix for any HTTP method call to return - the raw response object instead of the parsed content. - - For more information, see https://www.github.com/sst/opencode-sdk-python#accessing-raw-response-data-eg-headers - """ - return FindResourceWithRawResponse(self) - - @cached_property - def with_streaming_response(self) -> FindResourceWithStreamingResponse: - """ - An alternative to `.with_raw_response` that doesn't eagerly read the response body. - - For more information, see https://www.github.com/sst/opencode-sdk-python#with_streaming_response - """ - return FindResourceWithStreamingResponse(self) - - def files( - self, - *, - query: str, - # Use the following arguments if you need to pass additional parameters to the API that aren't available via kwargs. - # The extra values given here take precedence over values defined on the client or passed to this method. - extra_headers: Headers | None = None, - extra_query: Query | None = None, - extra_body: Body | None = None, - timeout: float | httpx.Timeout | None | NotGiven = NOT_GIVEN, - ) -> FindFilesResponse: - """ - Find files - - Args: - extra_headers: Send extra headers - - extra_query: Add additional query parameters to the request - - extra_body: Add additional JSON properties to the request - - timeout: Override the client-level default timeout for this request, in seconds - """ - return self._get( - "/find/file", - options=make_request_options( - extra_headers=extra_headers, - extra_query=extra_query, - extra_body=extra_body, - timeout=timeout, - query=maybe_transform({"query": query}, find_files_params.FindFilesParams), - ), - cast_to=FindFilesResponse, - ) - - def symbols( - self, - *, - query: str, - # Use the following arguments if you need to pass additional parameters to the API that aren't available via kwargs. - # The extra values given here take precedence over values defined on the client or passed to this method. - extra_headers: Headers | None = None, - extra_query: Query | None = None, - extra_body: Body | None = None, - timeout: float | httpx.Timeout | None | NotGiven = NOT_GIVEN, - ) -> FindSymbolsResponse: - """ - Find workspace symbols - - Args: - extra_headers: Send extra headers - - extra_query: Add additional query parameters to the request - - extra_body: Add additional JSON properties to the request - - timeout: Override the client-level default timeout for this request, in seconds - """ - return self._get( - "/find/symbol", - options=make_request_options( - extra_headers=extra_headers, - extra_query=extra_query, - extra_body=extra_body, - timeout=timeout, - query=maybe_transform({"query": query}, find_symbols_params.FindSymbolsParams), - ), - cast_to=FindSymbolsResponse, - ) - - def text( - self, - *, - pattern: str, - # Use the following arguments if you need to pass additional parameters to the API that aren't available via kwargs. - # The extra values given here take precedence over values defined on the client or passed to this method. - extra_headers: Headers | None = None, - extra_query: Query | None = None, - extra_body: Body | None = None, - timeout: float | httpx.Timeout | None | NotGiven = NOT_GIVEN, - ) -> FindTextResponse: - """ - Find text in files - - Args: - extra_headers: Send extra headers - - extra_query: Add additional query parameters to the request - - extra_body: Add additional JSON properties to the request - - timeout: Override the client-level default timeout for this request, in seconds - """ - return self._get( - "/find", - options=make_request_options( - extra_headers=extra_headers, - extra_query=extra_query, - extra_body=extra_body, - timeout=timeout, - query=maybe_transform({"pattern": pattern}, find_text_params.FindTextParams), - ), - cast_to=FindTextResponse, - ) - - -class AsyncFindResource(AsyncAPIResource): - @cached_property - def with_raw_response(self) -> AsyncFindResourceWithRawResponse: - """ - This property can be used as a prefix for any HTTP method call to return - the raw response object instead of the parsed content. - - For more information, see https://www.github.com/sst/opencode-sdk-python#accessing-raw-response-data-eg-headers - """ - return AsyncFindResourceWithRawResponse(self) - - @cached_property - def with_streaming_response(self) -> AsyncFindResourceWithStreamingResponse: - """ - An alternative to `.with_raw_response` that doesn't eagerly read the response body. - - For more information, see https://www.github.com/sst/opencode-sdk-python#with_streaming_response - """ - return AsyncFindResourceWithStreamingResponse(self) - - async def files( - self, - *, - query: str, - # Use the following arguments if you need to pass additional parameters to the API that aren't available via kwargs. - # The extra values given here take precedence over values defined on the client or passed to this method. - extra_headers: Headers | None = None, - extra_query: Query | None = None, - extra_body: Body | None = None, - timeout: float | httpx.Timeout | None | NotGiven = NOT_GIVEN, - ) -> FindFilesResponse: - """ - Find files - - Args: - extra_headers: Send extra headers - - extra_query: Add additional query parameters to the request - - extra_body: Add additional JSON properties to the request - - timeout: Override the client-level default timeout for this request, in seconds - """ - return await self._get( - "/find/file", - options=make_request_options( - extra_headers=extra_headers, - extra_query=extra_query, - extra_body=extra_body, - timeout=timeout, - query=await async_maybe_transform({"query": query}, find_files_params.FindFilesParams), - ), - cast_to=FindFilesResponse, - ) - - async def symbols( - self, - *, - query: str, - # Use the following arguments if you need to pass additional parameters to the API that aren't available via kwargs. - # The extra values given here take precedence over values defined on the client or passed to this method. - extra_headers: Headers | None = None, - extra_query: Query | None = None, - extra_body: Body | None = None, - timeout: float | httpx.Timeout | None | NotGiven = NOT_GIVEN, - ) -> FindSymbolsResponse: - """ - Find workspace symbols - - Args: - extra_headers: Send extra headers - - extra_query: Add additional query parameters to the request - - extra_body: Add additional JSON properties to the request - - timeout: Override the client-level default timeout for this request, in seconds - """ - return await self._get( - "/find/symbol", - options=make_request_options( - extra_headers=extra_headers, - extra_query=extra_query, - extra_body=extra_body, - timeout=timeout, - query=await async_maybe_transform({"query": query}, find_symbols_params.FindSymbolsParams), - ), - cast_to=FindSymbolsResponse, - ) - - async def text( - self, - *, - pattern: str, - # Use the following arguments if you need to pass additional parameters to the API that aren't available via kwargs. - # The extra values given here take precedence over values defined on the client or passed to this method. - extra_headers: Headers | None = None, - extra_query: Query | None = None, - extra_body: Body | None = None, - timeout: float | httpx.Timeout | None | NotGiven = NOT_GIVEN, - ) -> FindTextResponse: - """ - Find text in files - - Args: - extra_headers: Send extra headers - - extra_query: Add additional query parameters to the request - - extra_body: Add additional JSON properties to the request - - timeout: Override the client-level default timeout for this request, in seconds - """ - return await self._get( - "/find", - options=make_request_options( - extra_headers=extra_headers, - extra_query=extra_query, - extra_body=extra_body, - timeout=timeout, - query=await async_maybe_transform({"pattern": pattern}, find_text_params.FindTextParams), - ), - cast_to=FindTextResponse, - ) - - -class FindResourceWithRawResponse: - def __init__(self, find: FindResource) -> None: - self._find = find - - self.files = to_raw_response_wrapper( - find.files, - ) - self.symbols = to_raw_response_wrapper( - find.symbols, - ) - self.text = to_raw_response_wrapper( - find.text, - ) - - -class AsyncFindResourceWithRawResponse: - def __init__(self, find: AsyncFindResource) -> None: - self._find = find - - self.files = async_to_raw_response_wrapper( - find.files, - ) - self.symbols = async_to_raw_response_wrapper( - find.symbols, - ) - self.text = async_to_raw_response_wrapper( - find.text, - ) - - -class FindResourceWithStreamingResponse: - def __init__(self, find: FindResource) -> None: - self._find = find - - self.files = to_streamed_response_wrapper( - find.files, - ) - self.symbols = to_streamed_response_wrapper( - find.symbols, - ) - self.text = to_streamed_response_wrapper( - find.text, - ) - - -class AsyncFindResourceWithStreamingResponse: - def __init__(self, find: AsyncFindResource) -> None: - self._find = find - - self.files = async_to_streamed_response_wrapper( - find.files, - ) - self.symbols = async_to_streamed_response_wrapper( - find.symbols, - ) - self.text = async_to_streamed_response_wrapper( - find.text, - ) diff --git a/src/opencode_ai/resources/session.py b/src/opencode_ai/resources/session.py deleted file mode 100644 index 7e50413..0000000 --- a/src/opencode_ai/resources/session.py +++ /dev/null @@ -1,1088 +0,0 @@ -# File generated from our OpenAPI spec by Stainless. See CONTRIBUTING.md for details. - -from __future__ import annotations - -from typing import Dict, Iterable - -import httpx - -from ..types import session_chat_params, session_init_params, session_revert_params, session_summarize_params -from .._types import NOT_GIVEN, Body, Query, Headers, NotGiven -from .._utils import maybe_transform, async_maybe_transform -from .._compat import cached_property -from .._resource import SyncAPIResource, AsyncAPIResource -from .._response import ( - to_raw_response_wrapper, - to_streamed_response_wrapper, - async_to_raw_response_wrapper, - async_to_streamed_response_wrapper, -) -from .._base_client import make_request_options -from ..types.session import Session -from ..types.assistant_message import AssistantMessage -from ..types.session_init_response import SessionInitResponse -from ..types.session_list_response import SessionListResponse -from ..types.session_abort_response import SessionAbortResponse -from ..types.session_delete_response import SessionDeleteResponse -from ..types.session_messages_response import SessionMessagesResponse -from ..types.session_summarize_response import SessionSummarizeResponse - -__all__ = ["SessionResource", "AsyncSessionResource"] - - -class SessionResource(SyncAPIResource): - @cached_property - def with_raw_response(self) -> SessionResourceWithRawResponse: - """ - This property can be used as a prefix for any HTTP method call to return - the raw response object instead of the parsed content. - - For more information, see https://www.github.com/sst/opencode-sdk-python#accessing-raw-response-data-eg-headers - """ - return SessionResourceWithRawResponse(self) - - @cached_property - def with_streaming_response(self) -> SessionResourceWithStreamingResponse: - """ - An alternative to `.with_raw_response` that doesn't eagerly read the response body. - - For more information, see https://www.github.com/sst/opencode-sdk-python#with_streaming_response - """ - return SessionResourceWithStreamingResponse(self) - - def create( - self, - *, - # Use the following arguments if you need to pass additional parameters to the API that aren't available via kwargs. - # The extra values given here take precedence over values defined on the client or passed to this method. - extra_headers: Headers | None = None, - extra_query: Query | None = None, - extra_body: Body | None = None, - timeout: float | httpx.Timeout | None | NotGiven = NOT_GIVEN, - ) -> Session: - """Create a new session""" - return self._post( - "/session", - options=make_request_options( - extra_headers=extra_headers, extra_query=extra_query, extra_body=extra_body, timeout=timeout - ), - cast_to=Session, - ) - - def list( - self, - *, - # Use the following arguments if you need to pass additional parameters to the API that aren't available via kwargs. - # The extra values given here take precedence over values defined on the client or passed to this method. - extra_headers: Headers | None = None, - extra_query: Query | None = None, - extra_body: Body | None = None, - timeout: float | httpx.Timeout | None | NotGiven = NOT_GIVEN, - ) -> SessionListResponse: - """List all sessions""" - return self._get( - "/session", - options=make_request_options( - extra_headers=extra_headers, extra_query=extra_query, extra_body=extra_body, timeout=timeout - ), - cast_to=SessionListResponse, - ) - - def delete( - self, - id: str, - *, - # Use the following arguments if you need to pass additional parameters to the API that aren't available via kwargs. - # The extra values given here take precedence over values defined on the client or passed to this method. - extra_headers: Headers | None = None, - extra_query: Query | None = None, - extra_body: Body | None = None, - timeout: float | httpx.Timeout | None | NotGiven = NOT_GIVEN, - ) -> SessionDeleteResponse: - """ - Delete a session and all its data - - Args: - extra_headers: Send extra headers - - extra_query: Add additional query parameters to the request - - extra_body: Add additional JSON properties to the request - - timeout: Override the client-level default timeout for this request, in seconds - """ - if not id: - raise ValueError(f"Expected a non-empty value for `id` but received {id!r}") - return self._delete( - f"/session/{id}", - options=make_request_options( - extra_headers=extra_headers, extra_query=extra_query, extra_body=extra_body, timeout=timeout - ), - cast_to=SessionDeleteResponse, - ) - - def abort( - self, - id: str, - *, - # Use the following arguments if you need to pass additional parameters to the API that aren't available via kwargs. - # The extra values given here take precedence over values defined on the client or passed to this method. - extra_headers: Headers | None = None, - extra_query: Query | None = None, - extra_body: Body | None = None, - timeout: float | httpx.Timeout | None | NotGiven = NOT_GIVEN, - ) -> SessionAbortResponse: - """ - Abort a session - - Args: - extra_headers: Send extra headers - - extra_query: Add additional query parameters to the request - - extra_body: Add additional JSON properties to the request - - timeout: Override the client-level default timeout for this request, in seconds - """ - if not id: - raise ValueError(f"Expected a non-empty value for `id` but received {id!r}") - return self._post( - f"/session/{id}/abort", - options=make_request_options( - extra_headers=extra_headers, extra_query=extra_query, extra_body=extra_body, timeout=timeout - ), - cast_to=SessionAbortResponse, - ) - - def chat( - self, - id: str, - *, - model_id: str, - parts: Iterable[session_chat_params.Part], - provider_id: str, - message_id: str | NotGiven = NOT_GIVEN, - mode: str | NotGiven = NOT_GIVEN, - system: str | NotGiven = NOT_GIVEN, - tools: Dict[str, bool] | NotGiven = NOT_GIVEN, - # Use the following arguments if you need to pass additional parameters to the API that aren't available via kwargs. - # The extra values given here take precedence over values defined on the client or passed to this method. - extra_headers: Headers | None = None, - extra_query: Query | None = None, - extra_body: Body | None = None, - timeout: float | httpx.Timeout | None | NotGiven = NOT_GIVEN, - ) -> AssistantMessage: - """ - Create and send a new message to a session - - Args: - id: Session ID - - extra_headers: Send extra headers - - extra_query: Add additional query parameters to the request - - extra_body: Add additional JSON properties to the request - - timeout: Override the client-level default timeout for this request, in seconds - """ - if not id: - raise ValueError(f"Expected a non-empty value for `id` but received {id!r}") - return self._post( - f"/session/{id}/message", - body=maybe_transform( - { - "model_id": model_id, - "parts": parts, - "provider_id": provider_id, - "message_id": message_id, - "mode": mode, - "system": system, - "tools": tools, - }, - session_chat_params.SessionChatParams, - ), - options=make_request_options( - extra_headers=extra_headers, extra_query=extra_query, extra_body=extra_body, timeout=timeout - ), - cast_to=AssistantMessage, - ) - - def init( - self, - id: str, - *, - message_id: str, - model_id: str, - provider_id: str, - # Use the following arguments if you need to pass additional parameters to the API that aren't available via kwargs. - # The extra values given here take precedence over values defined on the client or passed to this method. - extra_headers: Headers | None = None, - extra_query: Query | None = None, - extra_body: Body | None = None, - timeout: float | httpx.Timeout | None | NotGiven = NOT_GIVEN, - ) -> SessionInitResponse: - """ - Analyze the app and create an AGENTS.md file - - Args: - id: Session ID - - extra_headers: Send extra headers - - extra_query: Add additional query parameters to the request - - extra_body: Add additional JSON properties to the request - - timeout: Override the client-level default timeout for this request, in seconds - """ - if not id: - raise ValueError(f"Expected a non-empty value for `id` but received {id!r}") - return self._post( - f"/session/{id}/init", - body=maybe_transform( - { - "message_id": message_id, - "model_id": model_id, - "provider_id": provider_id, - }, - session_init_params.SessionInitParams, - ), - options=make_request_options( - extra_headers=extra_headers, extra_query=extra_query, extra_body=extra_body, timeout=timeout - ), - cast_to=SessionInitResponse, - ) - - def messages( - self, - id: str, - *, - # Use the following arguments if you need to pass additional parameters to the API that aren't available via kwargs. - # The extra values given here take precedence over values defined on the client or passed to this method. - extra_headers: Headers | None = None, - extra_query: Query | None = None, - extra_body: Body | None = None, - timeout: float | httpx.Timeout | None | NotGiven = NOT_GIVEN, - ) -> SessionMessagesResponse: - """ - List messages for a session - - Args: - id: Session ID - - extra_headers: Send extra headers - - extra_query: Add additional query parameters to the request - - extra_body: Add additional JSON properties to the request - - timeout: Override the client-level default timeout for this request, in seconds - """ - if not id: - raise ValueError(f"Expected a non-empty value for `id` but received {id!r}") - return self._get( - f"/session/{id}/message", - options=make_request_options( - extra_headers=extra_headers, extra_query=extra_query, extra_body=extra_body, timeout=timeout - ), - cast_to=SessionMessagesResponse, - ) - - def revert( - self, - id: str, - *, - message_id: str, - part_id: str | NotGiven = NOT_GIVEN, - # Use the following arguments if you need to pass additional parameters to the API that aren't available via kwargs. - # The extra values given here take precedence over values defined on the client or passed to this method. - extra_headers: Headers | None = None, - extra_query: Query | None = None, - extra_body: Body | None = None, - timeout: float | httpx.Timeout | None | NotGiven = NOT_GIVEN, - ) -> Session: - """ - Revert a message - - Args: - extra_headers: Send extra headers - - extra_query: Add additional query parameters to the request - - extra_body: Add additional JSON properties to the request - - timeout: Override the client-level default timeout for this request, in seconds - """ - if not id: - raise ValueError(f"Expected a non-empty value for `id` but received {id!r}") - return self._post( - f"/session/{id}/revert", - body=maybe_transform( - { - "message_id": message_id, - "part_id": part_id, - }, - session_revert_params.SessionRevertParams, - ), - options=make_request_options( - extra_headers=extra_headers, extra_query=extra_query, extra_body=extra_body, timeout=timeout - ), - cast_to=Session, - ) - - def share( - self, - id: str, - *, - # Use the following arguments if you need to pass additional parameters to the API that aren't available via kwargs. - # The extra values given here take precedence over values defined on the client or passed to this method. - extra_headers: Headers | None = None, - extra_query: Query | None = None, - extra_body: Body | None = None, - timeout: float | httpx.Timeout | None | NotGiven = NOT_GIVEN, - ) -> Session: - """ - Share a session - - Args: - extra_headers: Send extra headers - - extra_query: Add additional query parameters to the request - - extra_body: Add additional JSON properties to the request - - timeout: Override the client-level default timeout for this request, in seconds - """ - if not id: - raise ValueError(f"Expected a non-empty value for `id` but received {id!r}") - return self._post( - f"/session/{id}/share", - options=make_request_options( - extra_headers=extra_headers, extra_query=extra_query, extra_body=extra_body, timeout=timeout - ), - cast_to=Session, - ) - - def summarize( - self, - id: str, - *, - model_id: str, - provider_id: str, - # Use the following arguments if you need to pass additional parameters to the API that aren't available via kwargs. - # The extra values given here take precedence over values defined on the client or passed to this method. - extra_headers: Headers | None = None, - extra_query: Query | None = None, - extra_body: Body | None = None, - timeout: float | httpx.Timeout | None | NotGiven = NOT_GIVEN, - ) -> SessionSummarizeResponse: - """ - Summarize the session - - Args: - id: Session ID - - extra_headers: Send extra headers - - extra_query: Add additional query parameters to the request - - extra_body: Add additional JSON properties to the request - - timeout: Override the client-level default timeout for this request, in seconds - """ - if not id: - raise ValueError(f"Expected a non-empty value for `id` but received {id!r}") - return self._post( - f"/session/{id}/summarize", - body=maybe_transform( - { - "model_id": model_id, - "provider_id": provider_id, - }, - session_summarize_params.SessionSummarizeParams, - ), - options=make_request_options( - extra_headers=extra_headers, extra_query=extra_query, extra_body=extra_body, timeout=timeout - ), - cast_to=SessionSummarizeResponse, - ) - - def unrevert( - self, - id: str, - *, - # Use the following arguments if you need to pass additional parameters to the API that aren't available via kwargs. - # The extra values given here take precedence over values defined on the client or passed to this method. - extra_headers: Headers | None = None, - extra_query: Query | None = None, - extra_body: Body | None = None, - timeout: float | httpx.Timeout | None | NotGiven = NOT_GIVEN, - ) -> Session: - """ - Restore all reverted messages - - Args: - extra_headers: Send extra headers - - extra_query: Add additional query parameters to the request - - extra_body: Add additional JSON properties to the request - - timeout: Override the client-level default timeout for this request, in seconds - """ - if not id: - raise ValueError(f"Expected a non-empty value for `id` but received {id!r}") - return self._post( - f"/session/{id}/unrevert", - options=make_request_options( - extra_headers=extra_headers, extra_query=extra_query, extra_body=extra_body, timeout=timeout - ), - cast_to=Session, - ) - - def unshare( - self, - id: str, - *, - # Use the following arguments if you need to pass additional parameters to the API that aren't available via kwargs. - # The extra values given here take precedence over values defined on the client or passed to this method. - extra_headers: Headers | None = None, - extra_query: Query | None = None, - extra_body: Body | None = None, - timeout: float | httpx.Timeout | None | NotGiven = NOT_GIVEN, - ) -> Session: - """ - Unshare the session - - Args: - extra_headers: Send extra headers - - extra_query: Add additional query parameters to the request - - extra_body: Add additional JSON properties to the request - - timeout: Override the client-level default timeout for this request, in seconds - """ - if not id: - raise ValueError(f"Expected a non-empty value for `id` but received {id!r}") - return self._delete( - f"/session/{id}/share", - options=make_request_options( - extra_headers=extra_headers, extra_query=extra_query, extra_body=extra_body, timeout=timeout - ), - cast_to=Session, - ) - - -class AsyncSessionResource(AsyncAPIResource): - @cached_property - def with_raw_response(self) -> AsyncSessionResourceWithRawResponse: - """ - This property can be used as a prefix for any HTTP method call to return - the raw response object instead of the parsed content. - - For more information, see https://www.github.com/sst/opencode-sdk-python#accessing-raw-response-data-eg-headers - """ - return AsyncSessionResourceWithRawResponse(self) - - @cached_property - def with_streaming_response(self) -> AsyncSessionResourceWithStreamingResponse: - """ - An alternative to `.with_raw_response` that doesn't eagerly read the response body. - - For more information, see https://www.github.com/sst/opencode-sdk-python#with_streaming_response - """ - return AsyncSessionResourceWithStreamingResponse(self) - - async def create( - self, - *, - # Use the following arguments if you need to pass additional parameters to the API that aren't available via kwargs. - # The extra values given here take precedence over values defined on the client or passed to this method. - extra_headers: Headers | None = None, - extra_query: Query | None = None, - extra_body: Body | None = None, - timeout: float | httpx.Timeout | None | NotGiven = NOT_GIVEN, - ) -> Session: - """Create a new session""" - return await self._post( - "/session", - options=make_request_options( - extra_headers=extra_headers, extra_query=extra_query, extra_body=extra_body, timeout=timeout - ), - cast_to=Session, - ) - - async def list( - self, - *, - # Use the following arguments if you need to pass additional parameters to the API that aren't available via kwargs. - # The extra values given here take precedence over values defined on the client or passed to this method. - extra_headers: Headers | None = None, - extra_query: Query | None = None, - extra_body: Body | None = None, - timeout: float | httpx.Timeout | None | NotGiven = NOT_GIVEN, - ) -> SessionListResponse: - """List all sessions""" - return await self._get( - "/session", - options=make_request_options( - extra_headers=extra_headers, extra_query=extra_query, extra_body=extra_body, timeout=timeout - ), - cast_to=SessionListResponse, - ) - - async def delete( - self, - id: str, - *, - # Use the following arguments if you need to pass additional parameters to the API that aren't available via kwargs. - # The extra values given here take precedence over values defined on the client or passed to this method. - extra_headers: Headers | None = None, - extra_query: Query | None = None, - extra_body: Body | None = None, - timeout: float | httpx.Timeout | None | NotGiven = NOT_GIVEN, - ) -> SessionDeleteResponse: - """ - Delete a session and all its data - - Args: - extra_headers: Send extra headers - - extra_query: Add additional query parameters to the request - - extra_body: Add additional JSON properties to the request - - timeout: Override the client-level default timeout for this request, in seconds - """ - if not id: - raise ValueError(f"Expected a non-empty value for `id` but received {id!r}") - return await self._delete( - f"/session/{id}", - options=make_request_options( - extra_headers=extra_headers, extra_query=extra_query, extra_body=extra_body, timeout=timeout - ), - cast_to=SessionDeleteResponse, - ) - - async def abort( - self, - id: str, - *, - # Use the following arguments if you need to pass additional parameters to the API that aren't available via kwargs. - # The extra values given here take precedence over values defined on the client or passed to this method. - extra_headers: Headers | None = None, - extra_query: Query | None = None, - extra_body: Body | None = None, - timeout: float | httpx.Timeout | None | NotGiven = NOT_GIVEN, - ) -> SessionAbortResponse: - """ - Abort a session - - Args: - extra_headers: Send extra headers - - extra_query: Add additional query parameters to the request - - extra_body: Add additional JSON properties to the request - - timeout: Override the client-level default timeout for this request, in seconds - """ - if not id: - raise ValueError(f"Expected a non-empty value for `id` but received {id!r}") - return await self._post( - f"/session/{id}/abort", - options=make_request_options( - extra_headers=extra_headers, extra_query=extra_query, extra_body=extra_body, timeout=timeout - ), - cast_to=SessionAbortResponse, - ) - - async def chat( - self, - id: str, - *, - model_id: str, - parts: Iterable[session_chat_params.Part], - provider_id: str, - message_id: str | NotGiven = NOT_GIVEN, - mode: str | NotGiven = NOT_GIVEN, - system: str | NotGiven = NOT_GIVEN, - tools: Dict[str, bool] | NotGiven = NOT_GIVEN, - # Use the following arguments if you need to pass additional parameters to the API that aren't available via kwargs. - # The extra values given here take precedence over values defined on the client or passed to this method. - extra_headers: Headers | None = None, - extra_query: Query | None = None, - extra_body: Body | None = None, - timeout: float | httpx.Timeout | None | NotGiven = NOT_GIVEN, - ) -> AssistantMessage: - """ - Create and send a new message to a session - - Args: - id: Session ID - - extra_headers: Send extra headers - - extra_query: Add additional query parameters to the request - - extra_body: Add additional JSON properties to the request - - timeout: Override the client-level default timeout for this request, in seconds - """ - if not id: - raise ValueError(f"Expected a non-empty value for `id` but received {id!r}") - return await self._post( - f"/session/{id}/message", - body=await async_maybe_transform( - { - "model_id": model_id, - "parts": parts, - "provider_id": provider_id, - "message_id": message_id, - "mode": mode, - "system": system, - "tools": tools, - }, - session_chat_params.SessionChatParams, - ), - options=make_request_options( - extra_headers=extra_headers, extra_query=extra_query, extra_body=extra_body, timeout=timeout - ), - cast_to=AssistantMessage, - ) - - async def init( - self, - id: str, - *, - message_id: str, - model_id: str, - provider_id: str, - # Use the following arguments if you need to pass additional parameters to the API that aren't available via kwargs. - # The extra values given here take precedence over values defined on the client or passed to this method. - extra_headers: Headers | None = None, - extra_query: Query | None = None, - extra_body: Body | None = None, - timeout: float | httpx.Timeout | None | NotGiven = NOT_GIVEN, - ) -> SessionInitResponse: - """ - Analyze the app and create an AGENTS.md file - - Args: - id: Session ID - - extra_headers: Send extra headers - - extra_query: Add additional query parameters to the request - - extra_body: Add additional JSON properties to the request - - timeout: Override the client-level default timeout for this request, in seconds - """ - if not id: - raise ValueError(f"Expected a non-empty value for `id` but received {id!r}") - return await self._post( - f"/session/{id}/init", - body=await async_maybe_transform( - { - "message_id": message_id, - "model_id": model_id, - "provider_id": provider_id, - }, - session_init_params.SessionInitParams, - ), - options=make_request_options( - extra_headers=extra_headers, extra_query=extra_query, extra_body=extra_body, timeout=timeout - ), - cast_to=SessionInitResponse, - ) - - async def messages( - self, - id: str, - *, - # Use the following arguments if you need to pass additional parameters to the API that aren't available via kwargs. - # The extra values given here take precedence over values defined on the client or passed to this method. - extra_headers: Headers | None = None, - extra_query: Query | None = None, - extra_body: Body | None = None, - timeout: float | httpx.Timeout | None | NotGiven = NOT_GIVEN, - ) -> SessionMessagesResponse: - """ - List messages for a session - - Args: - id: Session ID - - extra_headers: Send extra headers - - extra_query: Add additional query parameters to the request - - extra_body: Add additional JSON properties to the request - - timeout: Override the client-level default timeout for this request, in seconds - """ - if not id: - raise ValueError(f"Expected a non-empty value for `id` but received {id!r}") - return await self._get( - f"/session/{id}/message", - options=make_request_options( - extra_headers=extra_headers, extra_query=extra_query, extra_body=extra_body, timeout=timeout - ), - cast_to=SessionMessagesResponse, - ) - - async def revert( - self, - id: str, - *, - message_id: str, - part_id: str | NotGiven = NOT_GIVEN, - # Use the following arguments if you need to pass additional parameters to the API that aren't available via kwargs. - # The extra values given here take precedence over values defined on the client or passed to this method. - extra_headers: Headers | None = None, - extra_query: Query | None = None, - extra_body: Body | None = None, - timeout: float | httpx.Timeout | None | NotGiven = NOT_GIVEN, - ) -> Session: - """ - Revert a message - - Args: - extra_headers: Send extra headers - - extra_query: Add additional query parameters to the request - - extra_body: Add additional JSON properties to the request - - timeout: Override the client-level default timeout for this request, in seconds - """ - if not id: - raise ValueError(f"Expected a non-empty value for `id` but received {id!r}") - return await self._post( - f"/session/{id}/revert", - body=await async_maybe_transform( - { - "message_id": message_id, - "part_id": part_id, - }, - session_revert_params.SessionRevertParams, - ), - options=make_request_options( - extra_headers=extra_headers, extra_query=extra_query, extra_body=extra_body, timeout=timeout - ), - cast_to=Session, - ) - - async def share( - self, - id: str, - *, - # Use the following arguments if you need to pass additional parameters to the API that aren't available via kwargs. - # The extra values given here take precedence over values defined on the client or passed to this method. - extra_headers: Headers | None = None, - extra_query: Query | None = None, - extra_body: Body | None = None, - timeout: float | httpx.Timeout | None | NotGiven = NOT_GIVEN, - ) -> Session: - """ - Share a session - - Args: - extra_headers: Send extra headers - - extra_query: Add additional query parameters to the request - - extra_body: Add additional JSON properties to the request - - timeout: Override the client-level default timeout for this request, in seconds - """ - if not id: - raise ValueError(f"Expected a non-empty value for `id` but received {id!r}") - return await self._post( - f"/session/{id}/share", - options=make_request_options( - extra_headers=extra_headers, extra_query=extra_query, extra_body=extra_body, timeout=timeout - ), - cast_to=Session, - ) - - async def summarize( - self, - id: str, - *, - model_id: str, - provider_id: str, - # Use the following arguments if you need to pass additional parameters to the API that aren't available via kwargs. - # The extra values given here take precedence over values defined on the client or passed to this method. - extra_headers: Headers | None = None, - extra_query: Query | None = None, - extra_body: Body | None = None, - timeout: float | httpx.Timeout | None | NotGiven = NOT_GIVEN, - ) -> SessionSummarizeResponse: - """ - Summarize the session - - Args: - id: Session ID - - extra_headers: Send extra headers - - extra_query: Add additional query parameters to the request - - extra_body: Add additional JSON properties to the request - - timeout: Override the client-level default timeout for this request, in seconds - """ - if not id: - raise ValueError(f"Expected a non-empty value for `id` but received {id!r}") - return await self._post( - f"/session/{id}/summarize", - body=await async_maybe_transform( - { - "model_id": model_id, - "provider_id": provider_id, - }, - session_summarize_params.SessionSummarizeParams, - ), - options=make_request_options( - extra_headers=extra_headers, extra_query=extra_query, extra_body=extra_body, timeout=timeout - ), - cast_to=SessionSummarizeResponse, - ) - - async def unrevert( - self, - id: str, - *, - # Use the following arguments if you need to pass additional parameters to the API that aren't available via kwargs. - # The extra values given here take precedence over values defined on the client or passed to this method. - extra_headers: Headers | None = None, - extra_query: Query | None = None, - extra_body: Body | None = None, - timeout: float | httpx.Timeout | None | NotGiven = NOT_GIVEN, - ) -> Session: - """ - Restore all reverted messages - - Args: - extra_headers: Send extra headers - - extra_query: Add additional query parameters to the request - - extra_body: Add additional JSON properties to the request - - timeout: Override the client-level default timeout for this request, in seconds - """ - if not id: - raise ValueError(f"Expected a non-empty value for `id` but received {id!r}") - return await self._post( - f"/session/{id}/unrevert", - options=make_request_options( - extra_headers=extra_headers, extra_query=extra_query, extra_body=extra_body, timeout=timeout - ), - cast_to=Session, - ) - - async def unshare( - self, - id: str, - *, - # Use the following arguments if you need to pass additional parameters to the API that aren't available via kwargs. - # The extra values given here take precedence over values defined on the client or passed to this method. - extra_headers: Headers | None = None, - extra_query: Query | None = None, - extra_body: Body | None = None, - timeout: float | httpx.Timeout | None | NotGiven = NOT_GIVEN, - ) -> Session: - """ - Unshare the session - - Args: - extra_headers: Send extra headers - - extra_query: Add additional query parameters to the request - - extra_body: Add additional JSON properties to the request - - timeout: Override the client-level default timeout for this request, in seconds - """ - if not id: - raise ValueError(f"Expected a non-empty value for `id` but received {id!r}") - return await self._delete( - f"/session/{id}/share", - options=make_request_options( - extra_headers=extra_headers, extra_query=extra_query, extra_body=extra_body, timeout=timeout - ), - cast_to=Session, - ) - - -class SessionResourceWithRawResponse: - def __init__(self, session: SessionResource) -> None: - self._session = session - - self.create = to_raw_response_wrapper( - session.create, - ) - self.list = to_raw_response_wrapper( - session.list, - ) - self.delete = to_raw_response_wrapper( - session.delete, - ) - self.abort = to_raw_response_wrapper( - session.abort, - ) - self.chat = to_raw_response_wrapper( - session.chat, - ) - self.init = to_raw_response_wrapper( - session.init, - ) - self.messages = to_raw_response_wrapper( - session.messages, - ) - self.revert = to_raw_response_wrapper( - session.revert, - ) - self.share = to_raw_response_wrapper( - session.share, - ) - self.summarize = to_raw_response_wrapper( - session.summarize, - ) - self.unrevert = to_raw_response_wrapper( - session.unrevert, - ) - self.unshare = to_raw_response_wrapper( - session.unshare, - ) - - -class AsyncSessionResourceWithRawResponse: - def __init__(self, session: AsyncSessionResource) -> None: - self._session = session - - self.create = async_to_raw_response_wrapper( - session.create, - ) - self.list = async_to_raw_response_wrapper( - session.list, - ) - self.delete = async_to_raw_response_wrapper( - session.delete, - ) - self.abort = async_to_raw_response_wrapper( - session.abort, - ) - self.chat = async_to_raw_response_wrapper( - session.chat, - ) - self.init = async_to_raw_response_wrapper( - session.init, - ) - self.messages = async_to_raw_response_wrapper( - session.messages, - ) - self.revert = async_to_raw_response_wrapper( - session.revert, - ) - self.share = async_to_raw_response_wrapper( - session.share, - ) - self.summarize = async_to_raw_response_wrapper( - session.summarize, - ) - self.unrevert = async_to_raw_response_wrapper( - session.unrevert, - ) - self.unshare = async_to_raw_response_wrapper( - session.unshare, - ) - - -class SessionResourceWithStreamingResponse: - def __init__(self, session: SessionResource) -> None: - self._session = session - - self.create = to_streamed_response_wrapper( - session.create, - ) - self.list = to_streamed_response_wrapper( - session.list, - ) - self.delete = to_streamed_response_wrapper( - session.delete, - ) - self.abort = to_streamed_response_wrapper( - session.abort, - ) - self.chat = to_streamed_response_wrapper( - session.chat, - ) - self.init = to_streamed_response_wrapper( - session.init, - ) - self.messages = to_streamed_response_wrapper( - session.messages, - ) - self.revert = to_streamed_response_wrapper( - session.revert, - ) - self.share = to_streamed_response_wrapper( - session.share, - ) - self.summarize = to_streamed_response_wrapper( - session.summarize, - ) - self.unrevert = to_streamed_response_wrapper( - session.unrevert, - ) - self.unshare = to_streamed_response_wrapper( - session.unshare, - ) - - -class AsyncSessionResourceWithStreamingResponse: - def __init__(self, session: AsyncSessionResource) -> None: - self._session = session - - self.create = async_to_streamed_response_wrapper( - session.create, - ) - self.list = async_to_streamed_response_wrapper( - session.list, - ) - self.delete = async_to_streamed_response_wrapper( - session.delete, - ) - self.abort = async_to_streamed_response_wrapper( - session.abort, - ) - self.chat = async_to_streamed_response_wrapper( - session.chat, - ) - self.init = async_to_streamed_response_wrapper( - session.init, - ) - self.messages = async_to_streamed_response_wrapper( - session.messages, - ) - self.revert = async_to_streamed_response_wrapper( - session.revert, - ) - self.share = async_to_streamed_response_wrapper( - session.share, - ) - self.summarize = async_to_streamed_response_wrapper( - session.summarize, - ) - self.unrevert = async_to_streamed_response_wrapper( - session.unrevert, - ) - self.unshare = async_to_streamed_response_wrapper( - session.unshare, - ) diff --git a/src/opencode_ai/resources/tui.py b/src/opencode_ai/resources/tui.py deleted file mode 100644 index 7194e7a..0000000 --- a/src/opencode_ai/resources/tui.py +++ /dev/null @@ -1,214 +0,0 @@ -# File generated from our OpenAPI spec by Stainless. See CONTRIBUTING.md for details. - -from __future__ import annotations - -import httpx - -from ..types import tui_append_prompt_params -from .._types import NOT_GIVEN, Body, Query, Headers, NotGiven -from .._utils import maybe_transform, async_maybe_transform -from .._compat import cached_property -from .._resource import SyncAPIResource, AsyncAPIResource -from .._response import ( - to_raw_response_wrapper, - to_streamed_response_wrapper, - async_to_raw_response_wrapper, - async_to_streamed_response_wrapper, -) -from .._base_client import make_request_options -from ..types.tui_open_help_response import TuiOpenHelpResponse -from ..types.tui_append_prompt_response import TuiAppendPromptResponse - -__all__ = ["TuiResource", "AsyncTuiResource"] - - -class TuiResource(SyncAPIResource): - @cached_property - def with_raw_response(self) -> TuiResourceWithRawResponse: - """ - This property can be used as a prefix for any HTTP method call to return - the raw response object instead of the parsed content. - - For more information, see https://www.github.com/sst/opencode-sdk-python#accessing-raw-response-data-eg-headers - """ - return TuiResourceWithRawResponse(self) - - @cached_property - def with_streaming_response(self) -> TuiResourceWithStreamingResponse: - """ - An alternative to `.with_raw_response` that doesn't eagerly read the response body. - - For more information, see https://www.github.com/sst/opencode-sdk-python#with_streaming_response - """ - return TuiResourceWithStreamingResponse(self) - - def append_prompt( - self, - *, - text: str, - # Use the following arguments if you need to pass additional parameters to the API that aren't available via kwargs. - # The extra values given here take precedence over values defined on the client or passed to this method. - extra_headers: Headers | None = None, - extra_query: Query | None = None, - extra_body: Body | None = None, - timeout: float | httpx.Timeout | None | NotGiven = NOT_GIVEN, - ) -> TuiAppendPromptResponse: - """ - Append prompt to the TUI - - Args: - extra_headers: Send extra headers - - extra_query: Add additional query parameters to the request - - extra_body: Add additional JSON properties to the request - - timeout: Override the client-level default timeout for this request, in seconds - """ - return self._post( - "/tui/append-prompt", - body=maybe_transform({"text": text}, tui_append_prompt_params.TuiAppendPromptParams), - options=make_request_options( - extra_headers=extra_headers, extra_query=extra_query, extra_body=extra_body, timeout=timeout - ), - cast_to=TuiAppendPromptResponse, - ) - - def open_help( - self, - *, - # Use the following arguments if you need to pass additional parameters to the API that aren't available via kwargs. - # The extra values given here take precedence over values defined on the client or passed to this method. - extra_headers: Headers | None = None, - extra_query: Query | None = None, - extra_body: Body | None = None, - timeout: float | httpx.Timeout | None | NotGiven = NOT_GIVEN, - ) -> TuiOpenHelpResponse: - """Open the help dialog""" - return self._post( - "/tui/open-help", - options=make_request_options( - extra_headers=extra_headers, extra_query=extra_query, extra_body=extra_body, timeout=timeout - ), - cast_to=TuiOpenHelpResponse, - ) - - -class AsyncTuiResource(AsyncAPIResource): - @cached_property - def with_raw_response(self) -> AsyncTuiResourceWithRawResponse: - """ - This property can be used as a prefix for any HTTP method call to return - the raw response object instead of the parsed content. - - For more information, see https://www.github.com/sst/opencode-sdk-python#accessing-raw-response-data-eg-headers - """ - return AsyncTuiResourceWithRawResponse(self) - - @cached_property - def with_streaming_response(self) -> AsyncTuiResourceWithStreamingResponse: - """ - An alternative to `.with_raw_response` that doesn't eagerly read the response body. - - For more information, see https://www.github.com/sst/opencode-sdk-python#with_streaming_response - """ - return AsyncTuiResourceWithStreamingResponse(self) - - async def append_prompt( - self, - *, - text: str, - # Use the following arguments if you need to pass additional parameters to the API that aren't available via kwargs. - # The extra values given here take precedence over values defined on the client or passed to this method. - extra_headers: Headers | None = None, - extra_query: Query | None = None, - extra_body: Body | None = None, - timeout: float | httpx.Timeout | None | NotGiven = NOT_GIVEN, - ) -> TuiAppendPromptResponse: - """ - Append prompt to the TUI - - Args: - extra_headers: Send extra headers - - extra_query: Add additional query parameters to the request - - extra_body: Add additional JSON properties to the request - - timeout: Override the client-level default timeout for this request, in seconds - """ - return await self._post( - "/tui/append-prompt", - body=await async_maybe_transform({"text": text}, tui_append_prompt_params.TuiAppendPromptParams), - options=make_request_options( - extra_headers=extra_headers, extra_query=extra_query, extra_body=extra_body, timeout=timeout - ), - cast_to=TuiAppendPromptResponse, - ) - - async def open_help( - self, - *, - # Use the following arguments if you need to pass additional parameters to the API that aren't available via kwargs. - # The extra values given here take precedence over values defined on the client or passed to this method. - extra_headers: Headers | None = None, - extra_query: Query | None = None, - extra_body: Body | None = None, - timeout: float | httpx.Timeout | None | NotGiven = NOT_GIVEN, - ) -> TuiOpenHelpResponse: - """Open the help dialog""" - return await self._post( - "/tui/open-help", - options=make_request_options( - extra_headers=extra_headers, extra_query=extra_query, extra_body=extra_body, timeout=timeout - ), - cast_to=TuiOpenHelpResponse, - ) - - -class TuiResourceWithRawResponse: - def __init__(self, tui: TuiResource) -> None: - self._tui = tui - - self.append_prompt = to_raw_response_wrapper( - tui.append_prompt, - ) - self.open_help = to_raw_response_wrapper( - tui.open_help, - ) - - -class AsyncTuiResourceWithRawResponse: - def __init__(self, tui: AsyncTuiResource) -> None: - self._tui = tui - - self.append_prompt = async_to_raw_response_wrapper( - tui.append_prompt, - ) - self.open_help = async_to_raw_response_wrapper( - tui.open_help, - ) - - -class TuiResourceWithStreamingResponse: - def __init__(self, tui: TuiResource) -> None: - self._tui = tui - - self.append_prompt = to_streamed_response_wrapper( - tui.append_prompt, - ) - self.open_help = to_streamed_response_wrapper( - tui.open_help, - ) - - -class AsyncTuiResourceWithStreamingResponse: - def __init__(self, tui: AsyncTuiResource) -> None: - self._tui = tui - - self.append_prompt = async_to_streamed_response_wrapper( - tui.append_prompt, - ) - self.open_help = async_to_streamed_response_wrapper( - tui.open_help, - ) diff --git a/src/opencode_ai/types/__init__.py b/src/opencode_ai/types/__init__.py deleted file mode 100644 index 1d0663b..0000000 --- a/src/opencode_ai/types/__init__.py +++ /dev/null @@ -1,73 +0,0 @@ -# File generated from our OpenAPI spec by Stainless. See CONTRIBUTING.md for details. - -from __future__ import annotations - -from .app import App as App -from .file import File as File -from .mode import Mode as Mode -from .part import Part as Part -from .model import Model as Model -from .config import Config as Config -from .shared import ( - UnknownError as UnknownError, - ProviderAuthError as ProviderAuthError, - MessageAbortedError as MessageAbortedError, -) -from .symbol import Symbol as Symbol -from .message import Message as Message -from .session import Session as Session -from .provider import Provider as Provider -from .file_part import FilePart as FilePart -from .text_part import TextPart as TextPart -from .tool_part import ToolPart as ToolPart -from .file_source import FileSource as FileSource -from .mode_config import ModeConfig as ModeConfig -from .user_message import UserMessage as UserMessage -from .snapshot_part import SnapshotPart as SnapshotPart -from .symbol_source import SymbolSource as SymbolSource -from .app_log_params import AppLogParams as AppLogParams -from .keybinds_config import KeybindsConfig as KeybindsConfig -from .step_start_part import StepStartPart as StepStartPart -from .app_log_response import AppLogResponse as AppLogResponse -from .file_part_source import FilePartSource as FilePartSource -from .file_read_params import FileReadParams as FileReadParams -from .find_text_params import FindTextParams as FindTextParams -from .mcp_local_config import McpLocalConfig as McpLocalConfig -from .step_finish_part import StepFinishPart as StepFinishPart -from .tool_state_error import ToolStateError as ToolStateError -from .app_init_response import AppInitResponse as AppInitResponse -from .assistant_message import AssistantMessage as AssistantMessage -from .file_source_param import FileSourceParam as FileSourceParam -from .find_files_params import FindFilesParams as FindFilesParams -from .mcp_remote_config import McpRemoteConfig as McpRemoteConfig -from .app_modes_response import AppModesResponse as AppModesResponse -from .file_read_response import FileReadResponse as FileReadResponse -from .find_text_response import FindTextResponse as FindTextResponse -from .tool_state_pending import ToolStatePending as ToolStatePending -from .tool_state_running import ToolStateRunning as ToolStateRunning -from .event_list_response import EventListResponse as EventListResponse -from .find_files_response import FindFilesResponse as FindFilesResponse -from .find_symbols_params import FindSymbolsParams as FindSymbolsParams -from .session_chat_params import SessionChatParams as SessionChatParams -from .session_init_params import SessionInitParams as SessionInitParams -from .symbol_source_param import SymbolSourceParam as SymbolSourceParam -from .file_status_response import FileStatusResponse as FileStatusResponse -from .tool_state_completed import ToolStateCompleted as ToolStateCompleted -from .file_part_input_param import FilePartInputParam as FilePartInputParam -from .file_part_source_text import FilePartSourceText as FilePartSourceText -from .find_symbols_response import FindSymbolsResponse as FindSymbolsResponse -from .session_init_response import SessionInitResponse as SessionInitResponse -from .session_list_response import SessionListResponse as SessionListResponse -from .session_revert_params import SessionRevertParams as SessionRevertParams -from .text_part_input_param import TextPartInputParam as TextPartInputParam -from .app_providers_response import AppProvidersResponse as AppProvidersResponse -from .file_part_source_param import FilePartSourceParam as FilePartSourceParam -from .session_abort_response import SessionAbortResponse as SessionAbortResponse -from .tui_open_help_response import TuiOpenHelpResponse as TuiOpenHelpResponse -from .session_delete_response import SessionDeleteResponse as SessionDeleteResponse -from .session_summarize_params import SessionSummarizeParams as SessionSummarizeParams -from .tui_append_prompt_params import TuiAppendPromptParams as TuiAppendPromptParams -from .session_messages_response import SessionMessagesResponse as SessionMessagesResponse -from .session_summarize_response import SessionSummarizeResponse as SessionSummarizeResponse -from .tui_append_prompt_response import TuiAppendPromptResponse as TuiAppendPromptResponse -from .file_part_source_text_param import FilePartSourceTextParam as FilePartSourceTextParam diff --git a/src/opencode_ai/types/app.py b/src/opencode_ai/types/app.py deleted file mode 100644 index d60c600..0000000 --- a/src/opencode_ai/types/app.py +++ /dev/null @@ -1,33 +0,0 @@ -# File generated from our OpenAPI spec by Stainless. See CONTRIBUTING.md for details. - -from typing import Optional - -from .._models import BaseModel - -__all__ = ["App", "Path", "Time"] - - -class Path(BaseModel): - config: str - - cwd: str - - data: str - - root: str - - state: str - - -class Time(BaseModel): - initialized: Optional[float] = None - - -class App(BaseModel): - git: bool - - hostname: str - - path: Path - - time: Time diff --git a/src/opencode_ai/types/app_init_response.py b/src/opencode_ai/types/app_init_response.py deleted file mode 100644 index 08e3d67..0000000 --- a/src/opencode_ai/types/app_init_response.py +++ /dev/null @@ -1,7 +0,0 @@ -# File generated from our OpenAPI spec by Stainless. See CONTRIBUTING.md for details. - -from typing_extensions import TypeAlias - -__all__ = ["AppInitResponse"] - -AppInitResponse: TypeAlias = bool diff --git a/src/opencode_ai/types/app_log_params.py b/src/opencode_ai/types/app_log_params.py deleted file mode 100644 index 8b24c11..0000000 --- a/src/opencode_ai/types/app_log_params.py +++ /dev/null @@ -1,22 +0,0 @@ -# File generated from our OpenAPI spec by Stainless. See CONTRIBUTING.md for details. - -from __future__ import annotations - -from typing import Dict -from typing_extensions import Literal, Required, TypedDict - -__all__ = ["AppLogParams"] - - -class AppLogParams(TypedDict, total=False): - level: Required[Literal["debug", "info", "error", "warn"]] - """Log level""" - - message: Required[str] - """Log message""" - - service: Required[str] - """Service name for the log entry""" - - extra: Dict[str, object] - """Additional metadata for the log entry""" diff --git a/src/opencode_ai/types/app_log_response.py b/src/opencode_ai/types/app_log_response.py deleted file mode 100644 index f56ed8c..0000000 --- a/src/opencode_ai/types/app_log_response.py +++ /dev/null @@ -1,7 +0,0 @@ -# File generated from our OpenAPI spec by Stainless. See CONTRIBUTING.md for details. - -from typing_extensions import TypeAlias - -__all__ = ["AppLogResponse"] - -AppLogResponse: TypeAlias = bool diff --git a/src/opencode_ai/types/app_modes_response.py b/src/opencode_ai/types/app_modes_response.py deleted file mode 100644 index 8d76f89..0000000 --- a/src/opencode_ai/types/app_modes_response.py +++ /dev/null @@ -1,10 +0,0 @@ -# File generated from our OpenAPI spec by Stainless. See CONTRIBUTING.md for details. - -from typing import List -from typing_extensions import TypeAlias - -from .mode import Mode - -__all__ = ["AppModesResponse"] - -AppModesResponse: TypeAlias = List[Mode] diff --git a/src/opencode_ai/types/app_providers_response.py b/src/opencode_ai/types/app_providers_response.py deleted file mode 100644 index 62912e8..0000000 --- a/src/opencode_ai/types/app_providers_response.py +++ /dev/null @@ -1,14 +0,0 @@ -# File generated from our OpenAPI spec by Stainless. See CONTRIBUTING.md for details. - -from typing import Dict, List - -from .._models import BaseModel -from .provider import Provider - -__all__ = ["AppProvidersResponse"] - - -class AppProvidersResponse(BaseModel): - default: Dict[str, str] - - providers: List[Provider] diff --git a/src/opencode_ai/types/assistant_message.py b/src/opencode_ai/types/assistant_message.py deleted file mode 100644 index 490f829..0000000 --- a/src/opencode_ai/types/assistant_message.py +++ /dev/null @@ -1,82 +0,0 @@ -# File generated from our OpenAPI spec by Stainless. See CONTRIBUTING.md for details. - -from typing import List, Union, Optional -from typing_extensions import Literal, Annotated, TypeAlias - -from pydantic import Field as FieldInfo - -from .._utils import PropertyInfo -from .._models import BaseModel -from .shared.unknown_error import UnknownError -from .shared.provider_auth_error import ProviderAuthError -from .shared.message_aborted_error import MessageAbortedError - -__all__ = ["AssistantMessage", "Path", "Time", "Tokens", "TokensCache", "Error", "ErrorMessageOutputLengthError"] - - -class Path(BaseModel): - cwd: str - - root: str - - -class Time(BaseModel): - created: float - - completed: Optional[float] = None - - -class TokensCache(BaseModel): - read: float - - write: float - - -class Tokens(BaseModel): - cache: TokensCache - - input: float - - output: float - - reasoning: float - - -class ErrorMessageOutputLengthError(BaseModel): - data: object - - name: Literal["MessageOutputLengthError"] - - -Error: TypeAlias = Annotated[ - Union[ProviderAuthError, UnknownError, ErrorMessageOutputLengthError, MessageAbortedError], - PropertyInfo(discriminator="name"), -] - - -class AssistantMessage(BaseModel): - id: str - - cost: float - - mode: str - - api_model_id: str = FieldInfo(alias="modelID") - - path: Path - - provider_id: str = FieldInfo(alias="providerID") - - role: Literal["assistant"] - - session_id: str = FieldInfo(alias="sessionID") - - system: List[str] - - time: Time - - tokens: Tokens - - error: Optional[Error] = None - - summary: Optional[bool] = None diff --git a/src/opencode_ai/types/config.py b/src/opencode_ai/types/config.py deleted file mode 100644 index 76f9348..0000000 --- a/src/opencode_ai/types/config.py +++ /dev/null @@ -1,216 +0,0 @@ -# File generated from our OpenAPI spec by Stainless. See CONTRIBUTING.md for details. - -from typing import TYPE_CHECKING, Dict, List, Union, Optional -from typing_extensions import Literal, Annotated, TypeAlias - -from pydantic import Field as FieldInfo - -from .._utils import PropertyInfo -from .._models import BaseModel -from .mode_config import ModeConfig -from .keybinds_config import KeybindsConfig -from .mcp_local_config import McpLocalConfig -from .mcp_remote_config import McpRemoteConfig - -__all__ = [ - "Config", - "Agent", - "AgentGeneral", - "AgentAgentItem", - "Experimental", - "ExperimentalHook", - "ExperimentalHookFileEdited", - "ExperimentalHookSessionCompleted", - "Mcp", - "Mode", - "Provider", - "ProviderModels", - "ProviderModelsCost", - "ProviderModelsLimit", - "ProviderOptions", -] - - -class AgentGeneral(ModeConfig): - description: str - - -class AgentAgentItem(ModeConfig): - description: str - - -class Agent(BaseModel): - general: Optional[AgentGeneral] = None - - __pydantic_extra__: Dict[str, AgentAgentItem] = FieldInfo(init=False) # pyright: ignore[reportIncompatibleVariableOverride] - if TYPE_CHECKING: - # Stub to indicate that arbitrary properties are accepted. - # To access properties that are not valid identifiers you can use `getattr`, e.g. - # `getattr(obj, '$type')` - def __getattr__(self, attr: str) -> AgentAgentItem: ... - - -class ExperimentalHookFileEdited(BaseModel): - command: List[str] - - environment: Optional[Dict[str, str]] = None - - -class ExperimentalHookSessionCompleted(BaseModel): - command: List[str] - - environment: Optional[Dict[str, str]] = None - - -class ExperimentalHook(BaseModel): - file_edited: Optional[Dict[str, List[ExperimentalHookFileEdited]]] = None - - session_completed: Optional[List[ExperimentalHookSessionCompleted]] = None - - -class Experimental(BaseModel): - hook: Optional[ExperimentalHook] = None - - -Mcp: TypeAlias = Annotated[Union[McpLocalConfig, McpRemoteConfig], PropertyInfo(discriminator="type")] - - -class Mode(BaseModel): - build: Optional[ModeConfig] = None - - plan: Optional[ModeConfig] = None - - __pydantic_extra__: Dict[str, ModeConfig] = FieldInfo(init=False) # pyright: ignore[reportIncompatibleVariableOverride] - if TYPE_CHECKING: - # Stub to indicate that arbitrary properties are accepted. - # To access properties that are not valid identifiers you can use `getattr`, e.g. - # `getattr(obj, '$type')` - def __getattr__(self, attr: str) -> ModeConfig: ... - - -class ProviderModelsCost(BaseModel): - input: float - - output: float - - cache_read: Optional[float] = None - - cache_write: Optional[float] = None - - -class ProviderModelsLimit(BaseModel): - context: float - - output: float - - -class ProviderModels(BaseModel): - id: Optional[str] = None - - attachment: Optional[bool] = None - - cost: Optional[ProviderModelsCost] = None - - limit: Optional[ProviderModelsLimit] = None - - name: Optional[str] = None - - options: Optional[Dict[str, object]] = None - - reasoning: Optional[bool] = None - - release_date: Optional[str] = None - - temperature: Optional[bool] = None - - tool_call: Optional[bool] = None - - -class ProviderOptions(BaseModel): - api_key: Optional[str] = FieldInfo(alias="apiKey", default=None) - - base_url: Optional[str] = FieldInfo(alias="baseURL", default=None) - - __pydantic_extra__: Dict[str, object] = FieldInfo(init=False) # pyright: ignore[reportIncompatibleVariableOverride] - if TYPE_CHECKING: - # Stub to indicate that arbitrary properties are accepted. - # To access properties that are not valid identifiers you can use `getattr`, e.g. - # `getattr(obj, '$type')` - def __getattr__(self, attr: str) -> object: ... - - -class Provider(BaseModel): - models: Dict[str, ProviderModels] - - id: Optional[str] = None - - api: Optional[str] = None - - env: Optional[List[str]] = None - - name: Optional[str] = None - - npm: Optional[str] = None - - options: Optional[ProviderOptions] = None - - -class Config(BaseModel): - schema_: Optional[str] = FieldInfo(alias="$schema", default=None) - """JSON schema reference for configuration validation""" - - agent: Optional[Agent] = None - """Modes configuration, see https://opencode.ai/docs/modes""" - - autoshare: Optional[bool] = None - """@deprecated Use 'share' field instead. - - Share newly created sessions automatically - """ - - autoupdate: Optional[bool] = None - """Automatically update to the latest version""" - - disabled_providers: Optional[List[str]] = None - """Disable providers that are loaded automatically""" - - experimental: Optional[Experimental] = None - - instructions: Optional[List[str]] = None - """Additional instruction files or patterns to include""" - - keybinds: Optional[KeybindsConfig] = None - """Custom keybind configurations""" - - layout: Optional[Literal["auto", "stretch"]] = None - """@deprecated Always uses stretch layout.""" - - mcp: Optional[Dict[str, Mcp]] = None - """MCP (Model Context Protocol) server configurations""" - - mode: Optional[Mode] = None - """Modes configuration, see https://opencode.ai/docs/modes""" - - model: Optional[str] = None - """Model to use in the format of provider/model, eg anthropic/claude-2""" - - provider: Optional[Dict[str, Provider]] = None - """Custom provider configurations and model overrides""" - - share: Optional[Literal["manual", "auto", "disabled"]] = None - """ - Control sharing behavior:'manual' allows manual sharing via commands, 'auto' - enables automatic sharing, 'disabled' disables all sharing - """ - - small_model: Optional[str] = None - """ - Small model to use for tasks like summarization and title generation in the - format of provider/model - """ - - theme: Optional[str] = None - """Theme name to use for the interface""" - - username: Optional[str] = None - """Custom username to display in conversations instead of system username""" diff --git a/src/opencode_ai/types/event_list_response.py b/src/opencode_ai/types/event_list_response.py deleted file mode 100644 index fa52d22..0000000 --- a/src/opencode_ai/types/event_list_response.py +++ /dev/null @@ -1,272 +0,0 @@ -# File generated from our OpenAPI spec by Stainless. See CONTRIBUTING.md for details. - -from typing import Dict, Union, Optional -from typing_extensions import Literal, Annotated, TypeAlias - -from pydantic import Field as FieldInfo - -from .part import Part -from .._utils import PropertyInfo -from .message import Message -from .session import Session -from .._models import BaseModel -from .shared.unknown_error import UnknownError -from .shared.provider_auth_error import ProviderAuthError -from .shared.message_aborted_error import MessageAbortedError - -__all__ = [ - "EventListResponse", - "EventInstallationUpdated", - "EventInstallationUpdatedProperties", - "EventLspClientDiagnostics", - "EventLspClientDiagnosticsProperties", - "EventMessageUpdated", - "EventMessageUpdatedProperties", - "EventMessageRemoved", - "EventMessageRemovedProperties", - "EventMessagePartUpdated", - "EventMessagePartUpdatedProperties", - "EventMessagePartRemoved", - "EventMessagePartRemovedProperties", - "EventStorageWrite", - "EventStorageWriteProperties", - "EventPermissionUpdated", - "EventPermissionUpdatedProperties", - "EventPermissionUpdatedPropertiesTime", - "EventFileEdited", - "EventFileEditedProperties", - "EventSessionUpdated", - "EventSessionUpdatedProperties", - "EventSessionDeleted", - "EventSessionDeletedProperties", - "EventSessionIdle", - "EventSessionIdleProperties", - "EventSessionError", - "EventSessionErrorProperties", - "EventSessionErrorPropertiesError", - "EventSessionErrorPropertiesErrorMessageOutputLengthError", - "EventServerConnected", - "EventFileWatcherUpdated", - "EventFileWatcherUpdatedProperties", - "EventIdeInstalled", - "EventIdeInstalledProperties", -] - - -class EventInstallationUpdatedProperties(BaseModel): - version: str - - -class EventInstallationUpdated(BaseModel): - properties: EventInstallationUpdatedProperties - - type: Literal["installation.updated"] - - -class EventLspClientDiagnosticsProperties(BaseModel): - path: str - - server_id: str = FieldInfo(alias="serverID") - - -class EventLspClientDiagnostics(BaseModel): - properties: EventLspClientDiagnosticsProperties - - type: Literal["lsp.client.diagnostics"] - - -class EventMessageUpdatedProperties(BaseModel): - info: Message - - -class EventMessageUpdated(BaseModel): - properties: EventMessageUpdatedProperties - - type: Literal["message.updated"] - - -class EventMessageRemovedProperties(BaseModel): - message_id: str = FieldInfo(alias="messageID") - - session_id: str = FieldInfo(alias="sessionID") - - -class EventMessageRemoved(BaseModel): - properties: EventMessageRemovedProperties - - type: Literal["message.removed"] - - -class EventMessagePartUpdatedProperties(BaseModel): - part: Part - - -class EventMessagePartUpdated(BaseModel): - properties: EventMessagePartUpdatedProperties - - type: Literal["message.part.updated"] - - -class EventMessagePartRemovedProperties(BaseModel): - message_id: str = FieldInfo(alias="messageID") - - part_id: str = FieldInfo(alias="partID") - - session_id: str = FieldInfo(alias="sessionID") - - -class EventMessagePartRemoved(BaseModel): - properties: EventMessagePartRemovedProperties - - type: Literal["message.part.removed"] - - -class EventStorageWriteProperties(BaseModel): - key: str - - content: Optional[object] = None - - -class EventStorageWrite(BaseModel): - properties: EventStorageWriteProperties - - type: Literal["storage.write"] - - -class EventPermissionUpdatedPropertiesTime(BaseModel): - created: float - - -class EventPermissionUpdatedProperties(BaseModel): - id: str - - metadata: Dict[str, object] - - session_id: str = FieldInfo(alias="sessionID") - - time: EventPermissionUpdatedPropertiesTime - - title: str - - -class EventPermissionUpdated(BaseModel): - properties: EventPermissionUpdatedProperties - - type: Literal["permission.updated"] - - -class EventFileEditedProperties(BaseModel): - file: str - - -class EventFileEdited(BaseModel): - properties: EventFileEditedProperties - - type: Literal["file.edited"] - - -class EventSessionUpdatedProperties(BaseModel): - info: Session - - -class EventSessionUpdated(BaseModel): - properties: EventSessionUpdatedProperties - - type: Literal["session.updated"] - - -class EventSessionDeletedProperties(BaseModel): - info: Session - - -class EventSessionDeleted(BaseModel): - properties: EventSessionDeletedProperties - - type: Literal["session.deleted"] - - -class EventSessionIdleProperties(BaseModel): - session_id: str = FieldInfo(alias="sessionID") - - -class EventSessionIdle(BaseModel): - properties: EventSessionIdleProperties - - type: Literal["session.idle"] - - -class EventSessionErrorPropertiesErrorMessageOutputLengthError(BaseModel): - data: object - - name: Literal["MessageOutputLengthError"] - - -EventSessionErrorPropertiesError: TypeAlias = Annotated[ - Union[ - ProviderAuthError, UnknownError, EventSessionErrorPropertiesErrorMessageOutputLengthError, MessageAbortedError - ], - PropertyInfo(discriminator="name"), -] - - -class EventSessionErrorProperties(BaseModel): - error: Optional[EventSessionErrorPropertiesError] = None - - session_id: Optional[str] = FieldInfo(alias="sessionID", default=None) - - -class EventSessionError(BaseModel): - properties: EventSessionErrorProperties - - type: Literal["session.error"] - - -class EventServerConnected(BaseModel): - properties: object - - type: Literal["server.connected"] - - -class EventFileWatcherUpdatedProperties(BaseModel): - event: Literal["rename", "change"] - - file: str - - -class EventFileWatcherUpdated(BaseModel): - properties: EventFileWatcherUpdatedProperties - - type: Literal["file.watcher.updated"] - - -class EventIdeInstalledProperties(BaseModel): - ide: str - - -class EventIdeInstalled(BaseModel): - properties: EventIdeInstalledProperties - - type: Literal["ide.installed"] - - -EventListResponse: TypeAlias = Annotated[ - Union[ - EventInstallationUpdated, - EventLspClientDiagnostics, - EventMessageUpdated, - EventMessageRemoved, - EventMessagePartUpdated, - EventMessagePartRemoved, - EventStorageWrite, - EventPermissionUpdated, - EventFileEdited, - EventSessionUpdated, - EventSessionDeleted, - EventSessionIdle, - EventSessionError, - EventServerConnected, - EventFileWatcherUpdated, - EventIdeInstalled, - ], - PropertyInfo(discriminator="type"), -] diff --git a/src/opencode_ai/types/file.py b/src/opencode_ai/types/file.py deleted file mode 100644 index f156d68..0000000 --- a/src/opencode_ai/types/file.py +++ /dev/null @@ -1,17 +0,0 @@ -# File generated from our OpenAPI spec by Stainless. See CONTRIBUTING.md for details. - -from typing_extensions import Literal - -from .._models import BaseModel - -__all__ = ["File"] - - -class File(BaseModel): - added: int - - path: str - - removed: int - - status: Literal["added", "deleted", "modified"] diff --git a/src/opencode_ai/types/file_part.py b/src/opencode_ai/types/file_part.py deleted file mode 100644 index 42851c9..0000000 --- a/src/opencode_ai/types/file_part.py +++ /dev/null @@ -1,29 +0,0 @@ -# File generated from our OpenAPI spec by Stainless. See CONTRIBUTING.md for details. - -from typing import Optional -from typing_extensions import Literal - -from pydantic import Field as FieldInfo - -from .._models import BaseModel -from .file_part_source import FilePartSource - -__all__ = ["FilePart"] - - -class FilePart(BaseModel): - id: str - - message_id: str = FieldInfo(alias="messageID") - - mime: str - - session_id: str = FieldInfo(alias="sessionID") - - type: Literal["file"] - - url: str - - filename: Optional[str] = None - - source: Optional[FilePartSource] = None diff --git a/src/opencode_ai/types/file_part_input_param.py b/src/opencode_ai/types/file_part_input_param.py deleted file mode 100644 index 96325e0..0000000 --- a/src/opencode_ai/types/file_part_input_param.py +++ /dev/null @@ -1,23 +0,0 @@ -# File generated from our OpenAPI spec by Stainless. See CONTRIBUTING.md for details. - -from __future__ import annotations - -from typing_extensions import Literal, Required, TypedDict - -from .file_part_source_param import FilePartSourceParam - -__all__ = ["FilePartInputParam"] - - -class FilePartInputParam(TypedDict, total=False): - mime: Required[str] - - type: Required[Literal["file"]] - - url: Required[str] - - id: str - - filename: str - - source: FilePartSourceParam diff --git a/src/opencode_ai/types/file_part_source.py b/src/opencode_ai/types/file_part_source.py deleted file mode 100644 index 25cb97d..0000000 --- a/src/opencode_ai/types/file_part_source.py +++ /dev/null @@ -1,12 +0,0 @@ -# File generated from our OpenAPI spec by Stainless. See CONTRIBUTING.md for details. - -from typing import Union -from typing_extensions import Annotated, TypeAlias - -from .._utils import PropertyInfo -from .file_source import FileSource -from .symbol_source import SymbolSource - -__all__ = ["FilePartSource"] - -FilePartSource: TypeAlias = Annotated[Union[FileSource, SymbolSource], PropertyInfo(discriminator="type")] diff --git a/src/opencode_ai/types/file_part_source_param.py b/src/opencode_ai/types/file_part_source_param.py deleted file mode 100644 index 7b5bcbb..0000000 --- a/src/opencode_ai/types/file_part_source_param.py +++ /dev/null @@ -1,13 +0,0 @@ -# File generated from our OpenAPI spec by Stainless. See CONTRIBUTING.md for details. - -from __future__ import annotations - -from typing import Union -from typing_extensions import TypeAlias - -from .file_source_param import FileSourceParam -from .symbol_source_param import SymbolSourceParam - -__all__ = ["FilePartSourceParam"] - -FilePartSourceParam: TypeAlias = Union[FileSourceParam, SymbolSourceParam] diff --git a/src/opencode_ai/types/file_part_source_text.py b/src/opencode_ai/types/file_part_source_text.py deleted file mode 100644 index 95af821..0000000 --- a/src/opencode_ai/types/file_part_source_text.py +++ /dev/null @@ -1,13 +0,0 @@ -# File generated from our OpenAPI spec by Stainless. See CONTRIBUTING.md for details. - -from .._models import BaseModel - -__all__ = ["FilePartSourceText"] - - -class FilePartSourceText(BaseModel): - end: int - - start: int - - value: str diff --git a/src/opencode_ai/types/file_part_source_text_param.py b/src/opencode_ai/types/file_part_source_text_param.py deleted file mode 100644 index 40d94bc..0000000 --- a/src/opencode_ai/types/file_part_source_text_param.py +++ /dev/null @@ -1,15 +0,0 @@ -# File generated from our OpenAPI spec by Stainless. See CONTRIBUTING.md for details. - -from __future__ import annotations - -from typing_extensions import Required, TypedDict - -__all__ = ["FilePartSourceTextParam"] - - -class FilePartSourceTextParam(TypedDict, total=False): - end: Required[int] - - start: Required[int] - - value: Required[str] diff --git a/src/opencode_ai/types/file_read_params.py b/src/opencode_ai/types/file_read_params.py deleted file mode 100644 index b251a07..0000000 --- a/src/opencode_ai/types/file_read_params.py +++ /dev/null @@ -1,11 +0,0 @@ -# File generated from our OpenAPI spec by Stainless. See CONTRIBUTING.md for details. - -from __future__ import annotations - -from typing_extensions import Required, TypedDict - -__all__ = ["FileReadParams"] - - -class FileReadParams(TypedDict, total=False): - path: Required[str] diff --git a/src/opencode_ai/types/file_read_response.py b/src/opencode_ai/types/file_read_response.py deleted file mode 100644 index 5392a07..0000000 --- a/src/opencode_ai/types/file_read_response.py +++ /dev/null @@ -1,13 +0,0 @@ -# File generated from our OpenAPI spec by Stainless. See CONTRIBUTING.md for details. - -from typing_extensions import Literal - -from .._models import BaseModel - -__all__ = ["FileReadResponse"] - - -class FileReadResponse(BaseModel): - content: str - - type: Literal["raw", "patch"] diff --git a/src/opencode_ai/types/file_source.py b/src/opencode_ai/types/file_source.py deleted file mode 100644 index fd5f328..0000000 --- a/src/opencode_ai/types/file_source.py +++ /dev/null @@ -1,16 +0,0 @@ -# File generated from our OpenAPI spec by Stainless. See CONTRIBUTING.md for details. - -from typing_extensions import Literal - -from .._models import BaseModel -from .file_part_source_text import FilePartSourceText - -__all__ = ["FileSource"] - - -class FileSource(BaseModel): - path: str - - text: FilePartSourceText - - type: Literal["file"] diff --git a/src/opencode_ai/types/file_source_param.py b/src/opencode_ai/types/file_source_param.py deleted file mode 100644 index caf14a5..0000000 --- a/src/opencode_ai/types/file_source_param.py +++ /dev/null @@ -1,17 +0,0 @@ -# File generated from our OpenAPI spec by Stainless. See CONTRIBUTING.md for details. - -from __future__ import annotations - -from typing_extensions import Literal, Required, TypedDict - -from .file_part_source_text_param import FilePartSourceTextParam - -__all__ = ["FileSourceParam"] - - -class FileSourceParam(TypedDict, total=False): - path: Required[str] - - text: Required[FilePartSourceTextParam] - - type: Required[Literal["file"]] diff --git a/src/opencode_ai/types/file_status_response.py b/src/opencode_ai/types/file_status_response.py deleted file mode 100644 index 34a602b..0000000 --- a/src/opencode_ai/types/file_status_response.py +++ /dev/null @@ -1,10 +0,0 @@ -# File generated from our OpenAPI spec by Stainless. See CONTRIBUTING.md for details. - -from typing import List -from typing_extensions import TypeAlias - -from .file import File - -__all__ = ["FileStatusResponse"] - -FileStatusResponse: TypeAlias = List[File] diff --git a/src/opencode_ai/types/find_files_params.py b/src/opencode_ai/types/find_files_params.py deleted file mode 100644 index c2b3093..0000000 --- a/src/opencode_ai/types/find_files_params.py +++ /dev/null @@ -1,11 +0,0 @@ -# File generated from our OpenAPI spec by Stainless. See CONTRIBUTING.md for details. - -from __future__ import annotations - -from typing_extensions import Required, TypedDict - -__all__ = ["FindFilesParams"] - - -class FindFilesParams(TypedDict, total=False): - query: Required[str] diff --git a/src/opencode_ai/types/find_files_response.py b/src/opencode_ai/types/find_files_response.py deleted file mode 100644 index 2b408de..0000000 --- a/src/opencode_ai/types/find_files_response.py +++ /dev/null @@ -1,8 +0,0 @@ -# File generated from our OpenAPI spec by Stainless. See CONTRIBUTING.md for details. - -from typing import List -from typing_extensions import TypeAlias - -__all__ = ["FindFilesResponse"] - -FindFilesResponse: TypeAlias = List[str] diff --git a/src/opencode_ai/types/find_symbols_params.py b/src/opencode_ai/types/find_symbols_params.py deleted file mode 100644 index 60f9e9e..0000000 --- a/src/opencode_ai/types/find_symbols_params.py +++ /dev/null @@ -1,11 +0,0 @@ -# File generated from our OpenAPI spec by Stainless. See CONTRIBUTING.md for details. - -from __future__ import annotations - -from typing_extensions import Required, TypedDict - -__all__ = ["FindSymbolsParams"] - - -class FindSymbolsParams(TypedDict, total=False): - query: Required[str] diff --git a/src/opencode_ai/types/find_symbols_response.py b/src/opencode_ai/types/find_symbols_response.py deleted file mode 100644 index a0bc12a..0000000 --- a/src/opencode_ai/types/find_symbols_response.py +++ /dev/null @@ -1,10 +0,0 @@ -# File generated from our OpenAPI spec by Stainless. See CONTRIBUTING.md for details. - -from typing import List -from typing_extensions import TypeAlias - -from .symbol import Symbol - -__all__ = ["FindSymbolsResponse"] - -FindSymbolsResponse: TypeAlias = List[Symbol] diff --git a/src/opencode_ai/types/find_text_params.py b/src/opencode_ai/types/find_text_params.py deleted file mode 100644 index f35ea4f..0000000 --- a/src/opencode_ai/types/find_text_params.py +++ /dev/null @@ -1,11 +0,0 @@ -# File generated from our OpenAPI spec by Stainless. See CONTRIBUTING.md for details. - -from __future__ import annotations - -from typing_extensions import Required, TypedDict - -__all__ = ["FindTextParams"] - - -class FindTextParams(TypedDict, total=False): - pattern: Required[str] diff --git a/src/opencode_ai/types/find_text_response.py b/src/opencode_ai/types/find_text_response.py deleted file mode 100644 index 4557834..0000000 --- a/src/opencode_ai/types/find_text_response.py +++ /dev/null @@ -1,50 +0,0 @@ -# File generated from our OpenAPI spec by Stainless. See CONTRIBUTING.md for details. - -from typing import List -from typing_extensions import TypeAlias - -from .._models import BaseModel - -__all__ = [ - "FindTextResponse", - "FindTextResponseItem", - "FindTextResponseItemLines", - "FindTextResponseItemPath", - "FindTextResponseItemSubmatch", - "FindTextResponseItemSubmatchMatch", -] - - -class FindTextResponseItemLines(BaseModel): - text: str - - -class FindTextResponseItemPath(BaseModel): - text: str - - -class FindTextResponseItemSubmatchMatch(BaseModel): - text: str - - -class FindTextResponseItemSubmatch(BaseModel): - end: float - - match: FindTextResponseItemSubmatchMatch - - start: float - - -class FindTextResponseItem(BaseModel): - absolute_offset: float - - line_number: float - - lines: FindTextResponseItemLines - - path: FindTextResponseItemPath - - submatches: List[FindTextResponseItemSubmatch] - - -FindTextResponse: TypeAlias = List[FindTextResponseItem] diff --git a/src/opencode_ai/types/keybinds_config.py b/src/opencode_ai/types/keybinds_config.py deleted file mode 100644 index c7cb57c..0000000 --- a/src/opencode_ai/types/keybinds_config.py +++ /dev/null @@ -1,123 +0,0 @@ -# File generated from our OpenAPI spec by Stainless. See CONTRIBUTING.md for details. - -from pydantic import Field as FieldInfo - -from .._models import BaseModel - -__all__ = ["KeybindsConfig"] - - -class KeybindsConfig(BaseModel): - app_exit: str - """Exit the application""" - - app_help: str - """Show help dialog""" - - editor_open: str - """Open external editor""" - - file_close: str - """Close file""" - - file_diff_toggle: str - """Split/unified diff""" - - file_list: str - """List files""" - - file_search: str - """Search file""" - - input_clear: str - """Clear input field""" - - input_newline: str - """Insert newline in input""" - - input_paste: str - """Paste from clipboard""" - - input_submit: str - """Submit input""" - - leader: str - """Leader key for keybind combinations""" - - messages_copy: str - """Copy message""" - - messages_first: str - """Navigate to first message""" - - messages_half_page_down: str - """Scroll messages down by half page""" - - messages_half_page_up: str - """Scroll messages up by half page""" - - messages_last: str - """Navigate to last message""" - - messages_layout_toggle: str - """Toggle layout""" - - messages_next: str - """Navigate to next message""" - - messages_page_down: str - """Scroll messages down by one page""" - - messages_page_up: str - """Scroll messages up by one page""" - - messages_previous: str - """Navigate to previous message""" - - messages_redo: str - """Redo message""" - - messages_revert: str - """@deprecated use messages_undo. Revert message""" - - messages_undo: str - """Undo message""" - - api_model_list: str = FieldInfo(alias="model_list") - """List available models""" - - project_init: str - """Create/update AGENTS.md""" - - session_compact: str - """Compact the session""" - - session_export: str - """Export session to editor""" - - session_interrupt: str - """Interrupt current session""" - - session_list: str - """List all sessions""" - - session_new: str - """Create a new session""" - - session_share: str - """Share current session""" - - session_unshare: str - """Unshare current session""" - - switch_mode: str - """Next mode""" - - switch_mode_reverse: str - """Previous Mode""" - - theme_list: str - """List available themes""" - - tool_details: str - """Toggle tool details""" diff --git a/src/opencode_ai/types/mcp_local_config.py b/src/opencode_ai/types/mcp_local_config.py deleted file mode 100644 index 27118c2..0000000 --- a/src/opencode_ai/types/mcp_local_config.py +++ /dev/null @@ -1,22 +0,0 @@ -# File generated from our OpenAPI spec by Stainless. See CONTRIBUTING.md for details. - -from typing import Dict, List, Optional -from typing_extensions import Literal - -from .._models import BaseModel - -__all__ = ["McpLocalConfig"] - - -class McpLocalConfig(BaseModel): - command: List[str] - """Command and arguments to run the MCP server""" - - type: Literal["local"] - """Type of MCP server connection""" - - enabled: Optional[bool] = None - """Enable or disable the MCP server on startup""" - - environment: Optional[Dict[str, str]] = None - """Environment variables to set when running the MCP server""" diff --git a/src/opencode_ai/types/mcp_remote_config.py b/src/opencode_ai/types/mcp_remote_config.py deleted file mode 100644 index 6863ec7..0000000 --- a/src/opencode_ai/types/mcp_remote_config.py +++ /dev/null @@ -1,22 +0,0 @@ -# File generated from our OpenAPI spec by Stainless. See CONTRIBUTING.md for details. - -from typing import Dict, Optional -from typing_extensions import Literal - -from .._models import BaseModel - -__all__ = ["McpRemoteConfig"] - - -class McpRemoteConfig(BaseModel): - type: Literal["remote"] - """Type of MCP server connection""" - - url: str - """URL of the remote MCP server""" - - enabled: Optional[bool] = None - """Enable or disable the MCP server on startup""" - - headers: Optional[Dict[str, str]] = None - """Headers to send with the request""" diff --git a/src/opencode_ai/types/message.py b/src/opencode_ai/types/message.py deleted file mode 100644 index 6e27c8d..0000000 --- a/src/opencode_ai/types/message.py +++ /dev/null @@ -1,12 +0,0 @@ -# File generated from our OpenAPI spec by Stainless. See CONTRIBUTING.md for details. - -from typing import Union -from typing_extensions import Annotated, TypeAlias - -from .._utils import PropertyInfo -from .user_message import UserMessage -from .assistant_message import AssistantMessage - -__all__ = ["Message"] - -Message: TypeAlias = Annotated[Union[UserMessage, AssistantMessage], PropertyInfo(discriminator="role")] diff --git a/src/opencode_ai/types/mode.py b/src/opencode_ai/types/mode.py deleted file mode 100644 index 041b7f3..0000000 --- a/src/opencode_ai/types/mode.py +++ /dev/null @@ -1,27 +0,0 @@ -# File generated from our OpenAPI spec by Stainless. See CONTRIBUTING.md for details. - -from typing import Dict, Optional - -from pydantic import Field as FieldInfo - -from .._models import BaseModel - -__all__ = ["Mode", "Model"] - - -class Model(BaseModel): - api_model_id: str = FieldInfo(alias="modelID") - - provider_id: str = FieldInfo(alias="providerID") - - -class Mode(BaseModel): - name: str - - tools: Dict[str, bool] - - model: Optional[Model] = None - - prompt: Optional[str] = None - - temperature: Optional[float] = None diff --git a/src/opencode_ai/types/mode_config.py b/src/opencode_ai/types/mode_config.py deleted file mode 100644 index d7561c2..0000000 --- a/src/opencode_ai/types/mode_config.py +++ /dev/null @@ -1,19 +0,0 @@ -# File generated from our OpenAPI spec by Stainless. See CONTRIBUTING.md for details. - -from typing import Dict, Optional - -from .._models import BaseModel - -__all__ = ["ModeConfig"] - - -class ModeConfig(BaseModel): - disable: Optional[bool] = None - - model: Optional[str] = None - - prompt: Optional[str] = None - - temperature: Optional[float] = None - - tools: Optional[Dict[str, bool]] = None diff --git a/src/opencode_ai/types/model.py b/src/opencode_ai/types/model.py deleted file mode 100644 index 32fc15a..0000000 --- a/src/opencode_ai/types/model.py +++ /dev/null @@ -1,45 +0,0 @@ -# File generated from our OpenAPI spec by Stainless. See CONTRIBUTING.md for details. - -from typing import Dict, Optional - -from .._models import BaseModel - -__all__ = ["Model", "Cost", "Limit"] - - -class Cost(BaseModel): - input: float - - output: float - - cache_read: Optional[float] = None - - cache_write: Optional[float] = None - - -class Limit(BaseModel): - context: float - - output: float - - -class Model(BaseModel): - id: str - - attachment: bool - - cost: Cost - - limit: Limit - - name: str - - options: Dict[str, object] - - reasoning: bool - - release_date: str - - temperature: bool - - tool_call: bool diff --git a/src/opencode_ai/types/part.py b/src/opencode_ai/types/part.py deleted file mode 100644 index 5a74aa5..0000000 --- a/src/opencode_ai/types/part.py +++ /dev/null @@ -1,37 +0,0 @@ -# File generated from our OpenAPI spec by Stainless. See CONTRIBUTING.md for details. - -from typing import List, Union -from typing_extensions import Literal, Annotated, TypeAlias - -from pydantic import Field as FieldInfo - -from .._utils import PropertyInfo -from .._models import BaseModel -from .file_part import FilePart -from .text_part import TextPart -from .tool_part import ToolPart -from .snapshot_part import SnapshotPart -from .step_start_part import StepStartPart -from .step_finish_part import StepFinishPart - -__all__ = ["Part", "PatchPart"] - - -class PatchPart(BaseModel): - id: str - - files: List[str] - - hash: str - - message_id: str = FieldInfo(alias="messageID") - - session_id: str = FieldInfo(alias="sessionID") - - type: Literal["patch"] - - -Part: TypeAlias = Annotated[ - Union[TextPart, FilePart, ToolPart, StepStartPart, StepFinishPart, SnapshotPart, PatchPart], - PropertyInfo(discriminator="type"), -] diff --git a/src/opencode_ai/types/provider.py b/src/opencode_ai/types/provider.py deleted file mode 100644 index ce04398..0000000 --- a/src/opencode_ai/types/provider.py +++ /dev/null @@ -1,22 +0,0 @@ -# File generated from our OpenAPI spec by Stainless. See CONTRIBUTING.md for details. - -from typing import Dict, List, Optional - -from .model import Model -from .._models import BaseModel - -__all__ = ["Provider"] - - -class Provider(BaseModel): - id: str - - env: List[str] - - models: Dict[str, Model] - - name: str - - api: Optional[str] = None - - npm: Optional[str] = None diff --git a/src/opencode_ai/types/session.py b/src/opencode_ai/types/session.py deleted file mode 100644 index a0ed929..0000000 --- a/src/opencode_ai/types/session.py +++ /dev/null @@ -1,45 +0,0 @@ -# File generated from our OpenAPI spec by Stainless. See CONTRIBUTING.md for details. - -from typing import Optional - -from pydantic import Field as FieldInfo - -from .._models import BaseModel - -__all__ = ["Session", "Time", "Revert", "Share"] - - -class Time(BaseModel): - created: float - - updated: float - - -class Revert(BaseModel): - message_id: str = FieldInfo(alias="messageID") - - diff: Optional[str] = None - - part_id: Optional[str] = FieldInfo(alias="partID", default=None) - - snapshot: Optional[str] = None - - -class Share(BaseModel): - url: str - - -class Session(BaseModel): - id: str - - time: Time - - title: str - - version: str - - parent_id: Optional[str] = FieldInfo(alias="parentID", default=None) - - revert: Optional[Revert] = None - - share: Optional[Share] = None diff --git a/src/opencode_ai/types/session_abort_response.py b/src/opencode_ai/types/session_abort_response.py deleted file mode 100644 index 544a0b2..0000000 --- a/src/opencode_ai/types/session_abort_response.py +++ /dev/null @@ -1,7 +0,0 @@ -# File generated from our OpenAPI spec by Stainless. See CONTRIBUTING.md for details. - -from typing_extensions import TypeAlias - -__all__ = ["SessionAbortResponse"] - -SessionAbortResponse: TypeAlias = bool diff --git a/src/opencode_ai/types/session_chat_params.py b/src/opencode_ai/types/session_chat_params.py deleted file mode 100644 index e827282..0000000 --- a/src/opencode_ai/types/session_chat_params.py +++ /dev/null @@ -1,31 +0,0 @@ -# File generated from our OpenAPI spec by Stainless. See CONTRIBUTING.md for details. - -from __future__ import annotations - -from typing import Dict, Union, Iterable -from typing_extensions import Required, Annotated, TypeAlias, TypedDict - -from .._utils import PropertyInfo -from .file_part_input_param import FilePartInputParam -from .text_part_input_param import TextPartInputParam - -__all__ = ["SessionChatParams", "Part"] - - -class SessionChatParams(TypedDict, total=False): - model_id: Required[Annotated[str, PropertyInfo(alias="modelID")]] - - parts: Required[Iterable[Part]] - - provider_id: Required[Annotated[str, PropertyInfo(alias="providerID")]] - - message_id: Annotated[str, PropertyInfo(alias="messageID")] - - mode: str - - system: str - - tools: Dict[str, bool] - - -Part: TypeAlias = Union[TextPartInputParam, FilePartInputParam] diff --git a/src/opencode_ai/types/session_delete_response.py b/src/opencode_ai/types/session_delete_response.py deleted file mode 100644 index 0ebd0ad..0000000 --- a/src/opencode_ai/types/session_delete_response.py +++ /dev/null @@ -1,7 +0,0 @@ -# File generated from our OpenAPI spec by Stainless. See CONTRIBUTING.md for details. - -from typing_extensions import TypeAlias - -__all__ = ["SessionDeleteResponse"] - -SessionDeleteResponse: TypeAlias = bool diff --git a/src/opencode_ai/types/session_init_params.py b/src/opencode_ai/types/session_init_params.py deleted file mode 100644 index 8d6d0d8..0000000 --- a/src/opencode_ai/types/session_init_params.py +++ /dev/null @@ -1,17 +0,0 @@ -# File generated from our OpenAPI spec by Stainless. See CONTRIBUTING.md for details. - -from __future__ import annotations - -from typing_extensions import Required, Annotated, TypedDict - -from .._utils import PropertyInfo - -__all__ = ["SessionInitParams"] - - -class SessionInitParams(TypedDict, total=False): - message_id: Required[Annotated[str, PropertyInfo(alias="messageID")]] - - model_id: Required[Annotated[str, PropertyInfo(alias="modelID")]] - - provider_id: Required[Annotated[str, PropertyInfo(alias="providerID")]] diff --git a/src/opencode_ai/types/session_init_response.py b/src/opencode_ai/types/session_init_response.py deleted file mode 100644 index 69fcc6b..0000000 --- a/src/opencode_ai/types/session_init_response.py +++ /dev/null @@ -1,7 +0,0 @@ -# File generated from our OpenAPI spec by Stainless. See CONTRIBUTING.md for details. - -from typing_extensions import TypeAlias - -__all__ = ["SessionInitResponse"] - -SessionInitResponse: TypeAlias = bool diff --git a/src/opencode_ai/types/session_list_response.py b/src/opencode_ai/types/session_list_response.py deleted file mode 100644 index ca162dd..0000000 --- a/src/opencode_ai/types/session_list_response.py +++ /dev/null @@ -1,10 +0,0 @@ -# File generated from our OpenAPI spec by Stainless. See CONTRIBUTING.md for details. - -from typing import List -from typing_extensions import TypeAlias - -from .session import Session - -__all__ = ["SessionListResponse"] - -SessionListResponse: TypeAlias = List[Session] diff --git a/src/opencode_ai/types/session_messages_response.py b/src/opencode_ai/types/session_messages_response.py deleted file mode 100644 index 6eb8692..0000000 --- a/src/opencode_ai/types/session_messages_response.py +++ /dev/null @@ -1,19 +0,0 @@ -# File generated from our OpenAPI spec by Stainless. See CONTRIBUTING.md for details. - -from typing import List -from typing_extensions import TypeAlias - -from .part import Part -from .message import Message -from .._models import BaseModel - -__all__ = ["SessionMessagesResponse", "SessionMessagesResponseItem"] - - -class SessionMessagesResponseItem(BaseModel): - info: Message - - parts: List[Part] - - -SessionMessagesResponse: TypeAlias = List[SessionMessagesResponseItem] diff --git a/src/opencode_ai/types/session_revert_params.py b/src/opencode_ai/types/session_revert_params.py deleted file mode 100644 index e5361ee..0000000 --- a/src/opencode_ai/types/session_revert_params.py +++ /dev/null @@ -1,15 +0,0 @@ -# File generated from our OpenAPI spec by Stainless. See CONTRIBUTING.md for details. - -from __future__ import annotations - -from typing_extensions import Required, Annotated, TypedDict - -from .._utils import PropertyInfo - -__all__ = ["SessionRevertParams"] - - -class SessionRevertParams(TypedDict, total=False): - message_id: Required[Annotated[str, PropertyInfo(alias="messageID")]] - - part_id: Annotated[str, PropertyInfo(alias="partID")] diff --git a/src/opencode_ai/types/session_summarize_params.py b/src/opencode_ai/types/session_summarize_params.py deleted file mode 100644 index 46e3aa2..0000000 --- a/src/opencode_ai/types/session_summarize_params.py +++ /dev/null @@ -1,15 +0,0 @@ -# File generated from our OpenAPI spec by Stainless. See CONTRIBUTING.md for details. - -from __future__ import annotations - -from typing_extensions import Required, Annotated, TypedDict - -from .._utils import PropertyInfo - -__all__ = ["SessionSummarizeParams"] - - -class SessionSummarizeParams(TypedDict, total=False): - model_id: Required[Annotated[str, PropertyInfo(alias="modelID")]] - - provider_id: Required[Annotated[str, PropertyInfo(alias="providerID")]] diff --git a/src/opencode_ai/types/session_summarize_response.py b/src/opencode_ai/types/session_summarize_response.py deleted file mode 100644 index 5165232..0000000 --- a/src/opencode_ai/types/session_summarize_response.py +++ /dev/null @@ -1,7 +0,0 @@ -# File generated from our OpenAPI spec by Stainless. See CONTRIBUTING.md for details. - -from typing_extensions import TypeAlias - -__all__ = ["SessionSummarizeResponse"] - -SessionSummarizeResponse: TypeAlias = bool diff --git a/src/opencode_ai/types/shared/__init__.py b/src/opencode_ai/types/shared/__init__.py deleted file mode 100644 index bc579a8..0000000 --- a/src/opencode_ai/types/shared/__init__.py +++ /dev/null @@ -1,5 +0,0 @@ -# File generated from our OpenAPI spec by Stainless. See CONTRIBUTING.md for details. - -from .unknown_error import UnknownError as UnknownError -from .provider_auth_error import ProviderAuthError as ProviderAuthError -from .message_aborted_error import MessageAbortedError as MessageAbortedError diff --git a/src/opencode_ai/types/shared/message_aborted_error.py b/src/opencode_ai/types/shared/message_aborted_error.py deleted file mode 100644 index 9ffdcaa..0000000 --- a/src/opencode_ai/types/shared/message_aborted_error.py +++ /dev/null @@ -1,13 +0,0 @@ -# File generated from our OpenAPI spec by Stainless. See CONTRIBUTING.md for details. - -from typing_extensions import Literal - -from ..._models import BaseModel - -__all__ = ["MessageAbortedError"] - - -class MessageAbortedError(BaseModel): - data: object - - name: Literal["MessageAbortedError"] diff --git a/src/opencode_ai/types/shared/provider_auth_error.py b/src/opencode_ai/types/shared/provider_auth_error.py deleted file mode 100644 index 7ce4908..0000000 --- a/src/opencode_ai/types/shared/provider_auth_error.py +++ /dev/null @@ -1,21 +0,0 @@ -# File generated from our OpenAPI spec by Stainless. See CONTRIBUTING.md for details. - -from typing_extensions import Literal - -from pydantic import Field as FieldInfo - -from ..._models import BaseModel - -__all__ = ["ProviderAuthError", "Data"] - - -class Data(BaseModel): - message: str - - provider_id: str = FieldInfo(alias="providerID") - - -class ProviderAuthError(BaseModel): - data: Data - - name: Literal["ProviderAuthError"] diff --git a/src/opencode_ai/types/shared/unknown_error.py b/src/opencode_ai/types/shared/unknown_error.py deleted file mode 100644 index 240d359..0000000 --- a/src/opencode_ai/types/shared/unknown_error.py +++ /dev/null @@ -1,17 +0,0 @@ -# File generated from our OpenAPI spec by Stainless. See CONTRIBUTING.md for details. - -from typing_extensions import Literal - -from ..._models import BaseModel - -__all__ = ["UnknownError", "Data"] - - -class Data(BaseModel): - message: str - - -class UnknownError(BaseModel): - data: Data - - name: Literal["UnknownError"] diff --git a/src/opencode_ai/types/snapshot_part.py b/src/opencode_ai/types/snapshot_part.py deleted file mode 100644 index 485f47b..0000000 --- a/src/opencode_ai/types/snapshot_part.py +++ /dev/null @@ -1,21 +0,0 @@ -# File generated from our OpenAPI spec by Stainless. See CONTRIBUTING.md for details. - -from typing_extensions import Literal - -from pydantic import Field as FieldInfo - -from .._models import BaseModel - -__all__ = ["SnapshotPart"] - - -class SnapshotPart(BaseModel): - id: str - - message_id: str = FieldInfo(alias="messageID") - - session_id: str = FieldInfo(alias="sessionID") - - snapshot: str - - type: Literal["snapshot"] diff --git a/src/opencode_ai/types/step_finish_part.py b/src/opencode_ai/types/step_finish_part.py deleted file mode 100644 index b9f5b4b..0000000 --- a/src/opencode_ai/types/step_finish_part.py +++ /dev/null @@ -1,39 +0,0 @@ -# File generated from our OpenAPI spec by Stainless. See CONTRIBUTING.md for details. - -from typing_extensions import Literal - -from pydantic import Field as FieldInfo - -from .._models import BaseModel - -__all__ = ["StepFinishPart", "Tokens", "TokensCache"] - - -class TokensCache(BaseModel): - read: float - - write: float - - -class Tokens(BaseModel): - cache: TokensCache - - input: float - - output: float - - reasoning: float - - -class StepFinishPart(BaseModel): - id: str - - cost: float - - message_id: str = FieldInfo(alias="messageID") - - session_id: str = FieldInfo(alias="sessionID") - - tokens: Tokens - - type: Literal["step-finish"] diff --git a/src/opencode_ai/types/step_start_part.py b/src/opencode_ai/types/step_start_part.py deleted file mode 100644 index 6c9e0df..0000000 --- a/src/opencode_ai/types/step_start_part.py +++ /dev/null @@ -1,19 +0,0 @@ -# File generated from our OpenAPI spec by Stainless. See CONTRIBUTING.md for details. - -from typing_extensions import Literal - -from pydantic import Field as FieldInfo - -from .._models import BaseModel - -__all__ = ["StepStartPart"] - - -class StepStartPart(BaseModel): - id: str - - message_id: str = FieldInfo(alias="messageID") - - session_id: str = FieldInfo(alias="sessionID") - - type: Literal["step-start"] diff --git a/src/opencode_ai/types/symbol.py b/src/opencode_ai/types/symbol.py deleted file mode 100644 index c7d1f99..0000000 --- a/src/opencode_ai/types/symbol.py +++ /dev/null @@ -1,37 +0,0 @@ -# File generated from our OpenAPI spec by Stainless. See CONTRIBUTING.md for details. - -from .._models import BaseModel - -__all__ = ["Symbol", "Location", "LocationRange", "LocationRangeEnd", "LocationRangeStart"] - - -class LocationRangeEnd(BaseModel): - character: float - - line: float - - -class LocationRangeStart(BaseModel): - character: float - - line: float - - -class LocationRange(BaseModel): - end: LocationRangeEnd - - start: LocationRangeStart - - -class Location(BaseModel): - range: LocationRange - - uri: str - - -class Symbol(BaseModel): - kind: float - - location: Location - - name: str diff --git a/src/opencode_ai/types/symbol_source.py b/src/opencode_ai/types/symbol_source.py deleted file mode 100644 index f8982d8..0000000 --- a/src/opencode_ai/types/symbol_source.py +++ /dev/null @@ -1,40 +0,0 @@ -# File generated from our OpenAPI spec by Stainless. See CONTRIBUTING.md for details. - -from typing_extensions import Literal - -from .._models import BaseModel -from .file_part_source_text import FilePartSourceText - -__all__ = ["SymbolSource", "Range", "RangeEnd", "RangeStart"] - - -class RangeEnd(BaseModel): - character: float - - line: float - - -class RangeStart(BaseModel): - character: float - - line: float - - -class Range(BaseModel): - end: RangeEnd - - start: RangeStart - - -class SymbolSource(BaseModel): - kind: int - - name: str - - path: str - - range: Range - - text: FilePartSourceText - - type: Literal["symbol"] diff --git a/src/opencode_ai/types/symbol_source_param.py b/src/opencode_ai/types/symbol_source_param.py deleted file mode 100644 index b1ec7a0..0000000 --- a/src/opencode_ai/types/symbol_source_param.py +++ /dev/null @@ -1,41 +0,0 @@ -# File generated from our OpenAPI spec by Stainless. See CONTRIBUTING.md for details. - -from __future__ import annotations - -from typing_extensions import Literal, Required, TypedDict - -from .file_part_source_text_param import FilePartSourceTextParam - -__all__ = ["SymbolSourceParam", "Range", "RangeEnd", "RangeStart"] - - -class RangeEnd(TypedDict, total=False): - character: Required[float] - - line: Required[float] - - -class RangeStart(TypedDict, total=False): - character: Required[float] - - line: Required[float] - - -class Range(TypedDict, total=False): - end: Required[RangeEnd] - - start: Required[RangeStart] - - -class SymbolSourceParam(TypedDict, total=False): - kind: Required[int] - - name: Required[str] - - path: Required[str] - - range: Required[Range] - - text: Required[FilePartSourceTextParam] - - type: Required[Literal["symbol"]] diff --git a/src/opencode_ai/types/text_part.py b/src/opencode_ai/types/text_part.py deleted file mode 100644 index 514f409..0000000 --- a/src/opencode_ai/types/text_part.py +++ /dev/null @@ -1,32 +0,0 @@ -# File generated from our OpenAPI spec by Stainless. See CONTRIBUTING.md for details. - -from typing import Optional -from typing_extensions import Literal - -from pydantic import Field as FieldInfo - -from .._models import BaseModel - -__all__ = ["TextPart", "Time"] - - -class Time(BaseModel): - start: float - - end: Optional[float] = None - - -class TextPart(BaseModel): - id: str - - message_id: str = FieldInfo(alias="messageID") - - session_id: str = FieldInfo(alias="sessionID") - - text: str - - type: Literal["text"] - - synthetic: Optional[bool] = None - - time: Optional[Time] = None diff --git a/src/opencode_ai/types/text_part_input_param.py b/src/opencode_ai/types/text_part_input_param.py deleted file mode 100644 index 2850484..0000000 --- a/src/opencode_ai/types/text_part_input_param.py +++ /dev/null @@ -1,25 +0,0 @@ -# File generated from our OpenAPI spec by Stainless. See CONTRIBUTING.md for details. - -from __future__ import annotations - -from typing_extensions import Literal, Required, TypedDict - -__all__ = ["TextPartInputParam", "Time"] - - -class Time(TypedDict, total=False): - start: Required[float] - - end: float - - -class TextPartInputParam(TypedDict, total=False): - text: Required[str] - - type: Required[Literal["text"]] - - id: str - - synthetic: bool - - time: Time diff --git a/src/opencode_ai/types/tool_part.py b/src/opencode_ai/types/tool_part.py deleted file mode 100644 index 2de8ed9..0000000 --- a/src/opencode_ai/types/tool_part.py +++ /dev/null @@ -1,35 +0,0 @@ -# File generated from our OpenAPI spec by Stainless. See CONTRIBUTING.md for details. - -from typing import Union -from typing_extensions import Literal, Annotated, TypeAlias - -from pydantic import Field as FieldInfo - -from .._utils import PropertyInfo -from .._models import BaseModel -from .tool_state_error import ToolStateError -from .tool_state_pending import ToolStatePending -from .tool_state_running import ToolStateRunning -from .tool_state_completed import ToolStateCompleted - -__all__ = ["ToolPart", "State"] - -State: TypeAlias = Annotated[ - Union[ToolStatePending, ToolStateRunning, ToolStateCompleted, ToolStateError], PropertyInfo(discriminator="status") -] - - -class ToolPart(BaseModel): - id: str - - call_id: str = FieldInfo(alias="callID") - - message_id: str = FieldInfo(alias="messageID") - - session_id: str = FieldInfo(alias="sessionID") - - state: State - - tool: str - - type: Literal["tool"] diff --git a/src/opencode_ai/types/tool_state_completed.py b/src/opencode_ai/types/tool_state_completed.py deleted file mode 100644 index 5129842..0000000 --- a/src/opencode_ai/types/tool_state_completed.py +++ /dev/null @@ -1,28 +0,0 @@ -# File generated from our OpenAPI spec by Stainless. See CONTRIBUTING.md for details. - -from typing import Dict -from typing_extensions import Literal - -from .._models import BaseModel - -__all__ = ["ToolStateCompleted", "Time"] - - -class Time(BaseModel): - end: float - - start: float - - -class ToolStateCompleted(BaseModel): - input: Dict[str, object] - - metadata: Dict[str, object] - - output: str - - status: Literal["completed"] - - time: Time - - title: str diff --git a/src/opencode_ai/types/tool_state_error.py b/src/opencode_ai/types/tool_state_error.py deleted file mode 100644 index 141a4cd..0000000 --- a/src/opencode_ai/types/tool_state_error.py +++ /dev/null @@ -1,24 +0,0 @@ -# File generated from our OpenAPI spec by Stainless. See CONTRIBUTING.md for details. - -from typing import Dict -from typing_extensions import Literal - -from .._models import BaseModel - -__all__ = ["ToolStateError", "Time"] - - -class Time(BaseModel): - end: float - - start: float - - -class ToolStateError(BaseModel): - error: str - - input: Dict[str, object] - - status: Literal["error"] - - time: Time diff --git a/src/opencode_ai/types/tool_state_pending.py b/src/opencode_ai/types/tool_state_pending.py deleted file mode 100644 index c678c92..0000000 --- a/src/opencode_ai/types/tool_state_pending.py +++ /dev/null @@ -1,11 +0,0 @@ -# File generated from our OpenAPI spec by Stainless. See CONTRIBUTING.md for details. - -from typing_extensions import Literal - -from .._models import BaseModel - -__all__ = ["ToolStatePending"] - - -class ToolStatePending(BaseModel): - status: Literal["pending"] diff --git a/src/opencode_ai/types/tool_state_running.py b/src/opencode_ai/types/tool_state_running.py deleted file mode 100644 index 87e2e8d..0000000 --- a/src/opencode_ai/types/tool_state_running.py +++ /dev/null @@ -1,24 +0,0 @@ -# File generated from our OpenAPI spec by Stainless. See CONTRIBUTING.md for details. - -from typing import Dict, Optional -from typing_extensions import Literal - -from .._models import BaseModel - -__all__ = ["ToolStateRunning", "Time"] - - -class Time(BaseModel): - start: float - - -class ToolStateRunning(BaseModel): - status: Literal["running"] - - time: Time - - input: Optional[object] = None - - metadata: Optional[Dict[str, object]] = None - - title: Optional[str] = None diff --git a/src/opencode_ai/types/tui_append_prompt_params.py b/src/opencode_ai/types/tui_append_prompt_params.py deleted file mode 100644 index 431f731..0000000 --- a/src/opencode_ai/types/tui_append_prompt_params.py +++ /dev/null @@ -1,11 +0,0 @@ -# File generated from our OpenAPI spec by Stainless. See CONTRIBUTING.md for details. - -from __future__ import annotations - -from typing_extensions import Required, TypedDict - -__all__ = ["TuiAppendPromptParams"] - - -class TuiAppendPromptParams(TypedDict, total=False): - text: Required[str] diff --git a/src/opencode_ai/types/tui_append_prompt_response.py b/src/opencode_ai/types/tui_append_prompt_response.py deleted file mode 100644 index 85b6813..0000000 --- a/src/opencode_ai/types/tui_append_prompt_response.py +++ /dev/null @@ -1,7 +0,0 @@ -# File generated from our OpenAPI spec by Stainless. See CONTRIBUTING.md for details. - -from typing_extensions import TypeAlias - -__all__ = ["TuiAppendPromptResponse"] - -TuiAppendPromptResponse: TypeAlias = bool diff --git a/src/opencode_ai/types/tui_open_help_response.py b/src/opencode_ai/types/tui_open_help_response.py deleted file mode 100644 index 59df1f1..0000000 --- a/src/opencode_ai/types/tui_open_help_response.py +++ /dev/null @@ -1,7 +0,0 @@ -# File generated from our OpenAPI spec by Stainless. See CONTRIBUTING.md for details. - -from typing_extensions import TypeAlias - -__all__ = ["TuiOpenHelpResponse"] - -TuiOpenHelpResponse: TypeAlias = bool diff --git a/src/opencode_ai/types/user_message.py b/src/opencode_ai/types/user_message.py deleted file mode 100644 index 64c44bf..0000000 --- a/src/opencode_ai/types/user_message.py +++ /dev/null @@ -1,23 +0,0 @@ -# File generated from our OpenAPI spec by Stainless. See CONTRIBUTING.md for details. - -from typing_extensions import Literal - -from pydantic import Field as FieldInfo - -from .._models import BaseModel - -__all__ = ["UserMessage", "Time"] - - -class Time(BaseModel): - created: float - - -class UserMessage(BaseModel): - id: str - - role: Literal["user"] - - session_id: str = FieldInfo(alias="sessionID") - - time: Time diff --git a/tests/__init__.py b/tests/__init__.py deleted file mode 100644 index fd8019a..0000000 --- a/tests/__init__.py +++ /dev/null @@ -1 +0,0 @@ -# File generated from our OpenAPI spec by Stainless. See CONTRIBUTING.md for details. diff --git a/tests/api_resources/__init__.py b/tests/api_resources/__init__.py deleted file mode 100644 index fd8019a..0000000 --- a/tests/api_resources/__init__.py +++ /dev/null @@ -1 +0,0 @@ -# File generated from our OpenAPI spec by Stainless. See CONTRIBUTING.md for details. diff --git a/tests/api_resources/test_app.py b/tests/api_resources/test_app.py deleted file mode 100644 index e0f0670..0000000 --- a/tests/api_resources/test_app.py +++ /dev/null @@ -1,356 +0,0 @@ -# File generated from our OpenAPI spec by Stainless. See CONTRIBUTING.md for details. - -from __future__ import annotations - -import os -from typing import Any, cast - -import pytest - -from opencode_ai import Opencode, AsyncOpencode -from tests.utils import assert_matches_type -from opencode_ai.types import ( - App, - AppLogResponse, - AppInitResponse, - AppModesResponse, - AppProvidersResponse, -) - -base_url = os.environ.get("TEST_API_BASE_URL", "http://127.0.0.1:4010") - - -class TestApp: - parametrize = pytest.mark.parametrize("client", [False, True], indirect=True, ids=["loose", "strict"]) - - @pytest.mark.skip(reason="Prism tests are disabled") - @parametrize - def test_method_get(self, client: Opencode) -> None: - app = client.app.get() - assert_matches_type(App, app, path=["response"]) - - @pytest.mark.skip(reason="Prism tests are disabled") - @parametrize - def test_raw_response_get(self, client: Opencode) -> None: - response = client.app.with_raw_response.get() - - assert response.is_closed is True - assert response.http_request.headers.get("X-Stainless-Lang") == "python" - app = response.parse() - assert_matches_type(App, app, path=["response"]) - - @pytest.mark.skip(reason="Prism tests are disabled") - @parametrize - def test_streaming_response_get(self, client: Opencode) -> None: - with client.app.with_streaming_response.get() as response: - assert not response.is_closed - assert response.http_request.headers.get("X-Stainless-Lang") == "python" - - app = response.parse() - assert_matches_type(App, app, path=["response"]) - - assert cast(Any, response.is_closed) is True - - @pytest.mark.skip(reason="Prism tests are disabled") - @parametrize - def test_method_init(self, client: Opencode) -> None: - app = client.app.init() - assert_matches_type(AppInitResponse, app, path=["response"]) - - @pytest.mark.skip(reason="Prism tests are disabled") - @parametrize - def test_raw_response_init(self, client: Opencode) -> None: - response = client.app.with_raw_response.init() - - assert response.is_closed is True - assert response.http_request.headers.get("X-Stainless-Lang") == "python" - app = response.parse() - assert_matches_type(AppInitResponse, app, path=["response"]) - - @pytest.mark.skip(reason="Prism tests are disabled") - @parametrize - def test_streaming_response_init(self, client: Opencode) -> None: - with client.app.with_streaming_response.init() as response: - assert not response.is_closed - assert response.http_request.headers.get("X-Stainless-Lang") == "python" - - app = response.parse() - assert_matches_type(AppInitResponse, app, path=["response"]) - - assert cast(Any, response.is_closed) is True - - @pytest.mark.skip(reason="Prism tests are disabled") - @parametrize - def test_method_log(self, client: Opencode) -> None: - app = client.app.log( - level="debug", - message="message", - service="service", - ) - assert_matches_type(AppLogResponse, app, path=["response"]) - - @pytest.mark.skip(reason="Prism tests are disabled") - @parametrize - def test_method_log_with_all_params(self, client: Opencode) -> None: - app = client.app.log( - level="debug", - message="message", - service="service", - extra={"foo": "bar"}, - ) - assert_matches_type(AppLogResponse, app, path=["response"]) - - @pytest.mark.skip(reason="Prism tests are disabled") - @parametrize - def test_raw_response_log(self, client: Opencode) -> None: - response = client.app.with_raw_response.log( - level="debug", - message="message", - service="service", - ) - - assert response.is_closed is True - assert response.http_request.headers.get("X-Stainless-Lang") == "python" - app = response.parse() - assert_matches_type(AppLogResponse, app, path=["response"]) - - @pytest.mark.skip(reason="Prism tests are disabled") - @parametrize - def test_streaming_response_log(self, client: Opencode) -> None: - with client.app.with_streaming_response.log( - level="debug", - message="message", - service="service", - ) as response: - assert not response.is_closed - assert response.http_request.headers.get("X-Stainless-Lang") == "python" - - app = response.parse() - assert_matches_type(AppLogResponse, app, path=["response"]) - - assert cast(Any, response.is_closed) is True - - @pytest.mark.skip(reason="Prism tests are disabled") - @parametrize - def test_method_modes(self, client: Opencode) -> None: - app = client.app.modes() - assert_matches_type(AppModesResponse, app, path=["response"]) - - @pytest.mark.skip(reason="Prism tests are disabled") - @parametrize - def test_raw_response_modes(self, client: Opencode) -> None: - response = client.app.with_raw_response.modes() - - assert response.is_closed is True - assert response.http_request.headers.get("X-Stainless-Lang") == "python" - app = response.parse() - assert_matches_type(AppModesResponse, app, path=["response"]) - - @pytest.mark.skip(reason="Prism tests are disabled") - @parametrize - def test_streaming_response_modes(self, client: Opencode) -> None: - with client.app.with_streaming_response.modes() as response: - assert not response.is_closed - assert response.http_request.headers.get("X-Stainless-Lang") == "python" - - app = response.parse() - assert_matches_type(AppModesResponse, app, path=["response"]) - - assert cast(Any, response.is_closed) is True - - @pytest.mark.skip(reason="Prism tests are disabled") - @parametrize - def test_method_providers(self, client: Opencode) -> None: - app = client.app.providers() - assert_matches_type(AppProvidersResponse, app, path=["response"]) - - @pytest.mark.skip(reason="Prism tests are disabled") - @parametrize - def test_raw_response_providers(self, client: Opencode) -> None: - response = client.app.with_raw_response.providers() - - assert response.is_closed is True - assert response.http_request.headers.get("X-Stainless-Lang") == "python" - app = response.parse() - assert_matches_type(AppProvidersResponse, app, path=["response"]) - - @pytest.mark.skip(reason="Prism tests are disabled") - @parametrize - def test_streaming_response_providers(self, client: Opencode) -> None: - with client.app.with_streaming_response.providers() as response: - assert not response.is_closed - assert response.http_request.headers.get("X-Stainless-Lang") == "python" - - app = response.parse() - assert_matches_type(AppProvidersResponse, app, path=["response"]) - - assert cast(Any, response.is_closed) is True - - -class TestAsyncApp: - parametrize = pytest.mark.parametrize( - "async_client", [False, True, {"http_client": "aiohttp"}], indirect=True, ids=["loose", "strict", "aiohttp"] - ) - - @pytest.mark.skip(reason="Prism tests are disabled") - @parametrize - async def test_method_get(self, async_client: AsyncOpencode) -> None: - app = await async_client.app.get() - assert_matches_type(App, app, path=["response"]) - - @pytest.mark.skip(reason="Prism tests are disabled") - @parametrize - async def test_raw_response_get(self, async_client: AsyncOpencode) -> None: - response = await async_client.app.with_raw_response.get() - - assert response.is_closed is True - assert response.http_request.headers.get("X-Stainless-Lang") == "python" - app = await response.parse() - assert_matches_type(App, app, path=["response"]) - - @pytest.mark.skip(reason="Prism tests are disabled") - @parametrize - async def test_streaming_response_get(self, async_client: AsyncOpencode) -> None: - async with async_client.app.with_streaming_response.get() as response: - assert not response.is_closed - assert response.http_request.headers.get("X-Stainless-Lang") == "python" - - app = await response.parse() - assert_matches_type(App, app, path=["response"]) - - assert cast(Any, response.is_closed) is True - - @pytest.mark.skip(reason="Prism tests are disabled") - @parametrize - async def test_method_init(self, async_client: AsyncOpencode) -> None: - app = await async_client.app.init() - assert_matches_type(AppInitResponse, app, path=["response"]) - - @pytest.mark.skip(reason="Prism tests are disabled") - @parametrize - async def test_raw_response_init(self, async_client: AsyncOpencode) -> None: - response = await async_client.app.with_raw_response.init() - - assert response.is_closed is True - assert response.http_request.headers.get("X-Stainless-Lang") == "python" - app = await response.parse() - assert_matches_type(AppInitResponse, app, path=["response"]) - - @pytest.mark.skip(reason="Prism tests are disabled") - @parametrize - async def test_streaming_response_init(self, async_client: AsyncOpencode) -> None: - async with async_client.app.with_streaming_response.init() as response: - assert not response.is_closed - assert response.http_request.headers.get("X-Stainless-Lang") == "python" - - app = await response.parse() - assert_matches_type(AppInitResponse, app, path=["response"]) - - assert cast(Any, response.is_closed) is True - - @pytest.mark.skip(reason="Prism tests are disabled") - @parametrize - async def test_method_log(self, async_client: AsyncOpencode) -> None: - app = await async_client.app.log( - level="debug", - message="message", - service="service", - ) - assert_matches_type(AppLogResponse, app, path=["response"]) - - @pytest.mark.skip(reason="Prism tests are disabled") - @parametrize - async def test_method_log_with_all_params(self, async_client: AsyncOpencode) -> None: - app = await async_client.app.log( - level="debug", - message="message", - service="service", - extra={"foo": "bar"}, - ) - assert_matches_type(AppLogResponse, app, path=["response"]) - - @pytest.mark.skip(reason="Prism tests are disabled") - @parametrize - async def test_raw_response_log(self, async_client: AsyncOpencode) -> None: - response = await async_client.app.with_raw_response.log( - level="debug", - message="message", - service="service", - ) - - assert response.is_closed is True - assert response.http_request.headers.get("X-Stainless-Lang") == "python" - app = await response.parse() - assert_matches_type(AppLogResponse, app, path=["response"]) - - @pytest.mark.skip(reason="Prism tests are disabled") - @parametrize - async def test_streaming_response_log(self, async_client: AsyncOpencode) -> None: - async with async_client.app.with_streaming_response.log( - level="debug", - message="message", - service="service", - ) as response: - assert not response.is_closed - assert response.http_request.headers.get("X-Stainless-Lang") == "python" - - app = await response.parse() - assert_matches_type(AppLogResponse, app, path=["response"]) - - assert cast(Any, response.is_closed) is True - - @pytest.mark.skip(reason="Prism tests are disabled") - @parametrize - async def test_method_modes(self, async_client: AsyncOpencode) -> None: - app = await async_client.app.modes() - assert_matches_type(AppModesResponse, app, path=["response"]) - - @pytest.mark.skip(reason="Prism tests are disabled") - @parametrize - async def test_raw_response_modes(self, async_client: AsyncOpencode) -> None: - response = await async_client.app.with_raw_response.modes() - - assert response.is_closed is True - assert response.http_request.headers.get("X-Stainless-Lang") == "python" - app = await response.parse() - assert_matches_type(AppModesResponse, app, path=["response"]) - - @pytest.mark.skip(reason="Prism tests are disabled") - @parametrize - async def test_streaming_response_modes(self, async_client: AsyncOpencode) -> None: - async with async_client.app.with_streaming_response.modes() as response: - assert not response.is_closed - assert response.http_request.headers.get("X-Stainless-Lang") == "python" - - app = await response.parse() - assert_matches_type(AppModesResponse, app, path=["response"]) - - assert cast(Any, response.is_closed) is True - - @pytest.mark.skip(reason="Prism tests are disabled") - @parametrize - async def test_method_providers(self, async_client: AsyncOpencode) -> None: - app = await async_client.app.providers() - assert_matches_type(AppProvidersResponse, app, path=["response"]) - - @pytest.mark.skip(reason="Prism tests are disabled") - @parametrize - async def test_raw_response_providers(self, async_client: AsyncOpencode) -> None: - response = await async_client.app.with_raw_response.providers() - - assert response.is_closed is True - assert response.http_request.headers.get("X-Stainless-Lang") == "python" - app = await response.parse() - assert_matches_type(AppProvidersResponse, app, path=["response"]) - - @pytest.mark.skip(reason="Prism tests are disabled") - @parametrize - async def test_streaming_response_providers(self, async_client: AsyncOpencode) -> None: - async with async_client.app.with_streaming_response.providers() as response: - assert not response.is_closed - assert response.http_request.headers.get("X-Stainless-Lang") == "python" - - app = await response.parse() - assert_matches_type(AppProvidersResponse, app, path=["response"]) - - assert cast(Any, response.is_closed) is True diff --git a/tests/api_resources/test_config.py b/tests/api_resources/test_config.py deleted file mode 100644 index 86e4c8f..0000000 --- a/tests/api_resources/test_config.py +++ /dev/null @@ -1,80 +0,0 @@ -# File generated from our OpenAPI spec by Stainless. See CONTRIBUTING.md for details. - -from __future__ import annotations - -import os -from typing import Any, cast - -import pytest - -from opencode_ai import Opencode, AsyncOpencode -from tests.utils import assert_matches_type -from opencode_ai.types import Config - -base_url = os.environ.get("TEST_API_BASE_URL", "http://127.0.0.1:4010") - - -class TestConfig: - parametrize = pytest.mark.parametrize("client", [False, True], indirect=True, ids=["loose", "strict"]) - - @pytest.mark.skip(reason="Prism tests are disabled") - @parametrize - def test_method_get(self, client: Opencode) -> None: - config = client.config.get() - assert_matches_type(Config, config, path=["response"]) - - @pytest.mark.skip(reason="Prism tests are disabled") - @parametrize - def test_raw_response_get(self, client: Opencode) -> None: - response = client.config.with_raw_response.get() - - assert response.is_closed is True - assert response.http_request.headers.get("X-Stainless-Lang") == "python" - config = response.parse() - assert_matches_type(Config, config, path=["response"]) - - @pytest.mark.skip(reason="Prism tests are disabled") - @parametrize - def test_streaming_response_get(self, client: Opencode) -> None: - with client.config.with_streaming_response.get() as response: - assert not response.is_closed - assert response.http_request.headers.get("X-Stainless-Lang") == "python" - - config = response.parse() - assert_matches_type(Config, config, path=["response"]) - - assert cast(Any, response.is_closed) is True - - -class TestAsyncConfig: - parametrize = pytest.mark.parametrize( - "async_client", [False, True, {"http_client": "aiohttp"}], indirect=True, ids=["loose", "strict", "aiohttp"] - ) - - @pytest.mark.skip(reason="Prism tests are disabled") - @parametrize - async def test_method_get(self, async_client: AsyncOpencode) -> None: - config = await async_client.config.get() - assert_matches_type(Config, config, path=["response"]) - - @pytest.mark.skip(reason="Prism tests are disabled") - @parametrize - async def test_raw_response_get(self, async_client: AsyncOpencode) -> None: - response = await async_client.config.with_raw_response.get() - - assert response.is_closed is True - assert response.http_request.headers.get("X-Stainless-Lang") == "python" - config = await response.parse() - assert_matches_type(Config, config, path=["response"]) - - @pytest.mark.skip(reason="Prism tests are disabled") - @parametrize - async def test_streaming_response_get(self, async_client: AsyncOpencode) -> None: - async with async_client.config.with_streaming_response.get() as response: - assert not response.is_closed - assert response.http_request.headers.get("X-Stainless-Lang") == "python" - - config = await response.parse() - assert_matches_type(Config, config, path=["response"]) - - assert cast(Any, response.is_closed) is True diff --git a/tests/api_resources/test_event.py b/tests/api_resources/test_event.py deleted file mode 100644 index 95de55b..0000000 --- a/tests/api_resources/test_event.py +++ /dev/null @@ -1,76 +0,0 @@ -# File generated from our OpenAPI spec by Stainless. See CONTRIBUTING.md for details. - -from __future__ import annotations - -import os -from typing import Any, cast - -import pytest - -from opencode_ai import Opencode, AsyncOpencode - -base_url = os.environ.get("TEST_API_BASE_URL", "http://127.0.0.1:4010") - - -class TestEvent: - parametrize = pytest.mark.parametrize("client", [False, True], indirect=True, ids=["loose", "strict"]) - - @pytest.mark.skip(reason="Prism tests are disabled") - @parametrize - def test_method_list(self, client: Opencode) -> None: - event_stream = client.event.list() - event_stream.response.close() - - @pytest.mark.skip(reason="Prism tests are disabled") - @parametrize - def test_raw_response_list(self, client: Opencode) -> None: - response = client.event.with_raw_response.list() - - assert response.http_request.headers.get("X-Stainless-Lang") == "python" - stream = response.parse() - stream.close() - - @pytest.mark.skip(reason="Prism tests are disabled") - @parametrize - def test_streaming_response_list(self, client: Opencode) -> None: - with client.event.with_streaming_response.list() as response: - assert not response.is_closed - assert response.http_request.headers.get("X-Stainless-Lang") == "python" - - stream = response.parse() - stream.close() - - assert cast(Any, response.is_closed) is True - - -class TestAsyncEvent: - parametrize = pytest.mark.parametrize( - "async_client", [False, True, {"http_client": "aiohttp"}], indirect=True, ids=["loose", "strict", "aiohttp"] - ) - - @pytest.mark.skip(reason="Prism tests are disabled") - @parametrize - async def test_method_list(self, async_client: AsyncOpencode) -> None: - event_stream = await async_client.event.list() - await event_stream.response.aclose() - - @pytest.mark.skip(reason="Prism tests are disabled") - @parametrize - async def test_raw_response_list(self, async_client: AsyncOpencode) -> None: - response = await async_client.event.with_raw_response.list() - - assert response.http_request.headers.get("X-Stainless-Lang") == "python" - stream = await response.parse() - await stream.close() - - @pytest.mark.skip(reason="Prism tests are disabled") - @parametrize - async def test_streaming_response_list(self, async_client: AsyncOpencode) -> None: - async with async_client.event.with_streaming_response.list() as response: - assert not response.is_closed - assert response.http_request.headers.get("X-Stainless-Lang") == "python" - - stream = await response.parse() - await stream.close() - - assert cast(Any, response.is_closed) is True diff --git a/tests/api_resources/test_file.py b/tests/api_resources/test_file.py deleted file mode 100644 index cd09854..0000000 --- a/tests/api_resources/test_file.py +++ /dev/null @@ -1,148 +0,0 @@ -# File generated from our OpenAPI spec by Stainless. See CONTRIBUTING.md for details. - -from __future__ import annotations - -import os -from typing import Any, cast - -import pytest - -from opencode_ai import Opencode, AsyncOpencode -from tests.utils import assert_matches_type -from opencode_ai.types import FileReadResponse, FileStatusResponse - -base_url = os.environ.get("TEST_API_BASE_URL", "http://127.0.0.1:4010") - - -class TestFile: - parametrize = pytest.mark.parametrize("client", [False, True], indirect=True, ids=["loose", "strict"]) - - @pytest.mark.skip(reason="Prism tests are disabled") - @parametrize - def test_method_read(self, client: Opencode) -> None: - file = client.file.read( - path="path", - ) - assert_matches_type(FileReadResponse, file, path=["response"]) - - @pytest.mark.skip(reason="Prism tests are disabled") - @parametrize - def test_raw_response_read(self, client: Opencode) -> None: - response = client.file.with_raw_response.read( - path="path", - ) - - assert response.is_closed is True - assert response.http_request.headers.get("X-Stainless-Lang") == "python" - file = response.parse() - assert_matches_type(FileReadResponse, file, path=["response"]) - - @pytest.mark.skip(reason="Prism tests are disabled") - @parametrize - def test_streaming_response_read(self, client: Opencode) -> None: - with client.file.with_streaming_response.read( - path="path", - ) as response: - assert not response.is_closed - assert response.http_request.headers.get("X-Stainless-Lang") == "python" - - file = response.parse() - assert_matches_type(FileReadResponse, file, path=["response"]) - - assert cast(Any, response.is_closed) is True - - @pytest.mark.skip(reason="Prism tests are disabled") - @parametrize - def test_method_status(self, client: Opencode) -> None: - file = client.file.status() - assert_matches_type(FileStatusResponse, file, path=["response"]) - - @pytest.mark.skip(reason="Prism tests are disabled") - @parametrize - def test_raw_response_status(self, client: Opencode) -> None: - response = client.file.with_raw_response.status() - - assert response.is_closed is True - assert response.http_request.headers.get("X-Stainless-Lang") == "python" - file = response.parse() - assert_matches_type(FileStatusResponse, file, path=["response"]) - - @pytest.mark.skip(reason="Prism tests are disabled") - @parametrize - def test_streaming_response_status(self, client: Opencode) -> None: - with client.file.with_streaming_response.status() as response: - assert not response.is_closed - assert response.http_request.headers.get("X-Stainless-Lang") == "python" - - file = response.parse() - assert_matches_type(FileStatusResponse, file, path=["response"]) - - assert cast(Any, response.is_closed) is True - - -class TestAsyncFile: - parametrize = pytest.mark.parametrize( - "async_client", [False, True, {"http_client": "aiohttp"}], indirect=True, ids=["loose", "strict", "aiohttp"] - ) - - @pytest.mark.skip(reason="Prism tests are disabled") - @parametrize - async def test_method_read(self, async_client: AsyncOpencode) -> None: - file = await async_client.file.read( - path="path", - ) - assert_matches_type(FileReadResponse, file, path=["response"]) - - @pytest.mark.skip(reason="Prism tests are disabled") - @parametrize - async def test_raw_response_read(self, async_client: AsyncOpencode) -> None: - response = await async_client.file.with_raw_response.read( - path="path", - ) - - assert response.is_closed is True - assert response.http_request.headers.get("X-Stainless-Lang") == "python" - file = await response.parse() - assert_matches_type(FileReadResponse, file, path=["response"]) - - @pytest.mark.skip(reason="Prism tests are disabled") - @parametrize - async def test_streaming_response_read(self, async_client: AsyncOpencode) -> None: - async with async_client.file.with_streaming_response.read( - path="path", - ) as response: - assert not response.is_closed - assert response.http_request.headers.get("X-Stainless-Lang") == "python" - - file = await response.parse() - assert_matches_type(FileReadResponse, file, path=["response"]) - - assert cast(Any, response.is_closed) is True - - @pytest.mark.skip(reason="Prism tests are disabled") - @parametrize - async def test_method_status(self, async_client: AsyncOpencode) -> None: - file = await async_client.file.status() - assert_matches_type(FileStatusResponse, file, path=["response"]) - - @pytest.mark.skip(reason="Prism tests are disabled") - @parametrize - async def test_raw_response_status(self, async_client: AsyncOpencode) -> None: - response = await async_client.file.with_raw_response.status() - - assert response.is_closed is True - assert response.http_request.headers.get("X-Stainless-Lang") == "python" - file = await response.parse() - assert_matches_type(FileStatusResponse, file, path=["response"]) - - @pytest.mark.skip(reason="Prism tests are disabled") - @parametrize - async def test_streaming_response_status(self, async_client: AsyncOpencode) -> None: - async with async_client.file.with_streaming_response.status() as response: - assert not response.is_closed - assert response.http_request.headers.get("X-Stainless-Lang") == "python" - - file = await response.parse() - assert_matches_type(FileStatusResponse, file, path=["response"]) - - assert cast(Any, response.is_closed) is True diff --git a/tests/api_resources/test_find.py b/tests/api_resources/test_find.py deleted file mode 100644 index cd54e28..0000000 --- a/tests/api_resources/test_find.py +++ /dev/null @@ -1,232 +0,0 @@ -# File generated from our OpenAPI spec by Stainless. See CONTRIBUTING.md for details. - -from __future__ import annotations - -import os -from typing import Any, cast - -import pytest - -from opencode_ai import Opencode, AsyncOpencode -from tests.utils import assert_matches_type -from opencode_ai.types import ( - FindTextResponse, - FindFilesResponse, - FindSymbolsResponse, -) - -base_url = os.environ.get("TEST_API_BASE_URL", "http://127.0.0.1:4010") - - -class TestFind: - parametrize = pytest.mark.parametrize("client", [False, True], indirect=True, ids=["loose", "strict"]) - - @pytest.mark.skip(reason="Prism tests are disabled") - @parametrize - def test_method_files(self, client: Opencode) -> None: - find = client.find.files( - query="query", - ) - assert_matches_type(FindFilesResponse, find, path=["response"]) - - @pytest.mark.skip(reason="Prism tests are disabled") - @parametrize - def test_raw_response_files(self, client: Opencode) -> None: - response = client.find.with_raw_response.files( - query="query", - ) - - assert response.is_closed is True - assert response.http_request.headers.get("X-Stainless-Lang") == "python" - find = response.parse() - assert_matches_type(FindFilesResponse, find, path=["response"]) - - @pytest.mark.skip(reason="Prism tests are disabled") - @parametrize - def test_streaming_response_files(self, client: Opencode) -> None: - with client.find.with_streaming_response.files( - query="query", - ) as response: - assert not response.is_closed - assert response.http_request.headers.get("X-Stainless-Lang") == "python" - - find = response.parse() - assert_matches_type(FindFilesResponse, find, path=["response"]) - - assert cast(Any, response.is_closed) is True - - @pytest.mark.skip(reason="Prism tests are disabled") - @parametrize - def test_method_symbols(self, client: Opencode) -> None: - find = client.find.symbols( - query="query", - ) - assert_matches_type(FindSymbolsResponse, find, path=["response"]) - - @pytest.mark.skip(reason="Prism tests are disabled") - @parametrize - def test_raw_response_symbols(self, client: Opencode) -> None: - response = client.find.with_raw_response.symbols( - query="query", - ) - - assert response.is_closed is True - assert response.http_request.headers.get("X-Stainless-Lang") == "python" - find = response.parse() - assert_matches_type(FindSymbolsResponse, find, path=["response"]) - - @pytest.mark.skip(reason="Prism tests are disabled") - @parametrize - def test_streaming_response_symbols(self, client: Opencode) -> None: - with client.find.with_streaming_response.symbols( - query="query", - ) as response: - assert not response.is_closed - assert response.http_request.headers.get("X-Stainless-Lang") == "python" - - find = response.parse() - assert_matches_type(FindSymbolsResponse, find, path=["response"]) - - assert cast(Any, response.is_closed) is True - - @pytest.mark.skip(reason="Prism tests are disabled") - @parametrize - def test_method_text(self, client: Opencode) -> None: - find = client.find.text( - pattern="pattern", - ) - assert_matches_type(FindTextResponse, find, path=["response"]) - - @pytest.mark.skip(reason="Prism tests are disabled") - @parametrize - def test_raw_response_text(self, client: Opencode) -> None: - response = client.find.with_raw_response.text( - pattern="pattern", - ) - - assert response.is_closed is True - assert response.http_request.headers.get("X-Stainless-Lang") == "python" - find = response.parse() - assert_matches_type(FindTextResponse, find, path=["response"]) - - @pytest.mark.skip(reason="Prism tests are disabled") - @parametrize - def test_streaming_response_text(self, client: Opencode) -> None: - with client.find.with_streaming_response.text( - pattern="pattern", - ) as response: - assert not response.is_closed - assert response.http_request.headers.get("X-Stainless-Lang") == "python" - - find = response.parse() - assert_matches_type(FindTextResponse, find, path=["response"]) - - assert cast(Any, response.is_closed) is True - - -class TestAsyncFind: - parametrize = pytest.mark.parametrize( - "async_client", [False, True, {"http_client": "aiohttp"}], indirect=True, ids=["loose", "strict", "aiohttp"] - ) - - @pytest.mark.skip(reason="Prism tests are disabled") - @parametrize - async def test_method_files(self, async_client: AsyncOpencode) -> None: - find = await async_client.find.files( - query="query", - ) - assert_matches_type(FindFilesResponse, find, path=["response"]) - - @pytest.mark.skip(reason="Prism tests are disabled") - @parametrize - async def test_raw_response_files(self, async_client: AsyncOpencode) -> None: - response = await async_client.find.with_raw_response.files( - query="query", - ) - - assert response.is_closed is True - assert response.http_request.headers.get("X-Stainless-Lang") == "python" - find = await response.parse() - assert_matches_type(FindFilesResponse, find, path=["response"]) - - @pytest.mark.skip(reason="Prism tests are disabled") - @parametrize - async def test_streaming_response_files(self, async_client: AsyncOpencode) -> None: - async with async_client.find.with_streaming_response.files( - query="query", - ) as response: - assert not response.is_closed - assert response.http_request.headers.get("X-Stainless-Lang") == "python" - - find = await response.parse() - assert_matches_type(FindFilesResponse, find, path=["response"]) - - assert cast(Any, response.is_closed) is True - - @pytest.mark.skip(reason="Prism tests are disabled") - @parametrize - async def test_method_symbols(self, async_client: AsyncOpencode) -> None: - find = await async_client.find.symbols( - query="query", - ) - assert_matches_type(FindSymbolsResponse, find, path=["response"]) - - @pytest.mark.skip(reason="Prism tests are disabled") - @parametrize - async def test_raw_response_symbols(self, async_client: AsyncOpencode) -> None: - response = await async_client.find.with_raw_response.symbols( - query="query", - ) - - assert response.is_closed is True - assert response.http_request.headers.get("X-Stainless-Lang") == "python" - find = await response.parse() - assert_matches_type(FindSymbolsResponse, find, path=["response"]) - - @pytest.mark.skip(reason="Prism tests are disabled") - @parametrize - async def test_streaming_response_symbols(self, async_client: AsyncOpencode) -> None: - async with async_client.find.with_streaming_response.symbols( - query="query", - ) as response: - assert not response.is_closed - assert response.http_request.headers.get("X-Stainless-Lang") == "python" - - find = await response.parse() - assert_matches_type(FindSymbolsResponse, find, path=["response"]) - - assert cast(Any, response.is_closed) is True - - @pytest.mark.skip(reason="Prism tests are disabled") - @parametrize - async def test_method_text(self, async_client: AsyncOpencode) -> None: - find = await async_client.find.text( - pattern="pattern", - ) - assert_matches_type(FindTextResponse, find, path=["response"]) - - @pytest.mark.skip(reason="Prism tests are disabled") - @parametrize - async def test_raw_response_text(self, async_client: AsyncOpencode) -> None: - response = await async_client.find.with_raw_response.text( - pattern="pattern", - ) - - assert response.is_closed is True - assert response.http_request.headers.get("X-Stainless-Lang") == "python" - find = await response.parse() - assert_matches_type(FindTextResponse, find, path=["response"]) - - @pytest.mark.skip(reason="Prism tests are disabled") - @parametrize - async def test_streaming_response_text(self, async_client: AsyncOpencode) -> None: - async with async_client.find.with_streaming_response.text( - pattern="pattern", - ) as response: - assert not response.is_closed - assert response.http_request.headers.get("X-Stainless-Lang") == "python" - - find = await response.parse() - assert_matches_type(FindTextResponse, find, path=["response"]) - - assert cast(Any, response.is_closed) is True diff --git a/tests/api_resources/test_session.py b/tests/api_resources/test_session.py deleted file mode 100644 index 15b39d7..0000000 --- a/tests/api_resources/test_session.py +++ /dev/null @@ -1,1169 +0,0 @@ -# File generated from our OpenAPI spec by Stainless. See CONTRIBUTING.md for details. - -from __future__ import annotations - -import os -from typing import Any, cast - -import pytest - -from opencode_ai import Opencode, AsyncOpencode -from tests.utils import assert_matches_type -from opencode_ai.types import ( - Session, - AssistantMessage, - SessionInitResponse, - SessionListResponse, - SessionAbortResponse, - SessionDeleteResponse, - SessionMessagesResponse, - SessionSummarizeResponse, -) - -base_url = os.environ.get("TEST_API_BASE_URL", "http://127.0.0.1:4010") - - -class TestSession: - parametrize = pytest.mark.parametrize("client", [False, True], indirect=True, ids=["loose", "strict"]) - - @pytest.mark.skip(reason="Prism tests are disabled") - @parametrize - def test_method_create(self, client: Opencode) -> None: - session = client.session.create() - assert_matches_type(Session, session, path=["response"]) - - @pytest.mark.skip(reason="Prism tests are disabled") - @parametrize - def test_raw_response_create(self, client: Opencode) -> None: - response = client.session.with_raw_response.create() - - assert response.is_closed is True - assert response.http_request.headers.get("X-Stainless-Lang") == "python" - session = response.parse() - assert_matches_type(Session, session, path=["response"]) - - @pytest.mark.skip(reason="Prism tests are disabled") - @parametrize - def test_streaming_response_create(self, client: Opencode) -> None: - with client.session.with_streaming_response.create() as response: - assert not response.is_closed - assert response.http_request.headers.get("X-Stainless-Lang") == "python" - - session = response.parse() - assert_matches_type(Session, session, path=["response"]) - - assert cast(Any, response.is_closed) is True - - @pytest.mark.skip(reason="Prism tests are disabled") - @parametrize - def test_method_list(self, client: Opencode) -> None: - session = client.session.list() - assert_matches_type(SessionListResponse, session, path=["response"]) - - @pytest.mark.skip(reason="Prism tests are disabled") - @parametrize - def test_raw_response_list(self, client: Opencode) -> None: - response = client.session.with_raw_response.list() - - assert response.is_closed is True - assert response.http_request.headers.get("X-Stainless-Lang") == "python" - session = response.parse() - assert_matches_type(SessionListResponse, session, path=["response"]) - - @pytest.mark.skip(reason="Prism tests are disabled") - @parametrize - def test_streaming_response_list(self, client: Opencode) -> None: - with client.session.with_streaming_response.list() as response: - assert not response.is_closed - assert response.http_request.headers.get("X-Stainless-Lang") == "python" - - session = response.parse() - assert_matches_type(SessionListResponse, session, path=["response"]) - - assert cast(Any, response.is_closed) is True - - @pytest.mark.skip(reason="Prism tests are disabled") - @parametrize - def test_method_delete(self, client: Opencode) -> None: - session = client.session.delete( - "id", - ) - assert_matches_type(SessionDeleteResponse, session, path=["response"]) - - @pytest.mark.skip(reason="Prism tests are disabled") - @parametrize - def test_raw_response_delete(self, client: Opencode) -> None: - response = client.session.with_raw_response.delete( - "id", - ) - - assert response.is_closed is True - assert response.http_request.headers.get("X-Stainless-Lang") == "python" - session = response.parse() - assert_matches_type(SessionDeleteResponse, session, path=["response"]) - - @pytest.mark.skip(reason="Prism tests are disabled") - @parametrize - def test_streaming_response_delete(self, client: Opencode) -> None: - with client.session.with_streaming_response.delete( - "id", - ) as response: - assert not response.is_closed - assert response.http_request.headers.get("X-Stainless-Lang") == "python" - - session = response.parse() - assert_matches_type(SessionDeleteResponse, session, path=["response"]) - - assert cast(Any, response.is_closed) is True - - @pytest.mark.skip(reason="Prism tests are disabled") - @parametrize - def test_path_params_delete(self, client: Opencode) -> None: - with pytest.raises(ValueError, match=r"Expected a non-empty value for `id` but received ''"): - client.session.with_raw_response.delete( - "", - ) - - @pytest.mark.skip(reason="Prism tests are disabled") - @parametrize - def test_method_abort(self, client: Opencode) -> None: - session = client.session.abort( - "id", - ) - assert_matches_type(SessionAbortResponse, session, path=["response"]) - - @pytest.mark.skip(reason="Prism tests are disabled") - @parametrize - def test_raw_response_abort(self, client: Opencode) -> None: - response = client.session.with_raw_response.abort( - "id", - ) - - assert response.is_closed is True - assert response.http_request.headers.get("X-Stainless-Lang") == "python" - session = response.parse() - assert_matches_type(SessionAbortResponse, session, path=["response"]) - - @pytest.mark.skip(reason="Prism tests are disabled") - @parametrize - def test_streaming_response_abort(self, client: Opencode) -> None: - with client.session.with_streaming_response.abort( - "id", - ) as response: - assert not response.is_closed - assert response.http_request.headers.get("X-Stainless-Lang") == "python" - - session = response.parse() - assert_matches_type(SessionAbortResponse, session, path=["response"]) - - assert cast(Any, response.is_closed) is True - - @pytest.mark.skip(reason="Prism tests are disabled") - @parametrize - def test_path_params_abort(self, client: Opencode) -> None: - with pytest.raises(ValueError, match=r"Expected a non-empty value for `id` but received ''"): - client.session.with_raw_response.abort( - "", - ) - - @pytest.mark.skip(reason="Prism tests are disabled") - @parametrize - def test_method_chat(self, client: Opencode) -> None: - session = client.session.chat( - id="id", - model_id="modelID", - parts=[ - { - "text": "text", - "type": "text", - } - ], - provider_id="providerID", - ) - assert_matches_type(AssistantMessage, session, path=["response"]) - - @pytest.mark.skip(reason="Prism tests are disabled") - @parametrize - def test_method_chat_with_all_params(self, client: Opencode) -> None: - session = client.session.chat( - id="id", - model_id="modelID", - parts=[ - { - "text": "text", - "type": "text", - "id": "id", - "synthetic": True, - "time": { - "start": 0, - "end": 0, - }, - } - ], - provider_id="providerID", - message_id="msg", - mode="mode", - system="system", - tools={"foo": True}, - ) - assert_matches_type(AssistantMessage, session, path=["response"]) - - @pytest.mark.skip(reason="Prism tests are disabled") - @parametrize - def test_raw_response_chat(self, client: Opencode) -> None: - response = client.session.with_raw_response.chat( - id="id", - model_id="modelID", - parts=[ - { - "text": "text", - "type": "text", - } - ], - provider_id="providerID", - ) - - assert response.is_closed is True - assert response.http_request.headers.get("X-Stainless-Lang") == "python" - session = response.parse() - assert_matches_type(AssistantMessage, session, path=["response"]) - - @pytest.mark.skip(reason="Prism tests are disabled") - @parametrize - def test_streaming_response_chat(self, client: Opencode) -> None: - with client.session.with_streaming_response.chat( - id="id", - model_id="modelID", - parts=[ - { - "text": "text", - "type": "text", - } - ], - provider_id="providerID", - ) as response: - assert not response.is_closed - assert response.http_request.headers.get("X-Stainless-Lang") == "python" - - session = response.parse() - assert_matches_type(AssistantMessage, session, path=["response"]) - - assert cast(Any, response.is_closed) is True - - @pytest.mark.skip(reason="Prism tests are disabled") - @parametrize - def test_path_params_chat(self, client: Opencode) -> None: - with pytest.raises(ValueError, match=r"Expected a non-empty value for `id` but received ''"): - client.session.with_raw_response.chat( - id="", - model_id="modelID", - parts=[ - { - "text": "text", - "type": "text", - } - ], - provider_id="providerID", - ) - - @pytest.mark.skip(reason="Prism tests are disabled") - @parametrize - def test_method_init(self, client: Opencode) -> None: - session = client.session.init( - id="id", - message_id="messageID", - model_id="modelID", - provider_id="providerID", - ) - assert_matches_type(SessionInitResponse, session, path=["response"]) - - @pytest.mark.skip(reason="Prism tests are disabled") - @parametrize - def test_raw_response_init(self, client: Opencode) -> None: - response = client.session.with_raw_response.init( - id="id", - message_id="messageID", - model_id="modelID", - provider_id="providerID", - ) - - assert response.is_closed is True - assert response.http_request.headers.get("X-Stainless-Lang") == "python" - session = response.parse() - assert_matches_type(SessionInitResponse, session, path=["response"]) - - @pytest.mark.skip(reason="Prism tests are disabled") - @parametrize - def test_streaming_response_init(self, client: Opencode) -> None: - with client.session.with_streaming_response.init( - id="id", - message_id="messageID", - model_id="modelID", - provider_id="providerID", - ) as response: - assert not response.is_closed - assert response.http_request.headers.get("X-Stainless-Lang") == "python" - - session = response.parse() - assert_matches_type(SessionInitResponse, session, path=["response"]) - - assert cast(Any, response.is_closed) is True - - @pytest.mark.skip(reason="Prism tests are disabled") - @parametrize - def test_path_params_init(self, client: Opencode) -> None: - with pytest.raises(ValueError, match=r"Expected a non-empty value for `id` but received ''"): - client.session.with_raw_response.init( - id="", - message_id="messageID", - model_id="modelID", - provider_id="providerID", - ) - - @pytest.mark.skip(reason="Prism tests are disabled") - @parametrize - def test_method_messages(self, client: Opencode) -> None: - session = client.session.messages( - "id", - ) - assert_matches_type(SessionMessagesResponse, session, path=["response"]) - - @pytest.mark.skip(reason="Prism tests are disabled") - @parametrize - def test_raw_response_messages(self, client: Opencode) -> None: - response = client.session.with_raw_response.messages( - "id", - ) - - assert response.is_closed is True - assert response.http_request.headers.get("X-Stainless-Lang") == "python" - session = response.parse() - assert_matches_type(SessionMessagesResponse, session, path=["response"]) - - @pytest.mark.skip(reason="Prism tests are disabled") - @parametrize - def test_streaming_response_messages(self, client: Opencode) -> None: - with client.session.with_streaming_response.messages( - "id", - ) as response: - assert not response.is_closed - assert response.http_request.headers.get("X-Stainless-Lang") == "python" - - session = response.parse() - assert_matches_type(SessionMessagesResponse, session, path=["response"]) - - assert cast(Any, response.is_closed) is True - - @pytest.mark.skip(reason="Prism tests are disabled") - @parametrize - def test_path_params_messages(self, client: Opencode) -> None: - with pytest.raises(ValueError, match=r"Expected a non-empty value for `id` but received ''"): - client.session.with_raw_response.messages( - "", - ) - - @pytest.mark.skip(reason="Prism tests are disabled") - @parametrize - def test_method_revert(self, client: Opencode) -> None: - session = client.session.revert( - id="id", - message_id="msg", - ) - assert_matches_type(Session, session, path=["response"]) - - @pytest.mark.skip(reason="Prism tests are disabled") - @parametrize - def test_method_revert_with_all_params(self, client: Opencode) -> None: - session = client.session.revert( - id="id", - message_id="msg", - part_id="prt", - ) - assert_matches_type(Session, session, path=["response"]) - - @pytest.mark.skip(reason="Prism tests are disabled") - @parametrize - def test_raw_response_revert(self, client: Opencode) -> None: - response = client.session.with_raw_response.revert( - id="id", - message_id="msg", - ) - - assert response.is_closed is True - assert response.http_request.headers.get("X-Stainless-Lang") == "python" - session = response.parse() - assert_matches_type(Session, session, path=["response"]) - - @pytest.mark.skip(reason="Prism tests are disabled") - @parametrize - def test_streaming_response_revert(self, client: Opencode) -> None: - with client.session.with_streaming_response.revert( - id="id", - message_id="msg", - ) as response: - assert not response.is_closed - assert response.http_request.headers.get("X-Stainless-Lang") == "python" - - session = response.parse() - assert_matches_type(Session, session, path=["response"]) - - assert cast(Any, response.is_closed) is True - - @pytest.mark.skip(reason="Prism tests are disabled") - @parametrize - def test_path_params_revert(self, client: Opencode) -> None: - with pytest.raises(ValueError, match=r"Expected a non-empty value for `id` but received ''"): - client.session.with_raw_response.revert( - id="", - message_id="msg", - ) - - @pytest.mark.skip(reason="Prism tests are disabled") - @parametrize - def test_method_share(self, client: Opencode) -> None: - session = client.session.share( - "id", - ) - assert_matches_type(Session, session, path=["response"]) - - @pytest.mark.skip(reason="Prism tests are disabled") - @parametrize - def test_raw_response_share(self, client: Opencode) -> None: - response = client.session.with_raw_response.share( - "id", - ) - - assert response.is_closed is True - assert response.http_request.headers.get("X-Stainless-Lang") == "python" - session = response.parse() - assert_matches_type(Session, session, path=["response"]) - - @pytest.mark.skip(reason="Prism tests are disabled") - @parametrize - def test_streaming_response_share(self, client: Opencode) -> None: - with client.session.with_streaming_response.share( - "id", - ) as response: - assert not response.is_closed - assert response.http_request.headers.get("X-Stainless-Lang") == "python" - - session = response.parse() - assert_matches_type(Session, session, path=["response"]) - - assert cast(Any, response.is_closed) is True - - @pytest.mark.skip(reason="Prism tests are disabled") - @parametrize - def test_path_params_share(self, client: Opencode) -> None: - with pytest.raises(ValueError, match=r"Expected a non-empty value for `id` but received ''"): - client.session.with_raw_response.share( - "", - ) - - @pytest.mark.skip(reason="Prism tests are disabled") - @parametrize - def test_method_summarize(self, client: Opencode) -> None: - session = client.session.summarize( - id="id", - model_id="modelID", - provider_id="providerID", - ) - assert_matches_type(SessionSummarizeResponse, session, path=["response"]) - - @pytest.mark.skip(reason="Prism tests are disabled") - @parametrize - def test_raw_response_summarize(self, client: Opencode) -> None: - response = client.session.with_raw_response.summarize( - id="id", - model_id="modelID", - provider_id="providerID", - ) - - assert response.is_closed is True - assert response.http_request.headers.get("X-Stainless-Lang") == "python" - session = response.parse() - assert_matches_type(SessionSummarizeResponse, session, path=["response"]) - - @pytest.mark.skip(reason="Prism tests are disabled") - @parametrize - def test_streaming_response_summarize(self, client: Opencode) -> None: - with client.session.with_streaming_response.summarize( - id="id", - model_id="modelID", - provider_id="providerID", - ) as response: - assert not response.is_closed - assert response.http_request.headers.get("X-Stainless-Lang") == "python" - - session = response.parse() - assert_matches_type(SessionSummarizeResponse, session, path=["response"]) - - assert cast(Any, response.is_closed) is True - - @pytest.mark.skip(reason="Prism tests are disabled") - @parametrize - def test_path_params_summarize(self, client: Opencode) -> None: - with pytest.raises(ValueError, match=r"Expected a non-empty value for `id` but received ''"): - client.session.with_raw_response.summarize( - id="", - model_id="modelID", - provider_id="providerID", - ) - - @pytest.mark.skip(reason="Prism tests are disabled") - @parametrize - def test_method_unrevert(self, client: Opencode) -> None: - session = client.session.unrevert( - "id", - ) - assert_matches_type(Session, session, path=["response"]) - - @pytest.mark.skip(reason="Prism tests are disabled") - @parametrize - def test_raw_response_unrevert(self, client: Opencode) -> None: - response = client.session.with_raw_response.unrevert( - "id", - ) - - assert response.is_closed is True - assert response.http_request.headers.get("X-Stainless-Lang") == "python" - session = response.parse() - assert_matches_type(Session, session, path=["response"]) - - @pytest.mark.skip(reason="Prism tests are disabled") - @parametrize - def test_streaming_response_unrevert(self, client: Opencode) -> None: - with client.session.with_streaming_response.unrevert( - "id", - ) as response: - assert not response.is_closed - assert response.http_request.headers.get("X-Stainless-Lang") == "python" - - session = response.parse() - assert_matches_type(Session, session, path=["response"]) - - assert cast(Any, response.is_closed) is True - - @pytest.mark.skip(reason="Prism tests are disabled") - @parametrize - def test_path_params_unrevert(self, client: Opencode) -> None: - with pytest.raises(ValueError, match=r"Expected a non-empty value for `id` but received ''"): - client.session.with_raw_response.unrevert( - "", - ) - - @pytest.mark.skip(reason="Prism tests are disabled") - @parametrize - def test_method_unshare(self, client: Opencode) -> None: - session = client.session.unshare( - "id", - ) - assert_matches_type(Session, session, path=["response"]) - - @pytest.mark.skip(reason="Prism tests are disabled") - @parametrize - def test_raw_response_unshare(self, client: Opencode) -> None: - response = client.session.with_raw_response.unshare( - "id", - ) - - assert response.is_closed is True - assert response.http_request.headers.get("X-Stainless-Lang") == "python" - session = response.parse() - assert_matches_type(Session, session, path=["response"]) - - @pytest.mark.skip(reason="Prism tests are disabled") - @parametrize - def test_streaming_response_unshare(self, client: Opencode) -> None: - with client.session.with_streaming_response.unshare( - "id", - ) as response: - assert not response.is_closed - assert response.http_request.headers.get("X-Stainless-Lang") == "python" - - session = response.parse() - assert_matches_type(Session, session, path=["response"]) - - assert cast(Any, response.is_closed) is True - - @pytest.mark.skip(reason="Prism tests are disabled") - @parametrize - def test_path_params_unshare(self, client: Opencode) -> None: - with pytest.raises(ValueError, match=r"Expected a non-empty value for `id` but received ''"): - client.session.with_raw_response.unshare( - "", - ) - - -class TestAsyncSession: - parametrize = pytest.mark.parametrize( - "async_client", [False, True, {"http_client": "aiohttp"}], indirect=True, ids=["loose", "strict", "aiohttp"] - ) - - @pytest.mark.skip(reason="Prism tests are disabled") - @parametrize - async def test_method_create(self, async_client: AsyncOpencode) -> None: - session = await async_client.session.create() - assert_matches_type(Session, session, path=["response"]) - - @pytest.mark.skip(reason="Prism tests are disabled") - @parametrize - async def test_raw_response_create(self, async_client: AsyncOpencode) -> None: - response = await async_client.session.with_raw_response.create() - - assert response.is_closed is True - assert response.http_request.headers.get("X-Stainless-Lang") == "python" - session = await response.parse() - assert_matches_type(Session, session, path=["response"]) - - @pytest.mark.skip(reason="Prism tests are disabled") - @parametrize - async def test_streaming_response_create(self, async_client: AsyncOpencode) -> None: - async with async_client.session.with_streaming_response.create() as response: - assert not response.is_closed - assert response.http_request.headers.get("X-Stainless-Lang") == "python" - - session = await response.parse() - assert_matches_type(Session, session, path=["response"]) - - assert cast(Any, response.is_closed) is True - - @pytest.mark.skip(reason="Prism tests are disabled") - @parametrize - async def test_method_list(self, async_client: AsyncOpencode) -> None: - session = await async_client.session.list() - assert_matches_type(SessionListResponse, session, path=["response"]) - - @pytest.mark.skip(reason="Prism tests are disabled") - @parametrize - async def test_raw_response_list(self, async_client: AsyncOpencode) -> None: - response = await async_client.session.with_raw_response.list() - - assert response.is_closed is True - assert response.http_request.headers.get("X-Stainless-Lang") == "python" - session = await response.parse() - assert_matches_type(SessionListResponse, session, path=["response"]) - - @pytest.mark.skip(reason="Prism tests are disabled") - @parametrize - async def test_streaming_response_list(self, async_client: AsyncOpencode) -> None: - async with async_client.session.with_streaming_response.list() as response: - assert not response.is_closed - assert response.http_request.headers.get("X-Stainless-Lang") == "python" - - session = await response.parse() - assert_matches_type(SessionListResponse, session, path=["response"]) - - assert cast(Any, response.is_closed) is True - - @pytest.mark.skip(reason="Prism tests are disabled") - @parametrize - async def test_method_delete(self, async_client: AsyncOpencode) -> None: - session = await async_client.session.delete( - "id", - ) - assert_matches_type(SessionDeleteResponse, session, path=["response"]) - - @pytest.mark.skip(reason="Prism tests are disabled") - @parametrize - async def test_raw_response_delete(self, async_client: AsyncOpencode) -> None: - response = await async_client.session.with_raw_response.delete( - "id", - ) - - assert response.is_closed is True - assert response.http_request.headers.get("X-Stainless-Lang") == "python" - session = await response.parse() - assert_matches_type(SessionDeleteResponse, session, path=["response"]) - - @pytest.mark.skip(reason="Prism tests are disabled") - @parametrize - async def test_streaming_response_delete(self, async_client: AsyncOpencode) -> None: - async with async_client.session.with_streaming_response.delete( - "id", - ) as response: - assert not response.is_closed - assert response.http_request.headers.get("X-Stainless-Lang") == "python" - - session = await response.parse() - assert_matches_type(SessionDeleteResponse, session, path=["response"]) - - assert cast(Any, response.is_closed) is True - - @pytest.mark.skip(reason="Prism tests are disabled") - @parametrize - async def test_path_params_delete(self, async_client: AsyncOpencode) -> None: - with pytest.raises(ValueError, match=r"Expected a non-empty value for `id` but received ''"): - await async_client.session.with_raw_response.delete( - "", - ) - - @pytest.mark.skip(reason="Prism tests are disabled") - @parametrize - async def test_method_abort(self, async_client: AsyncOpencode) -> None: - session = await async_client.session.abort( - "id", - ) - assert_matches_type(SessionAbortResponse, session, path=["response"]) - - @pytest.mark.skip(reason="Prism tests are disabled") - @parametrize - async def test_raw_response_abort(self, async_client: AsyncOpencode) -> None: - response = await async_client.session.with_raw_response.abort( - "id", - ) - - assert response.is_closed is True - assert response.http_request.headers.get("X-Stainless-Lang") == "python" - session = await response.parse() - assert_matches_type(SessionAbortResponse, session, path=["response"]) - - @pytest.mark.skip(reason="Prism tests are disabled") - @parametrize - async def test_streaming_response_abort(self, async_client: AsyncOpencode) -> None: - async with async_client.session.with_streaming_response.abort( - "id", - ) as response: - assert not response.is_closed - assert response.http_request.headers.get("X-Stainless-Lang") == "python" - - session = await response.parse() - assert_matches_type(SessionAbortResponse, session, path=["response"]) - - assert cast(Any, response.is_closed) is True - - @pytest.mark.skip(reason="Prism tests are disabled") - @parametrize - async def test_path_params_abort(self, async_client: AsyncOpencode) -> None: - with pytest.raises(ValueError, match=r"Expected a non-empty value for `id` but received ''"): - await async_client.session.with_raw_response.abort( - "", - ) - - @pytest.mark.skip(reason="Prism tests are disabled") - @parametrize - async def test_method_chat(self, async_client: AsyncOpencode) -> None: - session = await async_client.session.chat( - id="id", - model_id="modelID", - parts=[ - { - "text": "text", - "type": "text", - } - ], - provider_id="providerID", - ) - assert_matches_type(AssistantMessage, session, path=["response"]) - - @pytest.mark.skip(reason="Prism tests are disabled") - @parametrize - async def test_method_chat_with_all_params(self, async_client: AsyncOpencode) -> None: - session = await async_client.session.chat( - id="id", - model_id="modelID", - parts=[ - { - "text": "text", - "type": "text", - "id": "id", - "synthetic": True, - "time": { - "start": 0, - "end": 0, - }, - } - ], - provider_id="providerID", - message_id="msg", - mode="mode", - system="system", - tools={"foo": True}, - ) - assert_matches_type(AssistantMessage, session, path=["response"]) - - @pytest.mark.skip(reason="Prism tests are disabled") - @parametrize - async def test_raw_response_chat(self, async_client: AsyncOpencode) -> None: - response = await async_client.session.with_raw_response.chat( - id="id", - model_id="modelID", - parts=[ - { - "text": "text", - "type": "text", - } - ], - provider_id="providerID", - ) - - assert response.is_closed is True - assert response.http_request.headers.get("X-Stainless-Lang") == "python" - session = await response.parse() - assert_matches_type(AssistantMessage, session, path=["response"]) - - @pytest.mark.skip(reason="Prism tests are disabled") - @parametrize - async def test_streaming_response_chat(self, async_client: AsyncOpencode) -> None: - async with async_client.session.with_streaming_response.chat( - id="id", - model_id="modelID", - parts=[ - { - "text": "text", - "type": "text", - } - ], - provider_id="providerID", - ) as response: - assert not response.is_closed - assert response.http_request.headers.get("X-Stainless-Lang") == "python" - - session = await response.parse() - assert_matches_type(AssistantMessage, session, path=["response"]) - - assert cast(Any, response.is_closed) is True - - @pytest.mark.skip(reason="Prism tests are disabled") - @parametrize - async def test_path_params_chat(self, async_client: AsyncOpencode) -> None: - with pytest.raises(ValueError, match=r"Expected a non-empty value for `id` but received ''"): - await async_client.session.with_raw_response.chat( - id="", - model_id="modelID", - parts=[ - { - "text": "text", - "type": "text", - } - ], - provider_id="providerID", - ) - - @pytest.mark.skip(reason="Prism tests are disabled") - @parametrize - async def test_method_init(self, async_client: AsyncOpencode) -> None: - session = await async_client.session.init( - id="id", - message_id="messageID", - model_id="modelID", - provider_id="providerID", - ) - assert_matches_type(SessionInitResponse, session, path=["response"]) - - @pytest.mark.skip(reason="Prism tests are disabled") - @parametrize - async def test_raw_response_init(self, async_client: AsyncOpencode) -> None: - response = await async_client.session.with_raw_response.init( - id="id", - message_id="messageID", - model_id="modelID", - provider_id="providerID", - ) - - assert response.is_closed is True - assert response.http_request.headers.get("X-Stainless-Lang") == "python" - session = await response.parse() - assert_matches_type(SessionInitResponse, session, path=["response"]) - - @pytest.mark.skip(reason="Prism tests are disabled") - @parametrize - async def test_streaming_response_init(self, async_client: AsyncOpencode) -> None: - async with async_client.session.with_streaming_response.init( - id="id", - message_id="messageID", - model_id="modelID", - provider_id="providerID", - ) as response: - assert not response.is_closed - assert response.http_request.headers.get("X-Stainless-Lang") == "python" - - session = await response.parse() - assert_matches_type(SessionInitResponse, session, path=["response"]) - - assert cast(Any, response.is_closed) is True - - @pytest.mark.skip(reason="Prism tests are disabled") - @parametrize - async def test_path_params_init(self, async_client: AsyncOpencode) -> None: - with pytest.raises(ValueError, match=r"Expected a non-empty value for `id` but received ''"): - await async_client.session.with_raw_response.init( - id="", - message_id="messageID", - model_id="modelID", - provider_id="providerID", - ) - - @pytest.mark.skip(reason="Prism tests are disabled") - @parametrize - async def test_method_messages(self, async_client: AsyncOpencode) -> None: - session = await async_client.session.messages( - "id", - ) - assert_matches_type(SessionMessagesResponse, session, path=["response"]) - - @pytest.mark.skip(reason="Prism tests are disabled") - @parametrize - async def test_raw_response_messages(self, async_client: AsyncOpencode) -> None: - response = await async_client.session.with_raw_response.messages( - "id", - ) - - assert response.is_closed is True - assert response.http_request.headers.get("X-Stainless-Lang") == "python" - session = await response.parse() - assert_matches_type(SessionMessagesResponse, session, path=["response"]) - - @pytest.mark.skip(reason="Prism tests are disabled") - @parametrize - async def test_streaming_response_messages(self, async_client: AsyncOpencode) -> None: - async with async_client.session.with_streaming_response.messages( - "id", - ) as response: - assert not response.is_closed - assert response.http_request.headers.get("X-Stainless-Lang") == "python" - - session = await response.parse() - assert_matches_type(SessionMessagesResponse, session, path=["response"]) - - assert cast(Any, response.is_closed) is True - - @pytest.mark.skip(reason="Prism tests are disabled") - @parametrize - async def test_path_params_messages(self, async_client: AsyncOpencode) -> None: - with pytest.raises(ValueError, match=r"Expected a non-empty value for `id` but received ''"): - await async_client.session.with_raw_response.messages( - "", - ) - - @pytest.mark.skip(reason="Prism tests are disabled") - @parametrize - async def test_method_revert(self, async_client: AsyncOpencode) -> None: - session = await async_client.session.revert( - id="id", - message_id="msg", - ) - assert_matches_type(Session, session, path=["response"]) - - @pytest.mark.skip(reason="Prism tests are disabled") - @parametrize - async def test_method_revert_with_all_params(self, async_client: AsyncOpencode) -> None: - session = await async_client.session.revert( - id="id", - message_id="msg", - part_id="prt", - ) - assert_matches_type(Session, session, path=["response"]) - - @pytest.mark.skip(reason="Prism tests are disabled") - @parametrize - async def test_raw_response_revert(self, async_client: AsyncOpencode) -> None: - response = await async_client.session.with_raw_response.revert( - id="id", - message_id="msg", - ) - - assert response.is_closed is True - assert response.http_request.headers.get("X-Stainless-Lang") == "python" - session = await response.parse() - assert_matches_type(Session, session, path=["response"]) - - @pytest.mark.skip(reason="Prism tests are disabled") - @parametrize - async def test_streaming_response_revert(self, async_client: AsyncOpencode) -> None: - async with async_client.session.with_streaming_response.revert( - id="id", - message_id="msg", - ) as response: - assert not response.is_closed - assert response.http_request.headers.get("X-Stainless-Lang") == "python" - - session = await response.parse() - assert_matches_type(Session, session, path=["response"]) - - assert cast(Any, response.is_closed) is True - - @pytest.mark.skip(reason="Prism tests are disabled") - @parametrize - async def test_path_params_revert(self, async_client: AsyncOpencode) -> None: - with pytest.raises(ValueError, match=r"Expected a non-empty value for `id` but received ''"): - await async_client.session.with_raw_response.revert( - id="", - message_id="msg", - ) - - @pytest.mark.skip(reason="Prism tests are disabled") - @parametrize - async def test_method_share(self, async_client: AsyncOpencode) -> None: - session = await async_client.session.share( - "id", - ) - assert_matches_type(Session, session, path=["response"]) - - @pytest.mark.skip(reason="Prism tests are disabled") - @parametrize - async def test_raw_response_share(self, async_client: AsyncOpencode) -> None: - response = await async_client.session.with_raw_response.share( - "id", - ) - - assert response.is_closed is True - assert response.http_request.headers.get("X-Stainless-Lang") == "python" - session = await response.parse() - assert_matches_type(Session, session, path=["response"]) - - @pytest.mark.skip(reason="Prism tests are disabled") - @parametrize - async def test_streaming_response_share(self, async_client: AsyncOpencode) -> None: - async with async_client.session.with_streaming_response.share( - "id", - ) as response: - assert not response.is_closed - assert response.http_request.headers.get("X-Stainless-Lang") == "python" - - session = await response.parse() - assert_matches_type(Session, session, path=["response"]) - - assert cast(Any, response.is_closed) is True - - @pytest.mark.skip(reason="Prism tests are disabled") - @parametrize - async def test_path_params_share(self, async_client: AsyncOpencode) -> None: - with pytest.raises(ValueError, match=r"Expected a non-empty value for `id` but received ''"): - await async_client.session.with_raw_response.share( - "", - ) - - @pytest.mark.skip(reason="Prism tests are disabled") - @parametrize - async def test_method_summarize(self, async_client: AsyncOpencode) -> None: - session = await async_client.session.summarize( - id="id", - model_id="modelID", - provider_id="providerID", - ) - assert_matches_type(SessionSummarizeResponse, session, path=["response"]) - - @pytest.mark.skip(reason="Prism tests are disabled") - @parametrize - async def test_raw_response_summarize(self, async_client: AsyncOpencode) -> None: - response = await async_client.session.with_raw_response.summarize( - id="id", - model_id="modelID", - provider_id="providerID", - ) - - assert response.is_closed is True - assert response.http_request.headers.get("X-Stainless-Lang") == "python" - session = await response.parse() - assert_matches_type(SessionSummarizeResponse, session, path=["response"]) - - @pytest.mark.skip(reason="Prism tests are disabled") - @parametrize - async def test_streaming_response_summarize(self, async_client: AsyncOpencode) -> None: - async with async_client.session.with_streaming_response.summarize( - id="id", - model_id="modelID", - provider_id="providerID", - ) as response: - assert not response.is_closed - assert response.http_request.headers.get("X-Stainless-Lang") == "python" - - session = await response.parse() - assert_matches_type(SessionSummarizeResponse, session, path=["response"]) - - assert cast(Any, response.is_closed) is True - - @pytest.mark.skip(reason="Prism tests are disabled") - @parametrize - async def test_path_params_summarize(self, async_client: AsyncOpencode) -> None: - with pytest.raises(ValueError, match=r"Expected a non-empty value for `id` but received ''"): - await async_client.session.with_raw_response.summarize( - id="", - model_id="modelID", - provider_id="providerID", - ) - - @pytest.mark.skip(reason="Prism tests are disabled") - @parametrize - async def test_method_unrevert(self, async_client: AsyncOpencode) -> None: - session = await async_client.session.unrevert( - "id", - ) - assert_matches_type(Session, session, path=["response"]) - - @pytest.mark.skip(reason="Prism tests are disabled") - @parametrize - async def test_raw_response_unrevert(self, async_client: AsyncOpencode) -> None: - response = await async_client.session.with_raw_response.unrevert( - "id", - ) - - assert response.is_closed is True - assert response.http_request.headers.get("X-Stainless-Lang") == "python" - session = await response.parse() - assert_matches_type(Session, session, path=["response"]) - - @pytest.mark.skip(reason="Prism tests are disabled") - @parametrize - async def test_streaming_response_unrevert(self, async_client: AsyncOpencode) -> None: - async with async_client.session.with_streaming_response.unrevert( - "id", - ) as response: - assert not response.is_closed - assert response.http_request.headers.get("X-Stainless-Lang") == "python" - - session = await response.parse() - assert_matches_type(Session, session, path=["response"]) - - assert cast(Any, response.is_closed) is True - - @pytest.mark.skip(reason="Prism tests are disabled") - @parametrize - async def test_path_params_unrevert(self, async_client: AsyncOpencode) -> None: - with pytest.raises(ValueError, match=r"Expected a non-empty value for `id` but received ''"): - await async_client.session.with_raw_response.unrevert( - "", - ) - - @pytest.mark.skip(reason="Prism tests are disabled") - @parametrize - async def test_method_unshare(self, async_client: AsyncOpencode) -> None: - session = await async_client.session.unshare( - "id", - ) - assert_matches_type(Session, session, path=["response"]) - - @pytest.mark.skip(reason="Prism tests are disabled") - @parametrize - async def test_raw_response_unshare(self, async_client: AsyncOpencode) -> None: - response = await async_client.session.with_raw_response.unshare( - "id", - ) - - assert response.is_closed is True - assert response.http_request.headers.get("X-Stainless-Lang") == "python" - session = await response.parse() - assert_matches_type(Session, session, path=["response"]) - - @pytest.mark.skip(reason="Prism tests are disabled") - @parametrize - async def test_streaming_response_unshare(self, async_client: AsyncOpencode) -> None: - async with async_client.session.with_streaming_response.unshare( - "id", - ) as response: - assert not response.is_closed - assert response.http_request.headers.get("X-Stainless-Lang") == "python" - - session = await response.parse() - assert_matches_type(Session, session, path=["response"]) - - assert cast(Any, response.is_closed) is True - - @pytest.mark.skip(reason="Prism tests are disabled") - @parametrize - async def test_path_params_unshare(self, async_client: AsyncOpencode) -> None: - with pytest.raises(ValueError, match=r"Expected a non-empty value for `id` but received ''"): - await async_client.session.with_raw_response.unshare( - "", - ) diff --git a/tests/api_resources/test_tui.py b/tests/api_resources/test_tui.py deleted file mode 100644 index 06145a5..0000000 --- a/tests/api_resources/test_tui.py +++ /dev/null @@ -1,148 +0,0 @@ -# File generated from our OpenAPI spec by Stainless. See CONTRIBUTING.md for details. - -from __future__ import annotations - -import os -from typing import Any, cast - -import pytest - -from opencode_ai import Opencode, AsyncOpencode -from tests.utils import assert_matches_type -from opencode_ai.types import TuiOpenHelpResponse, TuiAppendPromptResponse - -base_url = os.environ.get("TEST_API_BASE_URL", "http://127.0.0.1:4010") - - -class TestTui: - parametrize = pytest.mark.parametrize("client", [False, True], indirect=True, ids=["loose", "strict"]) - - @pytest.mark.skip(reason="Prism tests are disabled") - @parametrize - def test_method_append_prompt(self, client: Opencode) -> None: - tui = client.tui.append_prompt( - text="text", - ) - assert_matches_type(TuiAppendPromptResponse, tui, path=["response"]) - - @pytest.mark.skip(reason="Prism tests are disabled") - @parametrize - def test_raw_response_append_prompt(self, client: Opencode) -> None: - response = client.tui.with_raw_response.append_prompt( - text="text", - ) - - assert response.is_closed is True - assert response.http_request.headers.get("X-Stainless-Lang") == "python" - tui = response.parse() - assert_matches_type(TuiAppendPromptResponse, tui, path=["response"]) - - @pytest.mark.skip(reason="Prism tests are disabled") - @parametrize - def test_streaming_response_append_prompt(self, client: Opencode) -> None: - with client.tui.with_streaming_response.append_prompt( - text="text", - ) as response: - assert not response.is_closed - assert response.http_request.headers.get("X-Stainless-Lang") == "python" - - tui = response.parse() - assert_matches_type(TuiAppendPromptResponse, tui, path=["response"]) - - assert cast(Any, response.is_closed) is True - - @pytest.mark.skip(reason="Prism tests are disabled") - @parametrize - def test_method_open_help(self, client: Opencode) -> None: - tui = client.tui.open_help() - assert_matches_type(TuiOpenHelpResponse, tui, path=["response"]) - - @pytest.mark.skip(reason="Prism tests are disabled") - @parametrize - def test_raw_response_open_help(self, client: Opencode) -> None: - response = client.tui.with_raw_response.open_help() - - assert response.is_closed is True - assert response.http_request.headers.get("X-Stainless-Lang") == "python" - tui = response.parse() - assert_matches_type(TuiOpenHelpResponse, tui, path=["response"]) - - @pytest.mark.skip(reason="Prism tests are disabled") - @parametrize - def test_streaming_response_open_help(self, client: Opencode) -> None: - with client.tui.with_streaming_response.open_help() as response: - assert not response.is_closed - assert response.http_request.headers.get("X-Stainless-Lang") == "python" - - tui = response.parse() - assert_matches_type(TuiOpenHelpResponse, tui, path=["response"]) - - assert cast(Any, response.is_closed) is True - - -class TestAsyncTui: - parametrize = pytest.mark.parametrize( - "async_client", [False, True, {"http_client": "aiohttp"}], indirect=True, ids=["loose", "strict", "aiohttp"] - ) - - @pytest.mark.skip(reason="Prism tests are disabled") - @parametrize - async def test_method_append_prompt(self, async_client: AsyncOpencode) -> None: - tui = await async_client.tui.append_prompt( - text="text", - ) - assert_matches_type(TuiAppendPromptResponse, tui, path=["response"]) - - @pytest.mark.skip(reason="Prism tests are disabled") - @parametrize - async def test_raw_response_append_prompt(self, async_client: AsyncOpencode) -> None: - response = await async_client.tui.with_raw_response.append_prompt( - text="text", - ) - - assert response.is_closed is True - assert response.http_request.headers.get("X-Stainless-Lang") == "python" - tui = await response.parse() - assert_matches_type(TuiAppendPromptResponse, tui, path=["response"]) - - @pytest.mark.skip(reason="Prism tests are disabled") - @parametrize - async def test_streaming_response_append_prompt(self, async_client: AsyncOpencode) -> None: - async with async_client.tui.with_streaming_response.append_prompt( - text="text", - ) as response: - assert not response.is_closed - assert response.http_request.headers.get("X-Stainless-Lang") == "python" - - tui = await response.parse() - assert_matches_type(TuiAppendPromptResponse, tui, path=["response"]) - - assert cast(Any, response.is_closed) is True - - @pytest.mark.skip(reason="Prism tests are disabled") - @parametrize - async def test_method_open_help(self, async_client: AsyncOpencode) -> None: - tui = await async_client.tui.open_help() - assert_matches_type(TuiOpenHelpResponse, tui, path=["response"]) - - @pytest.mark.skip(reason="Prism tests are disabled") - @parametrize - async def test_raw_response_open_help(self, async_client: AsyncOpencode) -> None: - response = await async_client.tui.with_raw_response.open_help() - - assert response.is_closed is True - assert response.http_request.headers.get("X-Stainless-Lang") == "python" - tui = await response.parse() - assert_matches_type(TuiOpenHelpResponse, tui, path=["response"]) - - @pytest.mark.skip(reason="Prism tests are disabled") - @parametrize - async def test_streaming_response_open_help(self, async_client: AsyncOpencode) -> None: - async with async_client.tui.with_streaming_response.open_help() as response: - assert not response.is_closed - assert response.http_request.headers.get("X-Stainless-Lang") == "python" - - tui = await response.parse() - assert_matches_type(TuiOpenHelpResponse, tui, path=["response"]) - - assert cast(Any, response.is_closed) is True diff --git a/tests/conftest.py b/tests/conftest.py deleted file mode 100644 index aa47bd6..0000000 --- a/tests/conftest.py +++ /dev/null @@ -1,80 +0,0 @@ -# File generated from our OpenAPI spec by Stainless. See CONTRIBUTING.md for details. - -from __future__ import annotations - -import os -import logging -from typing import TYPE_CHECKING, Iterator, AsyncIterator - -import httpx -import pytest -from pytest_asyncio import is_async_test - -from opencode_ai import Opencode, AsyncOpencode, DefaultAioHttpClient -from opencode_ai._utils import is_dict - -if TYPE_CHECKING: - from _pytest.fixtures import FixtureRequest # pyright: ignore[reportPrivateImportUsage] - -pytest.register_assert_rewrite("tests.utils") - -logging.getLogger("opencode_ai").setLevel(logging.DEBUG) - - -# automatically add `pytest.mark.asyncio()` to all of our async tests -# so we don't have to add that boilerplate everywhere -def pytest_collection_modifyitems(items: list[pytest.Function]) -> None: - pytest_asyncio_tests = (item for item in items if is_async_test(item)) - session_scope_marker = pytest.mark.asyncio(loop_scope="session") - for async_test in pytest_asyncio_tests: - async_test.add_marker(session_scope_marker, append=False) - - # We skip tests that use both the aiohttp client and respx_mock as respx_mock - # doesn't support custom transports. - for item in items: - if "async_client" not in item.fixturenames or "respx_mock" not in item.fixturenames: - continue - - if not hasattr(item, "callspec"): - continue - - async_client_param = item.callspec.params.get("async_client") - if is_dict(async_client_param) and async_client_param.get("http_client") == "aiohttp": - item.add_marker(pytest.mark.skip(reason="aiohttp client is not compatible with respx_mock")) - - -base_url = os.environ.get("TEST_API_BASE_URL", "http://127.0.0.1:4010") - - -@pytest.fixture(scope="session") -def client(request: FixtureRequest) -> Iterator[Opencode]: - strict = getattr(request, "param", True) - if not isinstance(strict, bool): - raise TypeError(f"Unexpected fixture parameter type {type(strict)}, expected {bool}") - - with Opencode(base_url=base_url, _strict_response_validation=strict) as client: - yield client - - -@pytest.fixture(scope="session") -async def async_client(request: FixtureRequest) -> AsyncIterator[AsyncOpencode]: - param = getattr(request, "param", True) - - # defaults - strict = True - http_client: None | httpx.AsyncClient = None - - if isinstance(param, bool): - strict = param - elif is_dict(param): - strict = param.get("strict", True) - assert isinstance(strict, bool) - - http_client_type = param.get("http_client", "httpx") - if http_client_type == "aiohttp": - http_client = DefaultAioHttpClient() - else: - raise TypeError(f"Unexpected fixture parameter type {type(param)}, expected bool or dict") - - async with AsyncOpencode(base_url=base_url, _strict_response_validation=strict, http_client=http_client) as client: - yield client diff --git a/tests/sample_file.txt b/tests/sample_file.txt deleted file mode 100644 index af5626b..0000000 --- a/tests/sample_file.txt +++ /dev/null @@ -1 +0,0 @@ -Hello, world! diff --git a/tests/test_client.py b/tests/test_client.py deleted file mode 100644 index f8225c7..0000000 --- a/tests/test_client.py +++ /dev/null @@ -1,1661 +0,0 @@ -# File generated from our OpenAPI spec by Stainless. See CONTRIBUTING.md for details. - -from __future__ import annotations - -import gc -import os -import sys -import json -import time -import asyncio -import inspect -import subprocess -import tracemalloc -from typing import Any, Union, cast -from textwrap import dedent -from unittest import mock -from typing_extensions import Literal - -import httpx -import pytest -from respx import MockRouter -from pydantic import ValidationError - -from opencode_ai import Opencode, AsyncOpencode, APIResponseValidationError -from opencode_ai._types import Omit -from opencode_ai._models import BaseModel, FinalRequestOptions -from opencode_ai._streaming import Stream, AsyncStream -from opencode_ai._exceptions import APIStatusError, APITimeoutError, APIResponseValidationError -from opencode_ai._base_client import ( - DEFAULT_TIMEOUT, - HTTPX_DEFAULT_TIMEOUT, - BaseClient, - DefaultHttpxClient, - DefaultAsyncHttpxClient, - make_request_options, -) - -from .utils import update_env - -base_url = os.environ.get("TEST_API_BASE_URL", "http://127.0.0.1:4010") - - -def _get_params(client: BaseClient[Any, Any]) -> dict[str, str]: - request = client._build_request(FinalRequestOptions(method="get", url="/foo")) - url = httpx.URL(request.url) - return dict(url.params) - - -def _low_retry_timeout(*_args: Any, **_kwargs: Any) -> float: - return 0.1 - - -def _get_open_connections(client: Opencode | AsyncOpencode) -> int: - transport = client._client._transport - assert isinstance(transport, httpx.HTTPTransport) or isinstance(transport, httpx.AsyncHTTPTransport) - - pool = transport._pool - return len(pool._requests) - - -class TestOpencode: - client = Opencode(base_url=base_url, _strict_response_validation=True) - - @pytest.mark.respx(base_url=base_url) - def test_raw_response(self, respx_mock: MockRouter) -> None: - respx_mock.post("/foo").mock(return_value=httpx.Response(200, json={"foo": "bar"})) - - response = self.client.post("/foo", cast_to=httpx.Response) - assert response.status_code == 200 - assert isinstance(response, httpx.Response) - assert response.json() == {"foo": "bar"} - - @pytest.mark.respx(base_url=base_url) - def test_raw_response_for_binary(self, respx_mock: MockRouter) -> None: - respx_mock.post("/foo").mock( - return_value=httpx.Response(200, headers={"Content-Type": "application/binary"}, content='{"foo": "bar"}') - ) - - response = self.client.post("/foo", cast_to=httpx.Response) - assert response.status_code == 200 - assert isinstance(response, httpx.Response) - assert response.json() == {"foo": "bar"} - - def test_copy(self) -> None: - copied = self.client.copy() - assert id(copied) != id(self.client) - - def test_copy_default_options(self) -> None: - # options that have a default are overridden correctly - copied = self.client.copy(max_retries=7) - assert copied.max_retries == 7 - assert self.client.max_retries == 2 - - copied2 = copied.copy(max_retries=6) - assert copied2.max_retries == 6 - assert copied.max_retries == 7 - - # timeout - assert isinstance(self.client.timeout, httpx.Timeout) - copied = self.client.copy(timeout=None) - assert copied.timeout is None - assert isinstance(self.client.timeout, httpx.Timeout) - - def test_copy_default_headers(self) -> None: - client = Opencode(base_url=base_url, _strict_response_validation=True, default_headers={"X-Foo": "bar"}) - assert client.default_headers["X-Foo"] == "bar" - - # does not override the already given value when not specified - copied = client.copy() - assert copied.default_headers["X-Foo"] == "bar" - - # merges already given headers - copied = client.copy(default_headers={"X-Bar": "stainless"}) - assert copied.default_headers["X-Foo"] == "bar" - assert copied.default_headers["X-Bar"] == "stainless" - - # uses new values for any already given headers - copied = client.copy(default_headers={"X-Foo": "stainless"}) - assert copied.default_headers["X-Foo"] == "stainless" - - # set_default_headers - - # completely overrides already set values - copied = client.copy(set_default_headers={}) - assert copied.default_headers.get("X-Foo") is None - - copied = client.copy(set_default_headers={"X-Bar": "Robert"}) - assert copied.default_headers["X-Bar"] == "Robert" - - with pytest.raises( - ValueError, - match="`default_headers` and `set_default_headers` arguments are mutually exclusive", - ): - client.copy(set_default_headers={}, default_headers={"X-Foo": "Bar"}) - - def test_copy_default_query(self) -> None: - client = Opencode(base_url=base_url, _strict_response_validation=True, default_query={"foo": "bar"}) - assert _get_params(client)["foo"] == "bar" - - # does not override the already given value when not specified - copied = client.copy() - assert _get_params(copied)["foo"] == "bar" - - # merges already given params - copied = client.copy(default_query={"bar": "stainless"}) - params = _get_params(copied) - assert params["foo"] == "bar" - assert params["bar"] == "stainless" - - # uses new values for any already given headers - copied = client.copy(default_query={"foo": "stainless"}) - assert _get_params(copied)["foo"] == "stainless" - - # set_default_query - - # completely overrides already set values - copied = client.copy(set_default_query={}) - assert _get_params(copied) == {} - - copied = client.copy(set_default_query={"bar": "Robert"}) - assert _get_params(copied)["bar"] == "Robert" - - with pytest.raises( - ValueError, - # TODO: update - match="`default_query` and `set_default_query` arguments are mutually exclusive", - ): - client.copy(set_default_query={}, default_query={"foo": "Bar"}) - - def test_copy_signature(self) -> None: - # ensure the same parameters that can be passed to the client are defined in the `.copy()` method - init_signature = inspect.signature( - # mypy doesn't like that we access the `__init__` property. - self.client.__init__, # type: ignore[misc] - ) - copy_signature = inspect.signature(self.client.copy) - exclude_params = {"transport", "proxies", "_strict_response_validation"} - - for name in init_signature.parameters.keys(): - if name in exclude_params: - continue - - copy_param = copy_signature.parameters.get(name) - assert copy_param is not None, f"copy() signature is missing the {name} param" - - @pytest.mark.skipif(sys.version_info >= (3, 10), reason="fails because of a memory leak that started from 3.12") - def test_copy_build_request(self) -> None: - options = FinalRequestOptions(method="get", url="/foo") - - def build_request(options: FinalRequestOptions) -> None: - client = self.client.copy() - client._build_request(options) - - # ensure that the machinery is warmed up before tracing starts. - build_request(options) - gc.collect() - - tracemalloc.start(1000) - - snapshot_before = tracemalloc.take_snapshot() - - ITERATIONS = 10 - for _ in range(ITERATIONS): - build_request(options) - - gc.collect() - snapshot_after = tracemalloc.take_snapshot() - - tracemalloc.stop() - - def add_leak(leaks: list[tracemalloc.StatisticDiff], diff: tracemalloc.StatisticDiff) -> None: - if diff.count == 0: - # Avoid false positives by considering only leaks (i.e. allocations that persist). - return - - if diff.count % ITERATIONS != 0: - # Avoid false positives by considering only leaks that appear per iteration. - return - - for frame in diff.traceback: - if any( - frame.filename.endswith(fragment) - for fragment in [ - # to_raw_response_wrapper leaks through the @functools.wraps() decorator. - # - # removing the decorator fixes the leak for reasons we don't understand. - "opencode_ai/_legacy_response.py", - "opencode_ai/_response.py", - # pydantic.BaseModel.model_dump || pydantic.BaseModel.dict leak memory for some reason. - "opencode_ai/_compat.py", - # Standard library leaks we don't care about. - "/logging/__init__.py", - ] - ): - return - - leaks.append(diff) - - leaks: list[tracemalloc.StatisticDiff] = [] - for diff in snapshot_after.compare_to(snapshot_before, "traceback"): - add_leak(leaks, diff) - if leaks: - for leak in leaks: - print("MEMORY LEAK:", leak) - for frame in leak.traceback: - print(frame) - raise AssertionError() - - def test_request_timeout(self) -> None: - request = self.client._build_request(FinalRequestOptions(method="get", url="/foo")) - timeout = httpx.Timeout(**request.extensions["timeout"]) # type: ignore - assert timeout == DEFAULT_TIMEOUT - - request = self.client._build_request( - FinalRequestOptions(method="get", url="/foo", timeout=httpx.Timeout(100.0)) - ) - timeout = httpx.Timeout(**request.extensions["timeout"]) # type: ignore - assert timeout == httpx.Timeout(100.0) - - def test_client_timeout_option(self) -> None: - client = Opencode(base_url=base_url, _strict_response_validation=True, timeout=httpx.Timeout(0)) - - request = client._build_request(FinalRequestOptions(method="get", url="/foo")) - timeout = httpx.Timeout(**request.extensions["timeout"]) # type: ignore - assert timeout == httpx.Timeout(0) - - def test_http_client_timeout_option(self) -> None: - # custom timeout given to the httpx client should be used - with httpx.Client(timeout=None) as http_client: - client = Opencode(base_url=base_url, _strict_response_validation=True, http_client=http_client) - - request = client._build_request(FinalRequestOptions(method="get", url="/foo")) - timeout = httpx.Timeout(**request.extensions["timeout"]) # type: ignore - assert timeout == httpx.Timeout(None) - - # no timeout given to the httpx client should not use the httpx default - with httpx.Client() as http_client: - client = Opencode(base_url=base_url, _strict_response_validation=True, http_client=http_client) - - request = client._build_request(FinalRequestOptions(method="get", url="/foo")) - timeout = httpx.Timeout(**request.extensions["timeout"]) # type: ignore - assert timeout == DEFAULT_TIMEOUT - - # explicitly passing the default timeout currently results in it being ignored - with httpx.Client(timeout=HTTPX_DEFAULT_TIMEOUT) as http_client: - client = Opencode(base_url=base_url, _strict_response_validation=True, http_client=http_client) - - request = client._build_request(FinalRequestOptions(method="get", url="/foo")) - timeout = httpx.Timeout(**request.extensions["timeout"]) # type: ignore - assert timeout == DEFAULT_TIMEOUT # our default - - async def test_invalid_http_client(self) -> None: - with pytest.raises(TypeError, match="Invalid `http_client` arg"): - async with httpx.AsyncClient() as http_client: - Opencode(base_url=base_url, _strict_response_validation=True, http_client=cast(Any, http_client)) - - def test_default_headers_option(self) -> None: - client = Opencode(base_url=base_url, _strict_response_validation=True, default_headers={"X-Foo": "bar"}) - request = client._build_request(FinalRequestOptions(method="get", url="/foo")) - assert request.headers.get("x-foo") == "bar" - assert request.headers.get("x-stainless-lang") == "python" - - client2 = Opencode( - base_url=base_url, - _strict_response_validation=True, - default_headers={ - "X-Foo": "stainless", - "X-Stainless-Lang": "my-overriding-header", - }, - ) - request = client2._build_request(FinalRequestOptions(method="get", url="/foo")) - assert request.headers.get("x-foo") == "stainless" - assert request.headers.get("x-stainless-lang") == "my-overriding-header" - - def test_default_query_option(self) -> None: - client = Opencode(base_url=base_url, _strict_response_validation=True, default_query={"query_param": "bar"}) - request = client._build_request(FinalRequestOptions(method="get", url="/foo")) - url = httpx.URL(request.url) - assert dict(url.params) == {"query_param": "bar"} - - request = client._build_request( - FinalRequestOptions( - method="get", - url="/foo", - params={"foo": "baz", "query_param": "overridden"}, - ) - ) - url = httpx.URL(request.url) - assert dict(url.params) == {"foo": "baz", "query_param": "overridden"} - - def test_request_extra_json(self) -> None: - request = self.client._build_request( - FinalRequestOptions( - method="post", - url="/foo", - json_data={"foo": "bar"}, - extra_json={"baz": False}, - ), - ) - data = json.loads(request.content.decode("utf-8")) - assert data == {"foo": "bar", "baz": False} - - request = self.client._build_request( - FinalRequestOptions( - method="post", - url="/foo", - extra_json={"baz": False}, - ), - ) - data = json.loads(request.content.decode("utf-8")) - assert data == {"baz": False} - - # `extra_json` takes priority over `json_data` when keys clash - request = self.client._build_request( - FinalRequestOptions( - method="post", - url="/foo", - json_data={"foo": "bar", "baz": True}, - extra_json={"baz": None}, - ), - ) - data = json.loads(request.content.decode("utf-8")) - assert data == {"foo": "bar", "baz": None} - - def test_request_extra_headers(self) -> None: - request = self.client._build_request( - FinalRequestOptions( - method="post", - url="/foo", - **make_request_options(extra_headers={"X-Foo": "Foo"}), - ), - ) - assert request.headers.get("X-Foo") == "Foo" - - # `extra_headers` takes priority over `default_headers` when keys clash - request = self.client.with_options(default_headers={"X-Bar": "true"})._build_request( - FinalRequestOptions( - method="post", - url="/foo", - **make_request_options( - extra_headers={"X-Bar": "false"}, - ), - ), - ) - assert request.headers.get("X-Bar") == "false" - - def test_request_extra_query(self) -> None: - request = self.client._build_request( - FinalRequestOptions( - method="post", - url="/foo", - **make_request_options( - extra_query={"my_query_param": "Foo"}, - ), - ), - ) - params = dict(request.url.params) - assert params == {"my_query_param": "Foo"} - - # if both `query` and `extra_query` are given, they are merged - request = self.client._build_request( - FinalRequestOptions( - method="post", - url="/foo", - **make_request_options( - query={"bar": "1"}, - extra_query={"foo": "2"}, - ), - ), - ) - params = dict(request.url.params) - assert params == {"bar": "1", "foo": "2"} - - # `extra_query` takes priority over `query` when keys clash - request = self.client._build_request( - FinalRequestOptions( - method="post", - url="/foo", - **make_request_options( - query={"foo": "1"}, - extra_query={"foo": "2"}, - ), - ), - ) - params = dict(request.url.params) - assert params == {"foo": "2"} - - def test_multipart_repeating_array(self, client: Opencode) -> None: - request = client._build_request( - FinalRequestOptions.construct( - method="post", - url="/foo", - headers={"Content-Type": "multipart/form-data; boundary=6b7ba517decee4a450543ea6ae821c82"}, - json_data={"array": ["foo", "bar"]}, - files=[("foo.txt", b"hello world")], - ) - ) - - assert request.read().split(b"\r\n") == [ - b"--6b7ba517decee4a450543ea6ae821c82", - b'Content-Disposition: form-data; name="array[]"', - b"", - b"foo", - b"--6b7ba517decee4a450543ea6ae821c82", - b'Content-Disposition: form-data; name="array[]"', - b"", - b"bar", - b"--6b7ba517decee4a450543ea6ae821c82", - b'Content-Disposition: form-data; name="foo.txt"; filename="upload"', - b"Content-Type: application/octet-stream", - b"", - b"hello world", - b"--6b7ba517decee4a450543ea6ae821c82--", - b"", - ] - - @pytest.mark.respx(base_url=base_url) - def test_basic_union_response(self, respx_mock: MockRouter) -> None: - class Model1(BaseModel): - name: str - - class Model2(BaseModel): - foo: str - - respx_mock.get("/foo").mock(return_value=httpx.Response(200, json={"foo": "bar"})) - - response = self.client.get("/foo", cast_to=cast(Any, Union[Model1, Model2])) - assert isinstance(response, Model2) - assert response.foo == "bar" - - @pytest.mark.respx(base_url=base_url) - def test_union_response_different_types(self, respx_mock: MockRouter) -> None: - """Union of objects with the same field name using a different type""" - - class Model1(BaseModel): - foo: int - - class Model2(BaseModel): - foo: str - - respx_mock.get("/foo").mock(return_value=httpx.Response(200, json={"foo": "bar"})) - - response = self.client.get("/foo", cast_to=cast(Any, Union[Model1, Model2])) - assert isinstance(response, Model2) - assert response.foo == "bar" - - respx_mock.get("/foo").mock(return_value=httpx.Response(200, json={"foo": 1})) - - response = self.client.get("/foo", cast_to=cast(Any, Union[Model1, Model2])) - assert isinstance(response, Model1) - assert response.foo == 1 - - @pytest.mark.respx(base_url=base_url) - def test_non_application_json_content_type_for_json_data(self, respx_mock: MockRouter) -> None: - """ - Response that sets Content-Type to something other than application/json but returns json data - """ - - class Model(BaseModel): - foo: int - - respx_mock.get("/foo").mock( - return_value=httpx.Response( - 200, - content=json.dumps({"foo": 2}), - headers={"Content-Type": "application/text"}, - ) - ) - - response = self.client.get("/foo", cast_to=Model) - assert isinstance(response, Model) - assert response.foo == 2 - - def test_base_url_setter(self) -> None: - client = Opencode(base_url="https://example.com/from_init", _strict_response_validation=True) - assert client.base_url == "https://example.com/from_init/" - - client.base_url = "https://example.com/from_setter" # type: ignore[assignment] - - assert client.base_url == "https://example.com/from_setter/" - - def test_base_url_env(self) -> None: - with update_env(OPENCODE_BASE_URL="http://localhost:5000/from/env"): - client = Opencode(_strict_response_validation=True) - assert client.base_url == "http://localhost:5000/from/env/" - - @pytest.mark.parametrize( - "client", - [ - Opencode(base_url="http://localhost:5000/custom/path/", _strict_response_validation=True), - Opencode( - base_url="http://localhost:5000/custom/path/", - _strict_response_validation=True, - http_client=httpx.Client(), - ), - ], - ids=["standard", "custom http client"], - ) - def test_base_url_trailing_slash(self, client: Opencode) -> None: - request = client._build_request( - FinalRequestOptions( - method="post", - url="/foo", - json_data={"foo": "bar"}, - ), - ) - assert request.url == "http://localhost:5000/custom/path/foo" - - @pytest.mark.parametrize( - "client", - [ - Opencode(base_url="http://localhost:5000/custom/path/", _strict_response_validation=True), - Opencode( - base_url="http://localhost:5000/custom/path/", - _strict_response_validation=True, - http_client=httpx.Client(), - ), - ], - ids=["standard", "custom http client"], - ) - def test_base_url_no_trailing_slash(self, client: Opencode) -> None: - request = client._build_request( - FinalRequestOptions( - method="post", - url="/foo", - json_data={"foo": "bar"}, - ), - ) - assert request.url == "http://localhost:5000/custom/path/foo" - - @pytest.mark.parametrize( - "client", - [ - Opencode(base_url="http://localhost:5000/custom/path/", _strict_response_validation=True), - Opencode( - base_url="http://localhost:5000/custom/path/", - _strict_response_validation=True, - http_client=httpx.Client(), - ), - ], - ids=["standard", "custom http client"], - ) - def test_absolute_request_url(self, client: Opencode) -> None: - request = client._build_request( - FinalRequestOptions( - method="post", - url="https://myapi.com/foo", - json_data={"foo": "bar"}, - ), - ) - assert request.url == "https://myapi.com/foo" - - def test_copied_client_does_not_close_http(self) -> None: - client = Opencode(base_url=base_url, _strict_response_validation=True) - assert not client.is_closed() - - copied = client.copy() - assert copied is not client - - del copied - - assert not client.is_closed() - - def test_client_context_manager(self) -> None: - client = Opencode(base_url=base_url, _strict_response_validation=True) - with client as c2: - assert c2 is client - assert not c2.is_closed() - assert not client.is_closed() - assert client.is_closed() - - @pytest.mark.respx(base_url=base_url) - def test_client_response_validation_error(self, respx_mock: MockRouter) -> None: - class Model(BaseModel): - foo: str - - respx_mock.get("/foo").mock(return_value=httpx.Response(200, json={"foo": {"invalid": True}})) - - with pytest.raises(APIResponseValidationError) as exc: - self.client.get("/foo", cast_to=Model) - - assert isinstance(exc.value.__cause__, ValidationError) - - def test_client_max_retries_validation(self) -> None: - with pytest.raises(TypeError, match=r"max_retries cannot be None"): - Opencode(base_url=base_url, _strict_response_validation=True, max_retries=cast(Any, None)) - - @pytest.mark.respx(base_url=base_url) - def test_default_stream_cls(self, respx_mock: MockRouter) -> None: - class Model(BaseModel): - name: str - - respx_mock.post("/foo").mock(return_value=httpx.Response(200, json={"foo": "bar"})) - - stream = self.client.post("/foo", cast_to=Model, stream=True, stream_cls=Stream[Model]) - assert isinstance(stream, Stream) - stream.response.close() - - @pytest.mark.respx(base_url=base_url) - def test_received_text_for_expected_json(self, respx_mock: MockRouter) -> None: - class Model(BaseModel): - name: str - - respx_mock.get("/foo").mock(return_value=httpx.Response(200, text="my-custom-format")) - - strict_client = Opencode(base_url=base_url, _strict_response_validation=True) - - with pytest.raises(APIResponseValidationError): - strict_client.get("/foo", cast_to=Model) - - client = Opencode(base_url=base_url, _strict_response_validation=False) - - response = client.get("/foo", cast_to=Model) - assert isinstance(response, str) # type: ignore[unreachable] - - @pytest.mark.parametrize( - "remaining_retries,retry_after,timeout", - [ - [3, "20", 20], - [3, "0", 0.5], - [3, "-10", 0.5], - [3, "60", 60], - [3, "61", 0.5], - [3, "Fri, 29 Sep 2023 16:26:57 GMT", 20], - [3, "Fri, 29 Sep 2023 16:26:37 GMT", 0.5], - [3, "Fri, 29 Sep 2023 16:26:27 GMT", 0.5], - [3, "Fri, 29 Sep 2023 16:27:37 GMT", 60], - [3, "Fri, 29 Sep 2023 16:27:38 GMT", 0.5], - [3, "99999999999999999999999999999999999", 0.5], - [3, "Zun, 29 Sep 2023 16:26:27 GMT", 0.5], - [3, "", 0.5], - [2, "", 0.5 * 2.0], - [1, "", 0.5 * 4.0], - [-1100, "", 8], # test large number potentially overflowing - ], - ) - @mock.patch("time.time", mock.MagicMock(return_value=1696004797)) - def test_parse_retry_after_header(self, remaining_retries: int, retry_after: str, timeout: float) -> None: - client = Opencode(base_url=base_url, _strict_response_validation=True) - - headers = httpx.Headers({"retry-after": retry_after}) - options = FinalRequestOptions(method="get", url="/foo", max_retries=3) - calculated = client._calculate_retry_timeout(remaining_retries, options, headers) - assert calculated == pytest.approx(timeout, 0.5 * 0.875) # pyright: ignore[reportUnknownMemberType] - - @mock.patch("opencode_ai._base_client.BaseClient._calculate_retry_timeout", _low_retry_timeout) - @pytest.mark.respx(base_url=base_url) - def test_retrying_timeout_errors_doesnt_leak(self, respx_mock: MockRouter, client: Opencode) -> None: - respx_mock.get("/session").mock(side_effect=httpx.TimeoutException("Test timeout error")) - - with pytest.raises(APITimeoutError): - client.session.with_streaming_response.list().__enter__() - - assert _get_open_connections(self.client) == 0 - - @mock.patch("opencode_ai._base_client.BaseClient._calculate_retry_timeout", _low_retry_timeout) - @pytest.mark.respx(base_url=base_url) - def test_retrying_status_errors_doesnt_leak(self, respx_mock: MockRouter, client: Opencode) -> None: - respx_mock.get("/session").mock(return_value=httpx.Response(500)) - - with pytest.raises(APIStatusError): - client.session.with_streaming_response.list().__enter__() - assert _get_open_connections(self.client) == 0 - - @pytest.mark.parametrize("failures_before_success", [0, 2, 4]) - @mock.patch("opencode_ai._base_client.BaseClient._calculate_retry_timeout", _low_retry_timeout) - @pytest.mark.respx(base_url=base_url) - @pytest.mark.parametrize("failure_mode", ["status", "exception"]) - def test_retries_taken( - self, - client: Opencode, - failures_before_success: int, - failure_mode: Literal["status", "exception"], - respx_mock: MockRouter, - ) -> None: - client = client.with_options(max_retries=4) - - nb_retries = 0 - - def retry_handler(_request: httpx.Request) -> httpx.Response: - nonlocal nb_retries - if nb_retries < failures_before_success: - nb_retries += 1 - if failure_mode == "exception": - raise RuntimeError("oops") - return httpx.Response(500) - return httpx.Response(200) - - respx_mock.get("/session").mock(side_effect=retry_handler) - - response = client.session.with_raw_response.list() - - assert response.retries_taken == failures_before_success - assert int(response.http_request.headers.get("x-stainless-retry-count")) == failures_before_success - - @pytest.mark.parametrize("failures_before_success", [0, 2, 4]) - @mock.patch("opencode_ai._base_client.BaseClient._calculate_retry_timeout", _low_retry_timeout) - @pytest.mark.respx(base_url=base_url) - def test_omit_retry_count_header( - self, client: Opencode, failures_before_success: int, respx_mock: MockRouter - ) -> None: - client = client.with_options(max_retries=4) - - nb_retries = 0 - - def retry_handler(_request: httpx.Request) -> httpx.Response: - nonlocal nb_retries - if nb_retries < failures_before_success: - nb_retries += 1 - return httpx.Response(500) - return httpx.Response(200) - - respx_mock.get("/session").mock(side_effect=retry_handler) - - response = client.session.with_raw_response.list(extra_headers={"x-stainless-retry-count": Omit()}) - - assert len(response.http_request.headers.get_list("x-stainless-retry-count")) == 0 - - @pytest.mark.parametrize("failures_before_success", [0, 2, 4]) - @mock.patch("opencode_ai._base_client.BaseClient._calculate_retry_timeout", _low_retry_timeout) - @pytest.mark.respx(base_url=base_url) - def test_overwrite_retry_count_header( - self, client: Opencode, failures_before_success: int, respx_mock: MockRouter - ) -> None: - client = client.with_options(max_retries=4) - - nb_retries = 0 - - def retry_handler(_request: httpx.Request) -> httpx.Response: - nonlocal nb_retries - if nb_retries < failures_before_success: - nb_retries += 1 - return httpx.Response(500) - return httpx.Response(200) - - respx_mock.get("/session").mock(side_effect=retry_handler) - - response = client.session.with_raw_response.list(extra_headers={"x-stainless-retry-count": "42"}) - - assert response.http_request.headers.get("x-stainless-retry-count") == "42" - - def test_proxy_environment_variables(self, monkeypatch: pytest.MonkeyPatch) -> None: - # Test that the proxy environment variables are set correctly - monkeypatch.setenv("HTTPS_PROXY", "https://example.org") - - client = DefaultHttpxClient() - - mounts = tuple(client._mounts.items()) - assert len(mounts) == 1 - assert mounts[0][0].pattern == "https://" - - @pytest.mark.filterwarnings("ignore:.*deprecated.*:DeprecationWarning") - def test_default_client_creation(self) -> None: - # Ensure that the client can be initialized without any exceptions - DefaultHttpxClient( - verify=True, - cert=None, - trust_env=True, - http1=True, - http2=False, - limits=httpx.Limits(max_connections=100, max_keepalive_connections=20), - ) - - @pytest.mark.respx(base_url=base_url) - def test_follow_redirects(self, respx_mock: MockRouter) -> None: - # Test that the default follow_redirects=True allows following redirects - respx_mock.post("/redirect").mock( - return_value=httpx.Response(302, headers={"Location": f"{base_url}/redirected"}) - ) - respx_mock.get("/redirected").mock(return_value=httpx.Response(200, json={"status": "ok"})) - - response = self.client.post("/redirect", body={"key": "value"}, cast_to=httpx.Response) - assert response.status_code == 200 - assert response.json() == {"status": "ok"} - - @pytest.mark.respx(base_url=base_url) - def test_follow_redirects_disabled(self, respx_mock: MockRouter) -> None: - # Test that follow_redirects=False prevents following redirects - respx_mock.post("/redirect").mock( - return_value=httpx.Response(302, headers={"Location": f"{base_url}/redirected"}) - ) - - with pytest.raises(APIStatusError) as exc_info: - self.client.post( - "/redirect", body={"key": "value"}, options={"follow_redirects": False}, cast_to=httpx.Response - ) - - assert exc_info.value.response.status_code == 302 - assert exc_info.value.response.headers["Location"] == f"{base_url}/redirected" - - -class TestAsyncOpencode: - client = AsyncOpencode(base_url=base_url, _strict_response_validation=True) - - @pytest.mark.respx(base_url=base_url) - @pytest.mark.asyncio - async def test_raw_response(self, respx_mock: MockRouter) -> None: - respx_mock.post("/foo").mock(return_value=httpx.Response(200, json={"foo": "bar"})) - - response = await self.client.post("/foo", cast_to=httpx.Response) - assert response.status_code == 200 - assert isinstance(response, httpx.Response) - assert response.json() == {"foo": "bar"} - - @pytest.mark.respx(base_url=base_url) - @pytest.mark.asyncio - async def test_raw_response_for_binary(self, respx_mock: MockRouter) -> None: - respx_mock.post("/foo").mock( - return_value=httpx.Response(200, headers={"Content-Type": "application/binary"}, content='{"foo": "bar"}') - ) - - response = await self.client.post("/foo", cast_to=httpx.Response) - assert response.status_code == 200 - assert isinstance(response, httpx.Response) - assert response.json() == {"foo": "bar"} - - def test_copy(self) -> None: - copied = self.client.copy() - assert id(copied) != id(self.client) - - def test_copy_default_options(self) -> None: - # options that have a default are overridden correctly - copied = self.client.copy(max_retries=7) - assert copied.max_retries == 7 - assert self.client.max_retries == 2 - - copied2 = copied.copy(max_retries=6) - assert copied2.max_retries == 6 - assert copied.max_retries == 7 - - # timeout - assert isinstance(self.client.timeout, httpx.Timeout) - copied = self.client.copy(timeout=None) - assert copied.timeout is None - assert isinstance(self.client.timeout, httpx.Timeout) - - def test_copy_default_headers(self) -> None: - client = AsyncOpencode(base_url=base_url, _strict_response_validation=True, default_headers={"X-Foo": "bar"}) - assert client.default_headers["X-Foo"] == "bar" - - # does not override the already given value when not specified - copied = client.copy() - assert copied.default_headers["X-Foo"] == "bar" - - # merges already given headers - copied = client.copy(default_headers={"X-Bar": "stainless"}) - assert copied.default_headers["X-Foo"] == "bar" - assert copied.default_headers["X-Bar"] == "stainless" - - # uses new values for any already given headers - copied = client.copy(default_headers={"X-Foo": "stainless"}) - assert copied.default_headers["X-Foo"] == "stainless" - - # set_default_headers - - # completely overrides already set values - copied = client.copy(set_default_headers={}) - assert copied.default_headers.get("X-Foo") is None - - copied = client.copy(set_default_headers={"X-Bar": "Robert"}) - assert copied.default_headers["X-Bar"] == "Robert" - - with pytest.raises( - ValueError, - match="`default_headers` and `set_default_headers` arguments are mutually exclusive", - ): - client.copy(set_default_headers={}, default_headers={"X-Foo": "Bar"}) - - def test_copy_default_query(self) -> None: - client = AsyncOpencode(base_url=base_url, _strict_response_validation=True, default_query={"foo": "bar"}) - assert _get_params(client)["foo"] == "bar" - - # does not override the already given value when not specified - copied = client.copy() - assert _get_params(copied)["foo"] == "bar" - - # merges already given params - copied = client.copy(default_query={"bar": "stainless"}) - params = _get_params(copied) - assert params["foo"] == "bar" - assert params["bar"] == "stainless" - - # uses new values for any already given headers - copied = client.copy(default_query={"foo": "stainless"}) - assert _get_params(copied)["foo"] == "stainless" - - # set_default_query - - # completely overrides already set values - copied = client.copy(set_default_query={}) - assert _get_params(copied) == {} - - copied = client.copy(set_default_query={"bar": "Robert"}) - assert _get_params(copied)["bar"] == "Robert" - - with pytest.raises( - ValueError, - # TODO: update - match="`default_query` and `set_default_query` arguments are mutually exclusive", - ): - client.copy(set_default_query={}, default_query={"foo": "Bar"}) - - def test_copy_signature(self) -> None: - # ensure the same parameters that can be passed to the client are defined in the `.copy()` method - init_signature = inspect.signature( - # mypy doesn't like that we access the `__init__` property. - self.client.__init__, # type: ignore[misc] - ) - copy_signature = inspect.signature(self.client.copy) - exclude_params = {"transport", "proxies", "_strict_response_validation"} - - for name in init_signature.parameters.keys(): - if name in exclude_params: - continue - - copy_param = copy_signature.parameters.get(name) - assert copy_param is not None, f"copy() signature is missing the {name} param" - - @pytest.mark.skipif(sys.version_info >= (3, 10), reason="fails because of a memory leak that started from 3.12") - def test_copy_build_request(self) -> None: - options = FinalRequestOptions(method="get", url="/foo") - - def build_request(options: FinalRequestOptions) -> None: - client = self.client.copy() - client._build_request(options) - - # ensure that the machinery is warmed up before tracing starts. - build_request(options) - gc.collect() - - tracemalloc.start(1000) - - snapshot_before = tracemalloc.take_snapshot() - - ITERATIONS = 10 - for _ in range(ITERATIONS): - build_request(options) - - gc.collect() - snapshot_after = tracemalloc.take_snapshot() - - tracemalloc.stop() - - def add_leak(leaks: list[tracemalloc.StatisticDiff], diff: tracemalloc.StatisticDiff) -> None: - if diff.count == 0: - # Avoid false positives by considering only leaks (i.e. allocations that persist). - return - - if diff.count % ITERATIONS != 0: - # Avoid false positives by considering only leaks that appear per iteration. - return - - for frame in diff.traceback: - if any( - frame.filename.endswith(fragment) - for fragment in [ - # to_raw_response_wrapper leaks through the @functools.wraps() decorator. - # - # removing the decorator fixes the leak for reasons we don't understand. - "opencode_ai/_legacy_response.py", - "opencode_ai/_response.py", - # pydantic.BaseModel.model_dump || pydantic.BaseModel.dict leak memory for some reason. - "opencode_ai/_compat.py", - # Standard library leaks we don't care about. - "/logging/__init__.py", - ] - ): - return - - leaks.append(diff) - - leaks: list[tracemalloc.StatisticDiff] = [] - for diff in snapshot_after.compare_to(snapshot_before, "traceback"): - add_leak(leaks, diff) - if leaks: - for leak in leaks: - print("MEMORY LEAK:", leak) - for frame in leak.traceback: - print(frame) - raise AssertionError() - - async def test_request_timeout(self) -> None: - request = self.client._build_request(FinalRequestOptions(method="get", url="/foo")) - timeout = httpx.Timeout(**request.extensions["timeout"]) # type: ignore - assert timeout == DEFAULT_TIMEOUT - - request = self.client._build_request( - FinalRequestOptions(method="get", url="/foo", timeout=httpx.Timeout(100.0)) - ) - timeout = httpx.Timeout(**request.extensions["timeout"]) # type: ignore - assert timeout == httpx.Timeout(100.0) - - async def test_client_timeout_option(self) -> None: - client = AsyncOpencode(base_url=base_url, _strict_response_validation=True, timeout=httpx.Timeout(0)) - - request = client._build_request(FinalRequestOptions(method="get", url="/foo")) - timeout = httpx.Timeout(**request.extensions["timeout"]) # type: ignore - assert timeout == httpx.Timeout(0) - - async def test_http_client_timeout_option(self) -> None: - # custom timeout given to the httpx client should be used - async with httpx.AsyncClient(timeout=None) as http_client: - client = AsyncOpencode(base_url=base_url, _strict_response_validation=True, http_client=http_client) - - request = client._build_request(FinalRequestOptions(method="get", url="/foo")) - timeout = httpx.Timeout(**request.extensions["timeout"]) # type: ignore - assert timeout == httpx.Timeout(None) - - # no timeout given to the httpx client should not use the httpx default - async with httpx.AsyncClient() as http_client: - client = AsyncOpencode(base_url=base_url, _strict_response_validation=True, http_client=http_client) - - request = client._build_request(FinalRequestOptions(method="get", url="/foo")) - timeout = httpx.Timeout(**request.extensions["timeout"]) # type: ignore - assert timeout == DEFAULT_TIMEOUT - - # explicitly passing the default timeout currently results in it being ignored - async with httpx.AsyncClient(timeout=HTTPX_DEFAULT_TIMEOUT) as http_client: - client = AsyncOpencode(base_url=base_url, _strict_response_validation=True, http_client=http_client) - - request = client._build_request(FinalRequestOptions(method="get", url="/foo")) - timeout = httpx.Timeout(**request.extensions["timeout"]) # type: ignore - assert timeout == DEFAULT_TIMEOUT # our default - - def test_invalid_http_client(self) -> None: - with pytest.raises(TypeError, match="Invalid `http_client` arg"): - with httpx.Client() as http_client: - AsyncOpencode(base_url=base_url, _strict_response_validation=True, http_client=cast(Any, http_client)) - - def test_default_headers_option(self) -> None: - client = AsyncOpencode(base_url=base_url, _strict_response_validation=True, default_headers={"X-Foo": "bar"}) - request = client._build_request(FinalRequestOptions(method="get", url="/foo")) - assert request.headers.get("x-foo") == "bar" - assert request.headers.get("x-stainless-lang") == "python" - - client2 = AsyncOpencode( - base_url=base_url, - _strict_response_validation=True, - default_headers={ - "X-Foo": "stainless", - "X-Stainless-Lang": "my-overriding-header", - }, - ) - request = client2._build_request(FinalRequestOptions(method="get", url="/foo")) - assert request.headers.get("x-foo") == "stainless" - assert request.headers.get("x-stainless-lang") == "my-overriding-header" - - def test_default_query_option(self) -> None: - client = AsyncOpencode( - base_url=base_url, _strict_response_validation=True, default_query={"query_param": "bar"} - ) - request = client._build_request(FinalRequestOptions(method="get", url="/foo")) - url = httpx.URL(request.url) - assert dict(url.params) == {"query_param": "bar"} - - request = client._build_request( - FinalRequestOptions( - method="get", - url="/foo", - params={"foo": "baz", "query_param": "overridden"}, - ) - ) - url = httpx.URL(request.url) - assert dict(url.params) == {"foo": "baz", "query_param": "overridden"} - - def test_request_extra_json(self) -> None: - request = self.client._build_request( - FinalRequestOptions( - method="post", - url="/foo", - json_data={"foo": "bar"}, - extra_json={"baz": False}, - ), - ) - data = json.loads(request.content.decode("utf-8")) - assert data == {"foo": "bar", "baz": False} - - request = self.client._build_request( - FinalRequestOptions( - method="post", - url="/foo", - extra_json={"baz": False}, - ), - ) - data = json.loads(request.content.decode("utf-8")) - assert data == {"baz": False} - - # `extra_json` takes priority over `json_data` when keys clash - request = self.client._build_request( - FinalRequestOptions( - method="post", - url="/foo", - json_data={"foo": "bar", "baz": True}, - extra_json={"baz": None}, - ), - ) - data = json.loads(request.content.decode("utf-8")) - assert data == {"foo": "bar", "baz": None} - - def test_request_extra_headers(self) -> None: - request = self.client._build_request( - FinalRequestOptions( - method="post", - url="/foo", - **make_request_options(extra_headers={"X-Foo": "Foo"}), - ), - ) - assert request.headers.get("X-Foo") == "Foo" - - # `extra_headers` takes priority over `default_headers` when keys clash - request = self.client.with_options(default_headers={"X-Bar": "true"})._build_request( - FinalRequestOptions( - method="post", - url="/foo", - **make_request_options( - extra_headers={"X-Bar": "false"}, - ), - ), - ) - assert request.headers.get("X-Bar") == "false" - - def test_request_extra_query(self) -> None: - request = self.client._build_request( - FinalRequestOptions( - method="post", - url="/foo", - **make_request_options( - extra_query={"my_query_param": "Foo"}, - ), - ), - ) - params = dict(request.url.params) - assert params == {"my_query_param": "Foo"} - - # if both `query` and `extra_query` are given, they are merged - request = self.client._build_request( - FinalRequestOptions( - method="post", - url="/foo", - **make_request_options( - query={"bar": "1"}, - extra_query={"foo": "2"}, - ), - ), - ) - params = dict(request.url.params) - assert params == {"bar": "1", "foo": "2"} - - # `extra_query` takes priority over `query` when keys clash - request = self.client._build_request( - FinalRequestOptions( - method="post", - url="/foo", - **make_request_options( - query={"foo": "1"}, - extra_query={"foo": "2"}, - ), - ), - ) - params = dict(request.url.params) - assert params == {"foo": "2"} - - def test_multipart_repeating_array(self, async_client: AsyncOpencode) -> None: - request = async_client._build_request( - FinalRequestOptions.construct( - method="post", - url="/foo", - headers={"Content-Type": "multipart/form-data; boundary=6b7ba517decee4a450543ea6ae821c82"}, - json_data={"array": ["foo", "bar"]}, - files=[("foo.txt", b"hello world")], - ) - ) - - assert request.read().split(b"\r\n") == [ - b"--6b7ba517decee4a450543ea6ae821c82", - b'Content-Disposition: form-data; name="array[]"', - b"", - b"foo", - b"--6b7ba517decee4a450543ea6ae821c82", - b'Content-Disposition: form-data; name="array[]"', - b"", - b"bar", - b"--6b7ba517decee4a450543ea6ae821c82", - b'Content-Disposition: form-data; name="foo.txt"; filename="upload"', - b"Content-Type: application/octet-stream", - b"", - b"hello world", - b"--6b7ba517decee4a450543ea6ae821c82--", - b"", - ] - - @pytest.mark.respx(base_url=base_url) - async def test_basic_union_response(self, respx_mock: MockRouter) -> None: - class Model1(BaseModel): - name: str - - class Model2(BaseModel): - foo: str - - respx_mock.get("/foo").mock(return_value=httpx.Response(200, json={"foo": "bar"})) - - response = await self.client.get("/foo", cast_to=cast(Any, Union[Model1, Model2])) - assert isinstance(response, Model2) - assert response.foo == "bar" - - @pytest.mark.respx(base_url=base_url) - async def test_union_response_different_types(self, respx_mock: MockRouter) -> None: - """Union of objects with the same field name using a different type""" - - class Model1(BaseModel): - foo: int - - class Model2(BaseModel): - foo: str - - respx_mock.get("/foo").mock(return_value=httpx.Response(200, json={"foo": "bar"})) - - response = await self.client.get("/foo", cast_to=cast(Any, Union[Model1, Model2])) - assert isinstance(response, Model2) - assert response.foo == "bar" - - respx_mock.get("/foo").mock(return_value=httpx.Response(200, json={"foo": 1})) - - response = await self.client.get("/foo", cast_to=cast(Any, Union[Model1, Model2])) - assert isinstance(response, Model1) - assert response.foo == 1 - - @pytest.mark.respx(base_url=base_url) - async def test_non_application_json_content_type_for_json_data(self, respx_mock: MockRouter) -> None: - """ - Response that sets Content-Type to something other than application/json but returns json data - """ - - class Model(BaseModel): - foo: int - - respx_mock.get("/foo").mock( - return_value=httpx.Response( - 200, - content=json.dumps({"foo": 2}), - headers={"Content-Type": "application/text"}, - ) - ) - - response = await self.client.get("/foo", cast_to=Model) - assert isinstance(response, Model) - assert response.foo == 2 - - def test_base_url_setter(self) -> None: - client = AsyncOpencode(base_url="https://example.com/from_init", _strict_response_validation=True) - assert client.base_url == "https://example.com/from_init/" - - client.base_url = "https://example.com/from_setter" # type: ignore[assignment] - - assert client.base_url == "https://example.com/from_setter/" - - def test_base_url_env(self) -> None: - with update_env(OPENCODE_BASE_URL="http://localhost:5000/from/env"): - client = AsyncOpencode(_strict_response_validation=True) - assert client.base_url == "http://localhost:5000/from/env/" - - @pytest.mark.parametrize( - "client", - [ - AsyncOpencode(base_url="http://localhost:5000/custom/path/", _strict_response_validation=True), - AsyncOpencode( - base_url="http://localhost:5000/custom/path/", - _strict_response_validation=True, - http_client=httpx.AsyncClient(), - ), - ], - ids=["standard", "custom http client"], - ) - def test_base_url_trailing_slash(self, client: AsyncOpencode) -> None: - request = client._build_request( - FinalRequestOptions( - method="post", - url="/foo", - json_data={"foo": "bar"}, - ), - ) - assert request.url == "http://localhost:5000/custom/path/foo" - - @pytest.mark.parametrize( - "client", - [ - AsyncOpencode(base_url="http://localhost:5000/custom/path/", _strict_response_validation=True), - AsyncOpencode( - base_url="http://localhost:5000/custom/path/", - _strict_response_validation=True, - http_client=httpx.AsyncClient(), - ), - ], - ids=["standard", "custom http client"], - ) - def test_base_url_no_trailing_slash(self, client: AsyncOpencode) -> None: - request = client._build_request( - FinalRequestOptions( - method="post", - url="/foo", - json_data={"foo": "bar"}, - ), - ) - assert request.url == "http://localhost:5000/custom/path/foo" - - @pytest.mark.parametrize( - "client", - [ - AsyncOpencode(base_url="http://localhost:5000/custom/path/", _strict_response_validation=True), - AsyncOpencode( - base_url="http://localhost:5000/custom/path/", - _strict_response_validation=True, - http_client=httpx.AsyncClient(), - ), - ], - ids=["standard", "custom http client"], - ) - def test_absolute_request_url(self, client: AsyncOpencode) -> None: - request = client._build_request( - FinalRequestOptions( - method="post", - url="https://myapi.com/foo", - json_data={"foo": "bar"}, - ), - ) - assert request.url == "https://myapi.com/foo" - - async def test_copied_client_does_not_close_http(self) -> None: - client = AsyncOpencode(base_url=base_url, _strict_response_validation=True) - assert not client.is_closed() - - copied = client.copy() - assert copied is not client - - del copied - - await asyncio.sleep(0.2) - assert not client.is_closed() - - async def test_client_context_manager(self) -> None: - client = AsyncOpencode(base_url=base_url, _strict_response_validation=True) - async with client as c2: - assert c2 is client - assert not c2.is_closed() - assert not client.is_closed() - assert client.is_closed() - - @pytest.mark.respx(base_url=base_url) - @pytest.mark.asyncio - async def test_client_response_validation_error(self, respx_mock: MockRouter) -> None: - class Model(BaseModel): - foo: str - - respx_mock.get("/foo").mock(return_value=httpx.Response(200, json={"foo": {"invalid": True}})) - - with pytest.raises(APIResponseValidationError) as exc: - await self.client.get("/foo", cast_to=Model) - - assert isinstance(exc.value.__cause__, ValidationError) - - async def test_client_max_retries_validation(self) -> None: - with pytest.raises(TypeError, match=r"max_retries cannot be None"): - AsyncOpencode(base_url=base_url, _strict_response_validation=True, max_retries=cast(Any, None)) - - @pytest.mark.respx(base_url=base_url) - @pytest.mark.asyncio - async def test_default_stream_cls(self, respx_mock: MockRouter) -> None: - class Model(BaseModel): - name: str - - respx_mock.post("/foo").mock(return_value=httpx.Response(200, json={"foo": "bar"})) - - stream = await self.client.post("/foo", cast_to=Model, stream=True, stream_cls=AsyncStream[Model]) - assert isinstance(stream, AsyncStream) - await stream.response.aclose() - - @pytest.mark.respx(base_url=base_url) - @pytest.mark.asyncio - async def test_received_text_for_expected_json(self, respx_mock: MockRouter) -> None: - class Model(BaseModel): - name: str - - respx_mock.get("/foo").mock(return_value=httpx.Response(200, text="my-custom-format")) - - strict_client = AsyncOpencode(base_url=base_url, _strict_response_validation=True) - - with pytest.raises(APIResponseValidationError): - await strict_client.get("/foo", cast_to=Model) - - client = AsyncOpencode(base_url=base_url, _strict_response_validation=False) - - response = await client.get("/foo", cast_to=Model) - assert isinstance(response, str) # type: ignore[unreachable] - - @pytest.mark.parametrize( - "remaining_retries,retry_after,timeout", - [ - [3, "20", 20], - [3, "0", 0.5], - [3, "-10", 0.5], - [3, "60", 60], - [3, "61", 0.5], - [3, "Fri, 29 Sep 2023 16:26:57 GMT", 20], - [3, "Fri, 29 Sep 2023 16:26:37 GMT", 0.5], - [3, "Fri, 29 Sep 2023 16:26:27 GMT", 0.5], - [3, "Fri, 29 Sep 2023 16:27:37 GMT", 60], - [3, "Fri, 29 Sep 2023 16:27:38 GMT", 0.5], - [3, "99999999999999999999999999999999999", 0.5], - [3, "Zun, 29 Sep 2023 16:26:27 GMT", 0.5], - [3, "", 0.5], - [2, "", 0.5 * 2.0], - [1, "", 0.5 * 4.0], - [-1100, "", 8], # test large number potentially overflowing - ], - ) - @mock.patch("time.time", mock.MagicMock(return_value=1696004797)) - @pytest.mark.asyncio - async def test_parse_retry_after_header(self, remaining_retries: int, retry_after: str, timeout: float) -> None: - client = AsyncOpencode(base_url=base_url, _strict_response_validation=True) - - headers = httpx.Headers({"retry-after": retry_after}) - options = FinalRequestOptions(method="get", url="/foo", max_retries=3) - calculated = client._calculate_retry_timeout(remaining_retries, options, headers) - assert calculated == pytest.approx(timeout, 0.5 * 0.875) # pyright: ignore[reportUnknownMemberType] - - @mock.patch("opencode_ai._base_client.BaseClient._calculate_retry_timeout", _low_retry_timeout) - @pytest.mark.respx(base_url=base_url) - async def test_retrying_timeout_errors_doesnt_leak( - self, respx_mock: MockRouter, async_client: AsyncOpencode - ) -> None: - respx_mock.get("/session").mock(side_effect=httpx.TimeoutException("Test timeout error")) - - with pytest.raises(APITimeoutError): - await async_client.session.with_streaming_response.list().__aenter__() - - assert _get_open_connections(self.client) == 0 - - @mock.patch("opencode_ai._base_client.BaseClient._calculate_retry_timeout", _low_retry_timeout) - @pytest.mark.respx(base_url=base_url) - async def test_retrying_status_errors_doesnt_leak( - self, respx_mock: MockRouter, async_client: AsyncOpencode - ) -> None: - respx_mock.get("/session").mock(return_value=httpx.Response(500)) - - with pytest.raises(APIStatusError): - await async_client.session.with_streaming_response.list().__aenter__() - assert _get_open_connections(self.client) == 0 - - @pytest.mark.parametrize("failures_before_success", [0, 2, 4]) - @mock.patch("opencode_ai._base_client.BaseClient._calculate_retry_timeout", _low_retry_timeout) - @pytest.mark.respx(base_url=base_url) - @pytest.mark.asyncio - @pytest.mark.parametrize("failure_mode", ["status", "exception"]) - async def test_retries_taken( - self, - async_client: AsyncOpencode, - failures_before_success: int, - failure_mode: Literal["status", "exception"], - respx_mock: MockRouter, - ) -> None: - client = async_client.with_options(max_retries=4) - - nb_retries = 0 - - def retry_handler(_request: httpx.Request) -> httpx.Response: - nonlocal nb_retries - if nb_retries < failures_before_success: - nb_retries += 1 - if failure_mode == "exception": - raise RuntimeError("oops") - return httpx.Response(500) - return httpx.Response(200) - - respx_mock.get("/session").mock(side_effect=retry_handler) - - response = await client.session.with_raw_response.list() - - assert response.retries_taken == failures_before_success - assert int(response.http_request.headers.get("x-stainless-retry-count")) == failures_before_success - - @pytest.mark.parametrize("failures_before_success", [0, 2, 4]) - @mock.patch("opencode_ai._base_client.BaseClient._calculate_retry_timeout", _low_retry_timeout) - @pytest.mark.respx(base_url=base_url) - @pytest.mark.asyncio - async def test_omit_retry_count_header( - self, async_client: AsyncOpencode, failures_before_success: int, respx_mock: MockRouter - ) -> None: - client = async_client.with_options(max_retries=4) - - nb_retries = 0 - - def retry_handler(_request: httpx.Request) -> httpx.Response: - nonlocal nb_retries - if nb_retries < failures_before_success: - nb_retries += 1 - return httpx.Response(500) - return httpx.Response(200) - - respx_mock.get("/session").mock(side_effect=retry_handler) - - response = await client.session.with_raw_response.list(extra_headers={"x-stainless-retry-count": Omit()}) - - assert len(response.http_request.headers.get_list("x-stainless-retry-count")) == 0 - - @pytest.mark.parametrize("failures_before_success", [0, 2, 4]) - @mock.patch("opencode_ai._base_client.BaseClient._calculate_retry_timeout", _low_retry_timeout) - @pytest.mark.respx(base_url=base_url) - @pytest.mark.asyncio - async def test_overwrite_retry_count_header( - self, async_client: AsyncOpencode, failures_before_success: int, respx_mock: MockRouter - ) -> None: - client = async_client.with_options(max_retries=4) - - nb_retries = 0 - - def retry_handler(_request: httpx.Request) -> httpx.Response: - nonlocal nb_retries - if nb_retries < failures_before_success: - nb_retries += 1 - return httpx.Response(500) - return httpx.Response(200) - - respx_mock.get("/session").mock(side_effect=retry_handler) - - response = await client.session.with_raw_response.list(extra_headers={"x-stainless-retry-count": "42"}) - - assert response.http_request.headers.get("x-stainless-retry-count") == "42" - - def test_get_platform(self) -> None: - # A previous implementation of asyncify could leave threads unterminated when - # used with nest_asyncio. - # - # Since nest_asyncio.apply() is global and cannot be un-applied, this - # test is run in a separate process to avoid affecting other tests. - test_code = dedent(""" - import asyncio - import nest_asyncio - import threading - - from opencode_ai._utils import asyncify - from opencode_ai._base_client import get_platform - - async def test_main() -> None: - result = await asyncify(get_platform)() - print(result) - for thread in threading.enumerate(): - print(thread.name) - - nest_asyncio.apply() - asyncio.run(test_main()) - """) - with subprocess.Popen( - [sys.executable, "-c", test_code], - text=True, - ) as process: - timeout = 10 # seconds - - start_time = time.monotonic() - while True: - return_code = process.poll() - if return_code is not None: - if return_code != 0: - raise AssertionError("calling get_platform using asyncify resulted in a non-zero exit code") - - # success - break - - if time.monotonic() - start_time > timeout: - process.kill() - raise AssertionError("calling get_platform using asyncify resulted in a hung process") - - time.sleep(0.1) - - async def test_proxy_environment_variables(self, monkeypatch: pytest.MonkeyPatch) -> None: - # Test that the proxy environment variables are set correctly - monkeypatch.setenv("HTTPS_PROXY", "https://example.org") - - client = DefaultAsyncHttpxClient() - - mounts = tuple(client._mounts.items()) - assert len(mounts) == 1 - assert mounts[0][0].pattern == "https://" - - @pytest.mark.filterwarnings("ignore:.*deprecated.*:DeprecationWarning") - async def test_default_client_creation(self) -> None: - # Ensure that the client can be initialized without any exceptions - DefaultAsyncHttpxClient( - verify=True, - cert=None, - trust_env=True, - http1=True, - http2=False, - limits=httpx.Limits(max_connections=100, max_keepalive_connections=20), - ) - - @pytest.mark.respx(base_url=base_url) - async def test_follow_redirects(self, respx_mock: MockRouter) -> None: - # Test that the default follow_redirects=True allows following redirects - respx_mock.post("/redirect").mock( - return_value=httpx.Response(302, headers={"Location": f"{base_url}/redirected"}) - ) - respx_mock.get("/redirected").mock(return_value=httpx.Response(200, json={"status": "ok"})) - - response = await self.client.post("/redirect", body={"key": "value"}, cast_to=httpx.Response) - assert response.status_code == 200 - assert response.json() == {"status": "ok"} - - @pytest.mark.respx(base_url=base_url) - async def test_follow_redirects_disabled(self, respx_mock: MockRouter) -> None: - # Test that follow_redirects=False prevents following redirects - respx_mock.post("/redirect").mock( - return_value=httpx.Response(302, headers={"Location": f"{base_url}/redirected"}) - ) - - with pytest.raises(APIStatusError) as exc_info: - await self.client.post( - "/redirect", body={"key": "value"}, options={"follow_redirects": False}, cast_to=httpx.Response - ) - - assert exc_info.value.response.status_code == 302 - assert exc_info.value.response.headers["Location"] == f"{base_url}/redirected" diff --git a/tests/test_deepcopy.py b/tests/test_deepcopy.py deleted file mode 100644 index f43fa4f..0000000 --- a/tests/test_deepcopy.py +++ /dev/null @@ -1,58 +0,0 @@ -from opencode_ai._utils import deepcopy_minimal - - -def assert_different_identities(obj1: object, obj2: object) -> None: - assert obj1 == obj2 - assert id(obj1) != id(obj2) - - -def test_simple_dict() -> None: - obj1 = {"foo": "bar"} - obj2 = deepcopy_minimal(obj1) - assert_different_identities(obj1, obj2) - - -def test_nested_dict() -> None: - obj1 = {"foo": {"bar": True}} - obj2 = deepcopy_minimal(obj1) - assert_different_identities(obj1, obj2) - assert_different_identities(obj1["foo"], obj2["foo"]) - - -def test_complex_nested_dict() -> None: - obj1 = {"foo": {"bar": [{"hello": "world"}]}} - obj2 = deepcopy_minimal(obj1) - assert_different_identities(obj1, obj2) - assert_different_identities(obj1["foo"], obj2["foo"]) - assert_different_identities(obj1["foo"]["bar"], obj2["foo"]["bar"]) - assert_different_identities(obj1["foo"]["bar"][0], obj2["foo"]["bar"][0]) - - -def test_simple_list() -> None: - obj1 = ["a", "b", "c"] - obj2 = deepcopy_minimal(obj1) - assert_different_identities(obj1, obj2) - - -def test_nested_list() -> None: - obj1 = ["a", [1, 2, 3]] - obj2 = deepcopy_minimal(obj1) - assert_different_identities(obj1, obj2) - assert_different_identities(obj1[1], obj2[1]) - - -class MyObject: ... - - -def test_ignores_other_types() -> None: - # custom classes - my_obj = MyObject() - obj1 = {"foo": my_obj} - obj2 = deepcopy_minimal(obj1) - assert_different_identities(obj1, obj2) - assert obj1["foo"] is my_obj - - # tuples - obj3 = ("a", "b") - obj4 = deepcopy_minimal(obj3) - assert obj3 is obj4 diff --git a/tests/test_extract_files.py b/tests/test_extract_files.py deleted file mode 100644 index 1fc490b..0000000 --- a/tests/test_extract_files.py +++ /dev/null @@ -1,64 +0,0 @@ -from __future__ import annotations - -from typing import Sequence - -import pytest - -from opencode_ai._types import FileTypes -from opencode_ai._utils import extract_files - - -def test_removes_files_from_input() -> None: - query = {"foo": "bar"} - assert extract_files(query, paths=[]) == [] - assert query == {"foo": "bar"} - - query2 = {"foo": b"Bar", "hello": "world"} - assert extract_files(query2, paths=[["foo"]]) == [("foo", b"Bar")] - assert query2 == {"hello": "world"} - - query3 = {"foo": {"foo": {"bar": b"Bar"}}, "hello": "world"} - assert extract_files(query3, paths=[["foo", "foo", "bar"]]) == [("foo[foo][bar]", b"Bar")] - assert query3 == {"foo": {"foo": {}}, "hello": "world"} - - query4 = {"foo": {"bar": b"Bar", "baz": "foo"}, "hello": "world"} - assert extract_files(query4, paths=[["foo", "bar"]]) == [("foo[bar]", b"Bar")] - assert query4 == {"hello": "world", "foo": {"baz": "foo"}} - - -def test_multiple_files() -> None: - query = {"documents": [{"file": b"My first file"}, {"file": b"My second file"}]} - assert extract_files(query, paths=[["documents", "", "file"]]) == [ - ("documents[][file]", b"My first file"), - ("documents[][file]", b"My second file"), - ] - assert query == {"documents": [{}, {}]} - - -@pytest.mark.parametrize( - "query,paths,expected", - [ - [ - {"foo": {"bar": "baz"}}, - [["foo", "", "bar"]], - [], - ], - [ - {"foo": ["bar", "baz"]}, - [["foo", "bar"]], - [], - ], - [ - {"foo": {"bar": "baz"}}, - [["foo", "foo"]], - [], - ], - ], - ids=["dict expecting array", "array expecting dict", "unknown keys"], -) -def test_ignores_incorrect_paths( - query: dict[str, object], - paths: Sequence[Sequence[str]], - expected: list[tuple[str, FileTypes]], -) -> None: - assert extract_files(query, paths=paths) == expected diff --git a/tests/test_files.py b/tests/test_files.py deleted file mode 100644 index 6aec2c3..0000000 --- a/tests/test_files.py +++ /dev/null @@ -1,51 +0,0 @@ -from pathlib import Path - -import anyio -import pytest -from dirty_equals import IsDict, IsList, IsBytes, IsTuple - -from opencode_ai._files import to_httpx_files, async_to_httpx_files - -readme_path = Path(__file__).parent.parent.joinpath("README.md") - - -def test_pathlib_includes_file_name() -> None: - result = to_httpx_files({"file": readme_path}) - print(result) - assert result == IsDict({"file": IsTuple("README.md", IsBytes())}) - - -def test_tuple_input() -> None: - result = to_httpx_files([("file", readme_path)]) - print(result) - assert result == IsList(IsTuple("file", IsTuple("README.md", IsBytes()))) - - -@pytest.mark.asyncio -async def test_async_pathlib_includes_file_name() -> None: - result = await async_to_httpx_files({"file": readme_path}) - print(result) - assert result == IsDict({"file": IsTuple("README.md", IsBytes())}) - - -@pytest.mark.asyncio -async def test_async_supports_anyio_path() -> None: - result = await async_to_httpx_files({"file": anyio.Path(readme_path)}) - print(result) - assert result == IsDict({"file": IsTuple("README.md", IsBytes())}) - - -@pytest.mark.asyncio -async def test_async_tuple_input() -> None: - result = await async_to_httpx_files([("file", readme_path)]) - print(result) - assert result == IsList(IsTuple("file", IsTuple("README.md", IsBytes()))) - - -def test_string_not_allowed() -> None: - with pytest.raises(TypeError, match="Expected file types input to be a FileContent type or to be a tuple"): - to_httpx_files( - { - "file": "foo", # type: ignore - } - ) diff --git a/tests/test_models.py b/tests/test_models.py deleted file mode 100644 index 94fa22d..0000000 --- a/tests/test_models.py +++ /dev/null @@ -1,963 +0,0 @@ -import json -from typing import TYPE_CHECKING, Any, Dict, List, Union, Optional, cast -from datetime import datetime, timezone -from typing_extensions import Literal, Annotated, TypeAliasType - -import pytest -import pydantic -from pydantic import Field - -from opencode_ai._utils import PropertyInfo -from opencode_ai._compat import PYDANTIC_V2, parse_obj, model_dump, model_json -from opencode_ai._models import BaseModel, construct_type - - -class BasicModel(BaseModel): - foo: str - - -@pytest.mark.parametrize("value", ["hello", 1], ids=["correct type", "mismatched"]) -def test_basic(value: object) -> None: - m = BasicModel.construct(foo=value) - assert m.foo == value - - -def test_directly_nested_model() -> None: - class NestedModel(BaseModel): - nested: BasicModel - - m = NestedModel.construct(nested={"foo": "Foo!"}) - assert m.nested.foo == "Foo!" - - # mismatched types - m = NestedModel.construct(nested="hello!") - assert cast(Any, m.nested) == "hello!" - - -def test_optional_nested_model() -> None: - class NestedModel(BaseModel): - nested: Optional[BasicModel] - - m1 = NestedModel.construct(nested=None) - assert m1.nested is None - - m2 = NestedModel.construct(nested={"foo": "bar"}) - assert m2.nested is not None - assert m2.nested.foo == "bar" - - # mismatched types - m3 = NestedModel.construct(nested={"foo"}) - assert isinstance(cast(Any, m3.nested), set) - assert cast(Any, m3.nested) == {"foo"} - - -def test_list_nested_model() -> None: - class NestedModel(BaseModel): - nested: List[BasicModel] - - m = NestedModel.construct(nested=[{"foo": "bar"}, {"foo": "2"}]) - assert m.nested is not None - assert isinstance(m.nested, list) - assert len(m.nested) == 2 - assert m.nested[0].foo == "bar" - assert m.nested[1].foo == "2" - - # mismatched types - m = NestedModel.construct(nested=True) - assert cast(Any, m.nested) is True - - m = NestedModel.construct(nested=[False]) - assert cast(Any, m.nested) == [False] - - -def test_optional_list_nested_model() -> None: - class NestedModel(BaseModel): - nested: Optional[List[BasicModel]] - - m1 = NestedModel.construct(nested=[{"foo": "bar"}, {"foo": "2"}]) - assert m1.nested is not None - assert isinstance(m1.nested, list) - assert len(m1.nested) == 2 - assert m1.nested[0].foo == "bar" - assert m1.nested[1].foo == "2" - - m2 = NestedModel.construct(nested=None) - assert m2.nested is None - - # mismatched types - m3 = NestedModel.construct(nested={1}) - assert cast(Any, m3.nested) == {1} - - m4 = NestedModel.construct(nested=[False]) - assert cast(Any, m4.nested) == [False] - - -def test_list_optional_items_nested_model() -> None: - class NestedModel(BaseModel): - nested: List[Optional[BasicModel]] - - m = NestedModel.construct(nested=[None, {"foo": "bar"}]) - assert m.nested is not None - assert isinstance(m.nested, list) - assert len(m.nested) == 2 - assert m.nested[0] is None - assert m.nested[1] is not None - assert m.nested[1].foo == "bar" - - # mismatched types - m3 = NestedModel.construct(nested="foo") - assert cast(Any, m3.nested) == "foo" - - m4 = NestedModel.construct(nested=[False]) - assert cast(Any, m4.nested) == [False] - - -def test_list_mismatched_type() -> None: - class NestedModel(BaseModel): - nested: List[str] - - m = NestedModel.construct(nested=False) - assert cast(Any, m.nested) is False - - -def test_raw_dictionary() -> None: - class NestedModel(BaseModel): - nested: Dict[str, str] - - m = NestedModel.construct(nested={"hello": "world"}) - assert m.nested == {"hello": "world"} - - # mismatched types - m = NestedModel.construct(nested=False) - assert cast(Any, m.nested) is False - - -def test_nested_dictionary_model() -> None: - class NestedModel(BaseModel): - nested: Dict[str, BasicModel] - - m = NestedModel.construct(nested={"hello": {"foo": "bar"}}) - assert isinstance(m.nested, dict) - assert m.nested["hello"].foo == "bar" - - # mismatched types - m = NestedModel.construct(nested={"hello": False}) - assert cast(Any, m.nested["hello"]) is False - - -def test_unknown_fields() -> None: - m1 = BasicModel.construct(foo="foo", unknown=1) - assert m1.foo == "foo" - assert cast(Any, m1).unknown == 1 - - m2 = BasicModel.construct(foo="foo", unknown={"foo_bar": True}) - assert m2.foo == "foo" - assert cast(Any, m2).unknown == {"foo_bar": True} - - assert model_dump(m2) == {"foo": "foo", "unknown": {"foo_bar": True}} - - -def test_strict_validation_unknown_fields() -> None: - class Model(BaseModel): - foo: str - - model = parse_obj(Model, dict(foo="hello!", user="Robert")) - assert model.foo == "hello!" - assert cast(Any, model).user == "Robert" - - assert model_dump(model) == {"foo": "hello!", "user": "Robert"} - - -def test_aliases() -> None: - class Model(BaseModel): - my_field: int = Field(alias="myField") - - m = Model.construct(myField=1) - assert m.my_field == 1 - - # mismatched types - m = Model.construct(myField={"hello": False}) - assert cast(Any, m.my_field) == {"hello": False} - - -def test_repr() -> None: - model = BasicModel(foo="bar") - assert str(model) == "BasicModel(foo='bar')" - assert repr(model) == "BasicModel(foo='bar')" - - -def test_repr_nested_model() -> None: - class Child(BaseModel): - name: str - age: int - - class Parent(BaseModel): - name: str - child: Child - - model = Parent(name="Robert", child=Child(name="Foo", age=5)) - assert str(model) == "Parent(name='Robert', child=Child(name='Foo', age=5))" - assert repr(model) == "Parent(name='Robert', child=Child(name='Foo', age=5))" - - -def test_optional_list() -> None: - class Submodel(BaseModel): - name: str - - class Model(BaseModel): - items: Optional[List[Submodel]] - - m = Model.construct(items=None) - assert m.items is None - - m = Model.construct(items=[]) - assert m.items == [] - - m = Model.construct(items=[{"name": "Robert"}]) - assert m.items is not None - assert len(m.items) == 1 - assert m.items[0].name == "Robert" - - -def test_nested_union_of_models() -> None: - class Submodel1(BaseModel): - bar: bool - - class Submodel2(BaseModel): - thing: str - - class Model(BaseModel): - foo: Union[Submodel1, Submodel2] - - m = Model.construct(foo={"thing": "hello"}) - assert isinstance(m.foo, Submodel2) - assert m.foo.thing == "hello" - - -def test_nested_union_of_mixed_types() -> None: - class Submodel1(BaseModel): - bar: bool - - class Model(BaseModel): - foo: Union[Submodel1, Literal[True], Literal["CARD_HOLDER"]] - - m = Model.construct(foo=True) - assert m.foo is True - - m = Model.construct(foo="CARD_HOLDER") - assert m.foo == "CARD_HOLDER" - - m = Model.construct(foo={"bar": False}) - assert isinstance(m.foo, Submodel1) - assert m.foo.bar is False - - -def test_nested_union_multiple_variants() -> None: - class Submodel1(BaseModel): - bar: bool - - class Submodel2(BaseModel): - thing: str - - class Submodel3(BaseModel): - foo: int - - class Model(BaseModel): - foo: Union[Submodel1, Submodel2, None, Submodel3] - - m = Model.construct(foo={"thing": "hello"}) - assert isinstance(m.foo, Submodel2) - assert m.foo.thing == "hello" - - m = Model.construct(foo=None) - assert m.foo is None - - m = Model.construct() - assert m.foo is None - - m = Model.construct(foo={"foo": "1"}) - assert isinstance(m.foo, Submodel3) - assert m.foo.foo == 1 - - -def test_nested_union_invalid_data() -> None: - class Submodel1(BaseModel): - level: int - - class Submodel2(BaseModel): - name: str - - class Model(BaseModel): - foo: Union[Submodel1, Submodel2] - - m = Model.construct(foo=True) - assert cast(bool, m.foo) is True - - m = Model.construct(foo={"name": 3}) - if PYDANTIC_V2: - assert isinstance(m.foo, Submodel1) - assert m.foo.name == 3 # type: ignore - else: - assert isinstance(m.foo, Submodel2) - assert m.foo.name == "3" - - -def test_list_of_unions() -> None: - class Submodel1(BaseModel): - level: int - - class Submodel2(BaseModel): - name: str - - class Model(BaseModel): - items: List[Union[Submodel1, Submodel2]] - - m = Model.construct(items=[{"level": 1}, {"name": "Robert"}]) - assert len(m.items) == 2 - assert isinstance(m.items[0], Submodel1) - assert m.items[0].level == 1 - assert isinstance(m.items[1], Submodel2) - assert m.items[1].name == "Robert" - - m = Model.construct(items=[{"level": -1}, 156]) - assert len(m.items) == 2 - assert isinstance(m.items[0], Submodel1) - assert m.items[0].level == -1 - assert cast(Any, m.items[1]) == 156 - - -def test_union_of_lists() -> None: - class SubModel1(BaseModel): - level: int - - class SubModel2(BaseModel): - name: str - - class Model(BaseModel): - items: Union[List[SubModel1], List[SubModel2]] - - # with one valid entry - m = Model.construct(items=[{"name": "Robert"}]) - assert len(m.items) == 1 - assert isinstance(m.items[0], SubModel2) - assert m.items[0].name == "Robert" - - # with two entries pointing to different types - m = Model.construct(items=[{"level": 1}, {"name": "Robert"}]) - assert len(m.items) == 2 - assert isinstance(m.items[0], SubModel1) - assert m.items[0].level == 1 - assert isinstance(m.items[1], SubModel1) - assert cast(Any, m.items[1]).name == "Robert" - - # with two entries pointing to *completely* different types - m = Model.construct(items=[{"level": -1}, 156]) - assert len(m.items) == 2 - assert isinstance(m.items[0], SubModel1) - assert m.items[0].level == -1 - assert cast(Any, m.items[1]) == 156 - - -def test_dict_of_union() -> None: - class SubModel1(BaseModel): - name: str - - class SubModel2(BaseModel): - foo: str - - class Model(BaseModel): - data: Dict[str, Union[SubModel1, SubModel2]] - - m = Model.construct(data={"hello": {"name": "there"}, "foo": {"foo": "bar"}}) - assert len(list(m.data.keys())) == 2 - assert isinstance(m.data["hello"], SubModel1) - assert m.data["hello"].name == "there" - assert isinstance(m.data["foo"], SubModel2) - assert m.data["foo"].foo == "bar" - - # TODO: test mismatched type - - -def test_double_nested_union() -> None: - class SubModel1(BaseModel): - name: str - - class SubModel2(BaseModel): - bar: str - - class Model(BaseModel): - data: Dict[str, List[Union[SubModel1, SubModel2]]] - - m = Model.construct(data={"foo": [{"bar": "baz"}, {"name": "Robert"}]}) - assert len(m.data["foo"]) == 2 - - entry1 = m.data["foo"][0] - assert isinstance(entry1, SubModel2) - assert entry1.bar == "baz" - - entry2 = m.data["foo"][1] - assert isinstance(entry2, SubModel1) - assert entry2.name == "Robert" - - # TODO: test mismatched type - - -def test_union_of_dict() -> None: - class SubModel1(BaseModel): - name: str - - class SubModel2(BaseModel): - foo: str - - class Model(BaseModel): - data: Union[Dict[str, SubModel1], Dict[str, SubModel2]] - - m = Model.construct(data={"hello": {"name": "there"}, "foo": {"foo": "bar"}}) - assert len(list(m.data.keys())) == 2 - assert isinstance(m.data["hello"], SubModel1) - assert m.data["hello"].name == "there" - assert isinstance(m.data["foo"], SubModel1) - assert cast(Any, m.data["foo"]).foo == "bar" - - -def test_iso8601_datetime() -> None: - class Model(BaseModel): - created_at: datetime - - expected = datetime(2019, 12, 27, 18, 11, 19, 117000, tzinfo=timezone.utc) - - if PYDANTIC_V2: - expected_json = '{"created_at":"2019-12-27T18:11:19.117000Z"}' - else: - expected_json = '{"created_at": "2019-12-27T18:11:19.117000+00:00"}' - - model = Model.construct(created_at="2019-12-27T18:11:19.117Z") - assert model.created_at == expected - assert model_json(model) == expected_json - - model = parse_obj(Model, dict(created_at="2019-12-27T18:11:19.117Z")) - assert model.created_at == expected - assert model_json(model) == expected_json - - -def test_does_not_coerce_int() -> None: - class Model(BaseModel): - bar: int - - assert Model.construct(bar=1).bar == 1 - assert Model.construct(bar=10.9).bar == 10.9 - assert Model.construct(bar="19").bar == "19" # type: ignore[comparison-overlap] - assert Model.construct(bar=False).bar is False - - -def test_int_to_float_safe_conversion() -> None: - class Model(BaseModel): - float_field: float - - m = Model.construct(float_field=10) - assert m.float_field == 10.0 - assert isinstance(m.float_field, float) - - m = Model.construct(float_field=10.12) - assert m.float_field == 10.12 - assert isinstance(m.float_field, float) - - # number too big - m = Model.construct(float_field=2**53 + 1) - assert m.float_field == 2**53 + 1 - assert isinstance(m.float_field, int) - - -def test_deprecated_alias() -> None: - class Model(BaseModel): - resource_id: str = Field(alias="model_id") - - @property - def model_id(self) -> str: - return self.resource_id - - m = Model.construct(model_id="id") - assert m.model_id == "id" - assert m.resource_id == "id" - assert m.resource_id is m.model_id - - m = parse_obj(Model, {"model_id": "id"}) - assert m.model_id == "id" - assert m.resource_id == "id" - assert m.resource_id is m.model_id - - -def test_omitted_fields() -> None: - class Model(BaseModel): - resource_id: Optional[str] = None - - m = Model.construct() - assert m.resource_id is None - assert "resource_id" not in m.model_fields_set - - m = Model.construct(resource_id=None) - assert m.resource_id is None - assert "resource_id" in m.model_fields_set - - m = Model.construct(resource_id="foo") - assert m.resource_id == "foo" - assert "resource_id" in m.model_fields_set - - -def test_to_dict() -> None: - class Model(BaseModel): - foo: Optional[str] = Field(alias="FOO", default=None) - - m = Model(FOO="hello") - assert m.to_dict() == {"FOO": "hello"} - assert m.to_dict(use_api_names=False) == {"foo": "hello"} - - m2 = Model() - assert m2.to_dict() == {} - assert m2.to_dict(exclude_unset=False) == {"FOO": None} - assert m2.to_dict(exclude_unset=False, exclude_none=True) == {} - assert m2.to_dict(exclude_unset=False, exclude_defaults=True) == {} - - m3 = Model(FOO=None) - assert m3.to_dict() == {"FOO": None} - assert m3.to_dict(exclude_none=True) == {} - assert m3.to_dict(exclude_defaults=True) == {} - - class Model2(BaseModel): - created_at: datetime - - time_str = "2024-03-21T11:39:01.275859" - m4 = Model2.construct(created_at=time_str) - assert m4.to_dict(mode="python") == {"created_at": datetime.fromisoformat(time_str)} - assert m4.to_dict(mode="json") == {"created_at": time_str} - - if not PYDANTIC_V2: - with pytest.raises(ValueError, match="warnings is only supported in Pydantic v2"): - m.to_dict(warnings=False) - - -def test_forwards_compat_model_dump_method() -> None: - class Model(BaseModel): - foo: Optional[str] = Field(alias="FOO", default=None) - - m = Model(FOO="hello") - assert m.model_dump() == {"foo": "hello"} - assert m.model_dump(include={"bar"}) == {} - assert m.model_dump(exclude={"foo"}) == {} - assert m.model_dump(by_alias=True) == {"FOO": "hello"} - - m2 = Model() - assert m2.model_dump() == {"foo": None} - assert m2.model_dump(exclude_unset=True) == {} - assert m2.model_dump(exclude_none=True) == {} - assert m2.model_dump(exclude_defaults=True) == {} - - m3 = Model(FOO=None) - assert m3.model_dump() == {"foo": None} - assert m3.model_dump(exclude_none=True) == {} - - if not PYDANTIC_V2: - with pytest.raises(ValueError, match="round_trip is only supported in Pydantic v2"): - m.model_dump(round_trip=True) - - with pytest.raises(ValueError, match="warnings is only supported in Pydantic v2"): - m.model_dump(warnings=False) - - -def test_compat_method_no_error_for_warnings() -> None: - class Model(BaseModel): - foo: Optional[str] - - m = Model(foo="hello") - assert isinstance(model_dump(m, warnings=False), dict) - - -def test_to_json() -> None: - class Model(BaseModel): - foo: Optional[str] = Field(alias="FOO", default=None) - - m = Model(FOO="hello") - assert json.loads(m.to_json()) == {"FOO": "hello"} - assert json.loads(m.to_json(use_api_names=False)) == {"foo": "hello"} - - if PYDANTIC_V2: - assert m.to_json(indent=None) == '{"FOO":"hello"}' - else: - assert m.to_json(indent=None) == '{"FOO": "hello"}' - - m2 = Model() - assert json.loads(m2.to_json()) == {} - assert json.loads(m2.to_json(exclude_unset=False)) == {"FOO": None} - assert json.loads(m2.to_json(exclude_unset=False, exclude_none=True)) == {} - assert json.loads(m2.to_json(exclude_unset=False, exclude_defaults=True)) == {} - - m3 = Model(FOO=None) - assert json.loads(m3.to_json()) == {"FOO": None} - assert json.loads(m3.to_json(exclude_none=True)) == {} - - if not PYDANTIC_V2: - with pytest.raises(ValueError, match="warnings is only supported in Pydantic v2"): - m.to_json(warnings=False) - - -def test_forwards_compat_model_dump_json_method() -> None: - class Model(BaseModel): - foo: Optional[str] = Field(alias="FOO", default=None) - - m = Model(FOO="hello") - assert json.loads(m.model_dump_json()) == {"foo": "hello"} - assert json.loads(m.model_dump_json(include={"bar"})) == {} - assert json.loads(m.model_dump_json(include={"foo"})) == {"foo": "hello"} - assert json.loads(m.model_dump_json(by_alias=True)) == {"FOO": "hello"} - - assert m.model_dump_json(indent=2) == '{\n "foo": "hello"\n}' - - m2 = Model() - assert json.loads(m2.model_dump_json()) == {"foo": None} - assert json.loads(m2.model_dump_json(exclude_unset=True)) == {} - assert json.loads(m2.model_dump_json(exclude_none=True)) == {} - assert json.loads(m2.model_dump_json(exclude_defaults=True)) == {} - - m3 = Model(FOO=None) - assert json.loads(m3.model_dump_json()) == {"foo": None} - assert json.loads(m3.model_dump_json(exclude_none=True)) == {} - - if not PYDANTIC_V2: - with pytest.raises(ValueError, match="round_trip is only supported in Pydantic v2"): - m.model_dump_json(round_trip=True) - - with pytest.raises(ValueError, match="warnings is only supported in Pydantic v2"): - m.model_dump_json(warnings=False) - - -def test_type_compat() -> None: - # our model type can be assigned to Pydantic's model type - - def takes_pydantic(model: pydantic.BaseModel) -> None: # noqa: ARG001 - ... - - class OurModel(BaseModel): - foo: Optional[str] = None - - takes_pydantic(OurModel()) - - -def test_annotated_types() -> None: - class Model(BaseModel): - value: str - - m = construct_type( - value={"value": "foo"}, - type_=cast(Any, Annotated[Model, "random metadata"]), - ) - assert isinstance(m, Model) - assert m.value == "foo" - - -def test_discriminated_unions_invalid_data() -> None: - class A(BaseModel): - type: Literal["a"] - - data: str - - class B(BaseModel): - type: Literal["b"] - - data: int - - m = construct_type( - value={"type": "b", "data": "foo"}, - type_=cast(Any, Annotated[Union[A, B], PropertyInfo(discriminator="type")]), - ) - assert isinstance(m, B) - assert m.type == "b" - assert m.data == "foo" # type: ignore[comparison-overlap] - - m = construct_type( - value={"type": "a", "data": 100}, - type_=cast(Any, Annotated[Union[A, B], PropertyInfo(discriminator="type")]), - ) - assert isinstance(m, A) - assert m.type == "a" - if PYDANTIC_V2: - assert m.data == 100 # type: ignore[comparison-overlap] - else: - # pydantic v1 automatically converts inputs to strings - # if the expected type is a str - assert m.data == "100" - - -def test_discriminated_unions_unknown_variant() -> None: - class A(BaseModel): - type: Literal["a"] - - data: str - - class B(BaseModel): - type: Literal["b"] - - data: int - - m = construct_type( - value={"type": "c", "data": None, "new_thing": "bar"}, - type_=cast(Any, Annotated[Union[A, B], PropertyInfo(discriminator="type")]), - ) - - # just chooses the first variant - assert isinstance(m, A) - assert m.type == "c" # type: ignore[comparison-overlap] - assert m.data == None # type: ignore[unreachable] - assert m.new_thing == "bar" - - -def test_discriminated_unions_invalid_data_nested_unions() -> None: - class A(BaseModel): - type: Literal["a"] - - data: str - - class B(BaseModel): - type: Literal["b"] - - data: int - - class C(BaseModel): - type: Literal["c"] - - data: bool - - m = construct_type( - value={"type": "b", "data": "foo"}, - type_=cast(Any, Annotated[Union[Union[A, B], C], PropertyInfo(discriminator="type")]), - ) - assert isinstance(m, B) - assert m.type == "b" - assert m.data == "foo" # type: ignore[comparison-overlap] - - m = construct_type( - value={"type": "c", "data": "foo"}, - type_=cast(Any, Annotated[Union[Union[A, B], C], PropertyInfo(discriminator="type")]), - ) - assert isinstance(m, C) - assert m.type == "c" - assert m.data == "foo" # type: ignore[comparison-overlap] - - -def test_discriminated_unions_with_aliases_invalid_data() -> None: - class A(BaseModel): - foo_type: Literal["a"] = Field(alias="type") - - data: str - - class B(BaseModel): - foo_type: Literal["b"] = Field(alias="type") - - data: int - - m = construct_type( - value={"type": "b", "data": "foo"}, - type_=cast(Any, Annotated[Union[A, B], PropertyInfo(discriminator="foo_type")]), - ) - assert isinstance(m, B) - assert m.foo_type == "b" - assert m.data == "foo" # type: ignore[comparison-overlap] - - m = construct_type( - value={"type": "a", "data": 100}, - type_=cast(Any, Annotated[Union[A, B], PropertyInfo(discriminator="foo_type")]), - ) - assert isinstance(m, A) - assert m.foo_type == "a" - if PYDANTIC_V2: - assert m.data == 100 # type: ignore[comparison-overlap] - else: - # pydantic v1 automatically converts inputs to strings - # if the expected type is a str - assert m.data == "100" - - -def test_discriminated_unions_overlapping_discriminators_invalid_data() -> None: - class A(BaseModel): - type: Literal["a"] - - data: bool - - class B(BaseModel): - type: Literal["a"] - - data: int - - m = construct_type( - value={"type": "a", "data": "foo"}, - type_=cast(Any, Annotated[Union[A, B], PropertyInfo(discriminator="type")]), - ) - assert isinstance(m, B) - assert m.type == "a" - assert m.data == "foo" # type: ignore[comparison-overlap] - - -def test_discriminated_unions_invalid_data_uses_cache() -> None: - class A(BaseModel): - type: Literal["a"] - - data: str - - class B(BaseModel): - type: Literal["b"] - - data: int - - UnionType = cast(Any, Union[A, B]) - - assert not hasattr(UnionType, "__discriminator__") - - m = construct_type( - value={"type": "b", "data": "foo"}, type_=cast(Any, Annotated[UnionType, PropertyInfo(discriminator="type")]) - ) - assert isinstance(m, B) - assert m.type == "b" - assert m.data == "foo" # type: ignore[comparison-overlap] - - discriminator = UnionType.__discriminator__ - assert discriminator is not None - - m = construct_type( - value={"type": "b", "data": "foo"}, type_=cast(Any, Annotated[UnionType, PropertyInfo(discriminator="type")]) - ) - assert isinstance(m, B) - assert m.type == "b" - assert m.data == "foo" # type: ignore[comparison-overlap] - - # if the discriminator details object stays the same between invocations then - # we hit the cache - assert UnionType.__discriminator__ is discriminator - - -@pytest.mark.skipif(not PYDANTIC_V2, reason="TypeAliasType is not supported in Pydantic v1") -def test_type_alias_type() -> None: - Alias = TypeAliasType("Alias", str) # pyright: ignore - - class Model(BaseModel): - alias: Alias - union: Union[int, Alias] - - m = construct_type(value={"alias": "foo", "union": "bar"}, type_=Model) - assert isinstance(m, Model) - assert isinstance(m.alias, str) - assert m.alias == "foo" - assert isinstance(m.union, str) - assert m.union == "bar" - - -@pytest.mark.skipif(not PYDANTIC_V2, reason="TypeAliasType is not supported in Pydantic v1") -def test_field_named_cls() -> None: - class Model(BaseModel): - cls: str - - m = construct_type(value={"cls": "foo"}, type_=Model) - assert isinstance(m, Model) - assert isinstance(m.cls, str) - - -def test_discriminated_union_case() -> None: - class A(BaseModel): - type: Literal["a"] - - data: bool - - class B(BaseModel): - type: Literal["b"] - - data: List[Union[A, object]] - - class ModelA(BaseModel): - type: Literal["modelA"] - - data: int - - class ModelB(BaseModel): - type: Literal["modelB"] - - required: str - - data: Union[A, B] - - # when constructing ModelA | ModelB, value data doesn't match ModelB exactly - missing `required` - m = construct_type( - value={"type": "modelB", "data": {"type": "a", "data": True}}, - type_=cast(Any, Annotated[Union[ModelA, ModelB], PropertyInfo(discriminator="type")]), - ) - - assert isinstance(m, ModelB) - - -def test_nested_discriminated_union() -> None: - class InnerType1(BaseModel): - type: Literal["type_1"] - - class InnerModel(BaseModel): - inner_value: str - - class InnerType2(BaseModel): - type: Literal["type_2"] - some_inner_model: InnerModel - - class Type1(BaseModel): - base_type: Literal["base_type_1"] - value: Annotated[ - Union[ - InnerType1, - InnerType2, - ], - PropertyInfo(discriminator="type"), - ] - - class Type2(BaseModel): - base_type: Literal["base_type_2"] - - T = Annotated[ - Union[ - Type1, - Type2, - ], - PropertyInfo(discriminator="base_type"), - ] - - model = construct_type( - type_=T, - value={ - "base_type": "base_type_1", - "value": { - "type": "type_2", - }, - }, - ) - assert isinstance(model, Type1) - assert isinstance(model.value, InnerType2) - - -@pytest.mark.skipif(not PYDANTIC_V2, reason="this is only supported in pydantic v2 for now") -def test_extra_properties() -> None: - class Item(BaseModel): - prop: int - - class Model(BaseModel): - __pydantic_extra__: Dict[str, Item] = Field(init=False) # pyright: ignore[reportIncompatibleVariableOverride] - - other: str - - if TYPE_CHECKING: - - def __getattr__(self, attr: str) -> Item: ... - - model = construct_type( - type_=Model, - value={ - "a": {"prop": 1}, - "other": "foo", - }, - ) - assert isinstance(model, Model) - assert model.a.prop == 1 - assert isinstance(model.a, Item) - assert model.other == "foo" diff --git a/tests/test_qs.py b/tests/test_qs.py deleted file mode 100644 index 876a71f..0000000 --- a/tests/test_qs.py +++ /dev/null @@ -1,78 +0,0 @@ -from typing import Any, cast -from functools import partial -from urllib.parse import unquote - -import pytest - -from opencode_ai._qs import Querystring, stringify - - -def test_empty() -> None: - assert stringify({}) == "" - assert stringify({"a": {}}) == "" - assert stringify({"a": {"b": {"c": {}}}}) == "" - - -def test_basic() -> None: - assert stringify({"a": 1}) == "a=1" - assert stringify({"a": "b"}) == "a=b" - assert stringify({"a": True}) == "a=true" - assert stringify({"a": False}) == "a=false" - assert stringify({"a": 1.23456}) == "a=1.23456" - assert stringify({"a": None}) == "" - - -@pytest.mark.parametrize("method", ["class", "function"]) -def test_nested_dotted(method: str) -> None: - if method == "class": - serialise = Querystring(nested_format="dots").stringify - else: - serialise = partial(stringify, nested_format="dots") - - assert unquote(serialise({"a": {"b": "c"}})) == "a.b=c" - assert unquote(serialise({"a": {"b": "c", "d": "e", "f": "g"}})) == "a.b=c&a.d=e&a.f=g" - assert unquote(serialise({"a": {"b": {"c": {"d": "e"}}}})) == "a.b.c.d=e" - assert unquote(serialise({"a": {"b": True}})) == "a.b=true" - - -def test_nested_brackets() -> None: - assert unquote(stringify({"a": {"b": "c"}})) == "a[b]=c" - assert unquote(stringify({"a": {"b": "c", "d": "e", "f": "g"}})) == "a[b]=c&a[d]=e&a[f]=g" - assert unquote(stringify({"a": {"b": {"c": {"d": "e"}}}})) == "a[b][c][d]=e" - assert unquote(stringify({"a": {"b": True}})) == "a[b]=true" - - -@pytest.mark.parametrize("method", ["class", "function"]) -def test_array_comma(method: str) -> None: - if method == "class": - serialise = Querystring(array_format="comma").stringify - else: - serialise = partial(stringify, array_format="comma") - - assert unquote(serialise({"in": ["foo", "bar"]})) == "in=foo,bar" - assert unquote(serialise({"a": {"b": [True, False]}})) == "a[b]=true,false" - assert unquote(serialise({"a": {"b": [True, False, None, True]}})) == "a[b]=true,false,true" - - -def test_array_repeat() -> None: - assert unquote(stringify({"in": ["foo", "bar"]})) == "in=foo&in=bar" - assert unquote(stringify({"a": {"b": [True, False]}})) == "a[b]=true&a[b]=false" - assert unquote(stringify({"a": {"b": [True, False, None, True]}})) == "a[b]=true&a[b]=false&a[b]=true" - assert unquote(stringify({"in": ["foo", {"b": {"c": ["d", "e"]}}]})) == "in=foo&in[b][c]=d&in[b][c]=e" - - -@pytest.mark.parametrize("method", ["class", "function"]) -def test_array_brackets(method: str) -> None: - if method == "class": - serialise = Querystring(array_format="brackets").stringify - else: - serialise = partial(stringify, array_format="brackets") - - assert unquote(serialise({"in": ["foo", "bar"]})) == "in[]=foo&in[]=bar" - assert unquote(serialise({"a": {"b": [True, False]}})) == "a[b][]=true&a[b][]=false" - assert unquote(serialise({"a": {"b": [True, False, None, True]}})) == "a[b][]=true&a[b][]=false&a[b][]=true" - - -def test_unknown_array_format() -> None: - with pytest.raises(NotImplementedError, match="Unknown array_format value: foo, choose from comma, repeat"): - stringify({"a": ["foo", "bar"]}, array_format=cast(Any, "foo")) diff --git a/tests/test_required_args.py b/tests/test_required_args.py deleted file mode 100644 index d4b6a7e..0000000 --- a/tests/test_required_args.py +++ /dev/null @@ -1,111 +0,0 @@ -from __future__ import annotations - -import pytest - -from opencode_ai._utils import required_args - - -def test_too_many_positional_params() -> None: - @required_args(["a"]) - def foo(a: str | None = None) -> str | None: - return a - - with pytest.raises(TypeError, match=r"foo\(\) takes 1 argument\(s\) but 2 were given"): - foo("a", "b") # type: ignore - - -def test_positional_param() -> None: - @required_args(["a"]) - def foo(a: str | None = None) -> str | None: - return a - - assert foo("a") == "a" - assert foo(None) is None - assert foo(a="b") == "b" - - with pytest.raises(TypeError, match="Missing required argument: 'a'"): - foo() - - -def test_keyword_only_param() -> None: - @required_args(["a"]) - def foo(*, a: str | None = None) -> str | None: - return a - - assert foo(a="a") == "a" - assert foo(a=None) is None - assert foo(a="b") == "b" - - with pytest.raises(TypeError, match="Missing required argument: 'a'"): - foo() - - -def test_multiple_params() -> None: - @required_args(["a", "b", "c"]) - def foo(a: str = "", *, b: str = "", c: str = "") -> str | None: - return f"{a} {b} {c}" - - assert foo(a="a", b="b", c="c") == "a b c" - - error_message = r"Missing required arguments.*" - - with pytest.raises(TypeError, match=error_message): - foo() - - with pytest.raises(TypeError, match=error_message): - foo(a="a") - - with pytest.raises(TypeError, match=error_message): - foo(b="b") - - with pytest.raises(TypeError, match=error_message): - foo(c="c") - - with pytest.raises(TypeError, match=r"Missing required argument: 'a'"): - foo(b="a", c="c") - - with pytest.raises(TypeError, match=r"Missing required argument: 'b'"): - foo("a", c="c") - - -def test_multiple_variants() -> None: - @required_args(["a"], ["b"]) - def foo(*, a: str | None = None, b: str | None = None) -> str | None: - return a if a is not None else b - - assert foo(a="foo") == "foo" - assert foo(b="bar") == "bar" - assert foo(a=None) is None - assert foo(b=None) is None - - # TODO: this error message could probably be improved - with pytest.raises( - TypeError, - match=r"Missing required arguments; Expected either \('a'\) or \('b'\) arguments to be given", - ): - foo() - - -def test_multiple_params_multiple_variants() -> None: - @required_args(["a", "b"], ["c"]) - def foo(*, a: str | None = None, b: str | None = None, c: str | None = None) -> str | None: - if a is not None: - return a - if b is not None: - return b - return c - - error_message = r"Missing required arguments; Expected either \('a' and 'b'\) or \('c'\) arguments to be given" - - with pytest.raises(TypeError, match=error_message): - foo(a="foo") - - with pytest.raises(TypeError, match=error_message): - foo(b="bar") - - with pytest.raises(TypeError, match=error_message): - foo() - - assert foo(a=None, b="bar") == "bar" - assert foo(c=None) is None - assert foo(c="foo") == "foo" diff --git a/tests/test_response.py b/tests/test_response.py deleted file mode 100644 index 24cd168..0000000 --- a/tests/test_response.py +++ /dev/null @@ -1,277 +0,0 @@ -import json -from typing import Any, List, Union, cast -from typing_extensions import Annotated - -import httpx -import pytest -import pydantic - -from opencode_ai import Opencode, BaseModel, AsyncOpencode -from opencode_ai._response import ( - APIResponse, - BaseAPIResponse, - AsyncAPIResponse, - BinaryAPIResponse, - AsyncBinaryAPIResponse, - extract_response_type, -) -from opencode_ai._streaming import Stream -from opencode_ai._base_client import FinalRequestOptions - - -class ConcreteBaseAPIResponse(APIResponse[bytes]): ... - - -class ConcreteAPIResponse(APIResponse[List[str]]): ... - - -class ConcreteAsyncAPIResponse(APIResponse[httpx.Response]): ... - - -def test_extract_response_type_direct_classes() -> None: - assert extract_response_type(BaseAPIResponse[str]) == str - assert extract_response_type(APIResponse[str]) == str - assert extract_response_type(AsyncAPIResponse[str]) == str - - -def test_extract_response_type_direct_class_missing_type_arg() -> None: - with pytest.raises( - RuntimeError, - match="Expected type to have a type argument at index 0 but it did not", - ): - extract_response_type(AsyncAPIResponse) - - -def test_extract_response_type_concrete_subclasses() -> None: - assert extract_response_type(ConcreteBaseAPIResponse) == bytes - assert extract_response_type(ConcreteAPIResponse) == List[str] - assert extract_response_type(ConcreteAsyncAPIResponse) == httpx.Response - - -def test_extract_response_type_binary_response() -> None: - assert extract_response_type(BinaryAPIResponse) == bytes - assert extract_response_type(AsyncBinaryAPIResponse) == bytes - - -class PydanticModel(pydantic.BaseModel): ... - - -def test_response_parse_mismatched_basemodel(client: Opencode) -> None: - response = APIResponse( - raw=httpx.Response(200, content=b"foo"), - client=client, - stream=False, - stream_cls=None, - cast_to=str, - options=FinalRequestOptions.construct(method="get", url="/foo"), - ) - - with pytest.raises( - TypeError, - match="Pydantic models must subclass our base model type, e.g. `from opencode_ai import BaseModel`", - ): - response.parse(to=PydanticModel) - - -@pytest.mark.asyncio -async def test_async_response_parse_mismatched_basemodel(async_client: AsyncOpencode) -> None: - response = AsyncAPIResponse( - raw=httpx.Response(200, content=b"foo"), - client=async_client, - stream=False, - stream_cls=None, - cast_to=str, - options=FinalRequestOptions.construct(method="get", url="/foo"), - ) - - with pytest.raises( - TypeError, - match="Pydantic models must subclass our base model type, e.g. `from opencode_ai import BaseModel`", - ): - await response.parse(to=PydanticModel) - - -def test_response_parse_custom_stream(client: Opencode) -> None: - response = APIResponse( - raw=httpx.Response(200, content=b"foo"), - client=client, - stream=True, - stream_cls=None, - cast_to=str, - options=FinalRequestOptions.construct(method="get", url="/foo"), - ) - - stream = response.parse(to=Stream[int]) - assert stream._cast_to == int - - -@pytest.mark.asyncio -async def test_async_response_parse_custom_stream(async_client: AsyncOpencode) -> None: - response = AsyncAPIResponse( - raw=httpx.Response(200, content=b"foo"), - client=async_client, - stream=True, - stream_cls=None, - cast_to=str, - options=FinalRequestOptions.construct(method="get", url="/foo"), - ) - - stream = await response.parse(to=Stream[int]) - assert stream._cast_to == int - - -class CustomModel(BaseModel): - foo: str - bar: int - - -def test_response_parse_custom_model(client: Opencode) -> None: - response = APIResponse( - raw=httpx.Response(200, content=json.dumps({"foo": "hello!", "bar": 2})), - client=client, - stream=False, - stream_cls=None, - cast_to=str, - options=FinalRequestOptions.construct(method="get", url="/foo"), - ) - - obj = response.parse(to=CustomModel) - assert obj.foo == "hello!" - assert obj.bar == 2 - - -@pytest.mark.asyncio -async def test_async_response_parse_custom_model(async_client: AsyncOpencode) -> None: - response = AsyncAPIResponse( - raw=httpx.Response(200, content=json.dumps({"foo": "hello!", "bar": 2})), - client=async_client, - stream=False, - stream_cls=None, - cast_to=str, - options=FinalRequestOptions.construct(method="get", url="/foo"), - ) - - obj = await response.parse(to=CustomModel) - assert obj.foo == "hello!" - assert obj.bar == 2 - - -def test_response_parse_annotated_type(client: Opencode) -> None: - response = APIResponse( - raw=httpx.Response(200, content=json.dumps({"foo": "hello!", "bar": 2})), - client=client, - stream=False, - stream_cls=None, - cast_to=str, - options=FinalRequestOptions.construct(method="get", url="/foo"), - ) - - obj = response.parse( - to=cast("type[CustomModel]", Annotated[CustomModel, "random metadata"]), - ) - assert obj.foo == "hello!" - assert obj.bar == 2 - - -async def test_async_response_parse_annotated_type(async_client: AsyncOpencode) -> None: - response = AsyncAPIResponse( - raw=httpx.Response(200, content=json.dumps({"foo": "hello!", "bar": 2})), - client=async_client, - stream=False, - stream_cls=None, - cast_to=str, - options=FinalRequestOptions.construct(method="get", url="/foo"), - ) - - obj = await response.parse( - to=cast("type[CustomModel]", Annotated[CustomModel, "random metadata"]), - ) - assert obj.foo == "hello!" - assert obj.bar == 2 - - -@pytest.mark.parametrize( - "content, expected", - [ - ("false", False), - ("true", True), - ("False", False), - ("True", True), - ("TrUe", True), - ("FalSe", False), - ], -) -def test_response_parse_bool(client: Opencode, content: str, expected: bool) -> None: - response = APIResponse( - raw=httpx.Response(200, content=content), - client=client, - stream=False, - stream_cls=None, - cast_to=str, - options=FinalRequestOptions.construct(method="get", url="/foo"), - ) - - result = response.parse(to=bool) - assert result is expected - - -@pytest.mark.parametrize( - "content, expected", - [ - ("false", False), - ("true", True), - ("False", False), - ("True", True), - ("TrUe", True), - ("FalSe", False), - ], -) -async def test_async_response_parse_bool(client: AsyncOpencode, content: str, expected: bool) -> None: - response = AsyncAPIResponse( - raw=httpx.Response(200, content=content), - client=client, - stream=False, - stream_cls=None, - cast_to=str, - options=FinalRequestOptions.construct(method="get", url="/foo"), - ) - - result = await response.parse(to=bool) - assert result is expected - - -class OtherModel(BaseModel): - a: str - - -@pytest.mark.parametrize("client", [False], indirect=True) # loose validation -def test_response_parse_expect_model_union_non_json_content(client: Opencode) -> None: - response = APIResponse( - raw=httpx.Response(200, content=b"foo", headers={"Content-Type": "application/text"}), - client=client, - stream=False, - stream_cls=None, - cast_to=str, - options=FinalRequestOptions.construct(method="get", url="/foo"), - ) - - obj = response.parse(to=cast(Any, Union[CustomModel, OtherModel])) - assert isinstance(obj, str) - assert obj == "foo" - - -@pytest.mark.asyncio -@pytest.mark.parametrize("async_client", [False], indirect=True) # loose validation -async def test_async_response_parse_expect_model_union_non_json_content(async_client: AsyncOpencode) -> None: - response = AsyncAPIResponse( - raw=httpx.Response(200, content=b"foo", headers={"Content-Type": "application/text"}), - client=async_client, - stream=False, - stream_cls=None, - cast_to=str, - options=FinalRequestOptions.construct(method="get", url="/foo"), - ) - - obj = await response.parse(to=cast(Any, Union[CustomModel, OtherModel])) - assert isinstance(obj, str) - assert obj == "foo" diff --git a/tests/test_streaming.py b/tests/test_streaming.py deleted file mode 100644 index a3e8d4f..0000000 --- a/tests/test_streaming.py +++ /dev/null @@ -1,248 +0,0 @@ -from __future__ import annotations - -from typing import Iterator, AsyncIterator - -import httpx -import pytest - -from opencode_ai import Opencode, AsyncOpencode -from opencode_ai._streaming import Stream, AsyncStream, ServerSentEvent - - -@pytest.mark.asyncio -@pytest.mark.parametrize("sync", [True, False], ids=["sync", "async"]) -async def test_basic(sync: bool, client: Opencode, async_client: AsyncOpencode) -> None: - def body() -> Iterator[bytes]: - yield b"event: completion\n" - yield b'data: {"foo":true}\n' - yield b"\n" - - iterator = make_event_iterator(content=body(), sync=sync, client=client, async_client=async_client) - - sse = await iter_next(iterator) - assert sse.event == "completion" - assert sse.json() == {"foo": True} - - await assert_empty_iter(iterator) - - -@pytest.mark.asyncio -@pytest.mark.parametrize("sync", [True, False], ids=["sync", "async"]) -async def test_data_missing_event(sync: bool, client: Opencode, async_client: AsyncOpencode) -> None: - def body() -> Iterator[bytes]: - yield b'data: {"foo":true}\n' - yield b"\n" - - iterator = make_event_iterator(content=body(), sync=sync, client=client, async_client=async_client) - - sse = await iter_next(iterator) - assert sse.event is None - assert sse.json() == {"foo": True} - - await assert_empty_iter(iterator) - - -@pytest.mark.asyncio -@pytest.mark.parametrize("sync", [True, False], ids=["sync", "async"]) -async def test_event_missing_data(sync: bool, client: Opencode, async_client: AsyncOpencode) -> None: - def body() -> Iterator[bytes]: - yield b"event: ping\n" - yield b"\n" - - iterator = make_event_iterator(content=body(), sync=sync, client=client, async_client=async_client) - - sse = await iter_next(iterator) - assert sse.event == "ping" - assert sse.data == "" - - await assert_empty_iter(iterator) - - -@pytest.mark.asyncio -@pytest.mark.parametrize("sync", [True, False], ids=["sync", "async"]) -async def test_multiple_events(sync: bool, client: Opencode, async_client: AsyncOpencode) -> None: - def body() -> Iterator[bytes]: - yield b"event: ping\n" - yield b"\n" - yield b"event: completion\n" - yield b"\n" - - iterator = make_event_iterator(content=body(), sync=sync, client=client, async_client=async_client) - - sse = await iter_next(iterator) - assert sse.event == "ping" - assert sse.data == "" - - sse = await iter_next(iterator) - assert sse.event == "completion" - assert sse.data == "" - - await assert_empty_iter(iterator) - - -@pytest.mark.asyncio -@pytest.mark.parametrize("sync", [True, False], ids=["sync", "async"]) -async def test_multiple_events_with_data(sync: bool, client: Opencode, async_client: AsyncOpencode) -> None: - def body() -> Iterator[bytes]: - yield b"event: ping\n" - yield b'data: {"foo":true}\n' - yield b"\n" - yield b"event: completion\n" - yield b'data: {"bar":false}\n' - yield b"\n" - - iterator = make_event_iterator(content=body(), sync=sync, client=client, async_client=async_client) - - sse = await iter_next(iterator) - assert sse.event == "ping" - assert sse.json() == {"foo": True} - - sse = await iter_next(iterator) - assert sse.event == "completion" - assert sse.json() == {"bar": False} - - await assert_empty_iter(iterator) - - -@pytest.mark.asyncio -@pytest.mark.parametrize("sync", [True, False], ids=["sync", "async"]) -async def test_multiple_data_lines_with_empty_line(sync: bool, client: Opencode, async_client: AsyncOpencode) -> None: - def body() -> Iterator[bytes]: - yield b"event: ping\n" - yield b"data: {\n" - yield b'data: "foo":\n' - yield b"data: \n" - yield b"data:\n" - yield b"data: true}\n" - yield b"\n\n" - - iterator = make_event_iterator(content=body(), sync=sync, client=client, async_client=async_client) - - sse = await iter_next(iterator) - assert sse.event == "ping" - assert sse.json() == {"foo": True} - assert sse.data == '{\n"foo":\n\n\ntrue}' - - await assert_empty_iter(iterator) - - -@pytest.mark.asyncio -@pytest.mark.parametrize("sync", [True, False], ids=["sync", "async"]) -async def test_data_json_escaped_double_new_line(sync: bool, client: Opencode, async_client: AsyncOpencode) -> None: - def body() -> Iterator[bytes]: - yield b"event: ping\n" - yield b'data: {"foo": "my long\\n\\ncontent"}' - yield b"\n\n" - - iterator = make_event_iterator(content=body(), sync=sync, client=client, async_client=async_client) - - sse = await iter_next(iterator) - assert sse.event == "ping" - assert sse.json() == {"foo": "my long\n\ncontent"} - - await assert_empty_iter(iterator) - - -@pytest.mark.asyncio -@pytest.mark.parametrize("sync", [True, False], ids=["sync", "async"]) -async def test_multiple_data_lines(sync: bool, client: Opencode, async_client: AsyncOpencode) -> None: - def body() -> Iterator[bytes]: - yield b"event: ping\n" - yield b"data: {\n" - yield b'data: "foo":\n' - yield b"data: true}\n" - yield b"\n\n" - - iterator = make_event_iterator(content=body(), sync=sync, client=client, async_client=async_client) - - sse = await iter_next(iterator) - assert sse.event == "ping" - assert sse.json() == {"foo": True} - - await assert_empty_iter(iterator) - - -@pytest.mark.parametrize("sync", [True, False], ids=["sync", "async"]) -async def test_special_new_line_character( - sync: bool, - client: Opencode, - async_client: AsyncOpencode, -) -> None: - def body() -> Iterator[bytes]: - yield b'data: {"content":" culpa"}\n' - yield b"\n" - yield b'data: {"content":" \xe2\x80\xa8"}\n' - yield b"\n" - yield b'data: {"content":"foo"}\n' - yield b"\n" - - iterator = make_event_iterator(content=body(), sync=sync, client=client, async_client=async_client) - - sse = await iter_next(iterator) - assert sse.event is None - assert sse.json() == {"content": " culpa"} - - sse = await iter_next(iterator) - assert sse.event is None - assert sse.json() == {"content": " 
"} - - sse = await iter_next(iterator) - assert sse.event is None - assert sse.json() == {"content": "foo"} - - await assert_empty_iter(iterator) - - -@pytest.mark.parametrize("sync", [True, False], ids=["sync", "async"]) -async def test_multi_byte_character_multiple_chunks( - sync: bool, - client: Opencode, - async_client: AsyncOpencode, -) -> None: - def body() -> Iterator[bytes]: - yield b'data: {"content":"' - # bytes taken from the string 'известни' and arbitrarily split - # so that some multi-byte characters span multiple chunks - yield b"\xd0" - yield b"\xb8\xd0\xb7\xd0" - yield b"\xb2\xd0\xb5\xd1\x81\xd1\x82\xd0\xbd\xd0\xb8" - yield b'"}\n' - yield b"\n" - - iterator = make_event_iterator(content=body(), sync=sync, client=client, async_client=async_client) - - sse = await iter_next(iterator) - assert sse.event is None - assert sse.json() == {"content": "известни"} - - -async def to_aiter(iter: Iterator[bytes]) -> AsyncIterator[bytes]: - for chunk in iter: - yield chunk - - -async def iter_next(iter: Iterator[ServerSentEvent] | AsyncIterator[ServerSentEvent]) -> ServerSentEvent: - if isinstance(iter, AsyncIterator): - return await iter.__anext__() - - return next(iter) - - -async def assert_empty_iter(iter: Iterator[ServerSentEvent] | AsyncIterator[ServerSentEvent]) -> None: - with pytest.raises((StopAsyncIteration, RuntimeError)): - await iter_next(iter) - - -def make_event_iterator( - content: Iterator[bytes], - *, - sync: bool, - client: Opencode, - async_client: AsyncOpencode, -) -> Iterator[ServerSentEvent] | AsyncIterator[ServerSentEvent]: - if sync: - return Stream(cast_to=object, client=client, response=httpx.Response(200, content=content))._iter_events() - - return AsyncStream( - cast_to=object, client=async_client, response=httpx.Response(200, content=to_aiter(content)) - )._iter_events() diff --git a/tests/test_transform.py b/tests/test_transform.py deleted file mode 100644 index 14a6ae1..0000000 --- a/tests/test_transform.py +++ /dev/null @@ -1,453 +0,0 @@ -from __future__ import annotations - -import io -import pathlib -from typing import Any, Dict, List, Union, TypeVar, Iterable, Optional, cast -from datetime import date, datetime -from typing_extensions import Required, Annotated, TypedDict - -import pytest - -from opencode_ai._types import NOT_GIVEN, Base64FileInput -from opencode_ai._utils import ( - PropertyInfo, - transform as _transform, - parse_datetime, - async_transform as _async_transform, -) -from opencode_ai._compat import PYDANTIC_V2 -from opencode_ai._models import BaseModel - -_T = TypeVar("_T") - -SAMPLE_FILE_PATH = pathlib.Path(__file__).parent.joinpath("sample_file.txt") - - -async def transform( - data: _T, - expected_type: object, - use_async: bool, -) -> _T: - if use_async: - return await _async_transform(data, expected_type=expected_type) - - return _transform(data, expected_type=expected_type) - - -parametrize = pytest.mark.parametrize("use_async", [False, True], ids=["sync", "async"]) - - -class Foo1(TypedDict): - foo_bar: Annotated[str, PropertyInfo(alias="fooBar")] - - -@parametrize -@pytest.mark.asyncio -async def test_top_level_alias(use_async: bool) -> None: - assert await transform({"foo_bar": "hello"}, expected_type=Foo1, use_async=use_async) == {"fooBar": "hello"} - - -class Foo2(TypedDict): - bar: Bar2 - - -class Bar2(TypedDict): - this_thing: Annotated[int, PropertyInfo(alias="this__thing")] - baz: Annotated[Baz2, PropertyInfo(alias="Baz")] - - -class Baz2(TypedDict): - my_baz: Annotated[str, PropertyInfo(alias="myBaz")] - - -@parametrize -@pytest.mark.asyncio -async def test_recursive_typeddict(use_async: bool) -> None: - assert await transform({"bar": {"this_thing": 1}}, Foo2, use_async) == {"bar": {"this__thing": 1}} - assert await transform({"bar": {"baz": {"my_baz": "foo"}}}, Foo2, use_async) == {"bar": {"Baz": {"myBaz": "foo"}}} - - -class Foo3(TypedDict): - things: List[Bar3] - - -class Bar3(TypedDict): - my_field: Annotated[str, PropertyInfo(alias="myField")] - - -@parametrize -@pytest.mark.asyncio -async def test_list_of_typeddict(use_async: bool) -> None: - result = await transform({"things": [{"my_field": "foo"}, {"my_field": "foo2"}]}, Foo3, use_async) - assert result == {"things": [{"myField": "foo"}, {"myField": "foo2"}]} - - -class Foo4(TypedDict): - foo: Union[Bar4, Baz4] - - -class Bar4(TypedDict): - foo_bar: Annotated[str, PropertyInfo(alias="fooBar")] - - -class Baz4(TypedDict): - foo_baz: Annotated[str, PropertyInfo(alias="fooBaz")] - - -@parametrize -@pytest.mark.asyncio -async def test_union_of_typeddict(use_async: bool) -> None: - assert await transform({"foo": {"foo_bar": "bar"}}, Foo4, use_async) == {"foo": {"fooBar": "bar"}} - assert await transform({"foo": {"foo_baz": "baz"}}, Foo4, use_async) == {"foo": {"fooBaz": "baz"}} - assert await transform({"foo": {"foo_baz": "baz", "foo_bar": "bar"}}, Foo4, use_async) == { - "foo": {"fooBaz": "baz", "fooBar": "bar"} - } - - -class Foo5(TypedDict): - foo: Annotated[Union[Bar4, List[Baz4]], PropertyInfo(alias="FOO")] - - -class Bar5(TypedDict): - foo_bar: Annotated[str, PropertyInfo(alias="fooBar")] - - -class Baz5(TypedDict): - foo_baz: Annotated[str, PropertyInfo(alias="fooBaz")] - - -@parametrize -@pytest.mark.asyncio -async def test_union_of_list(use_async: bool) -> None: - assert await transform({"foo": {"foo_bar": "bar"}}, Foo5, use_async) == {"FOO": {"fooBar": "bar"}} - assert await transform( - { - "foo": [ - {"foo_baz": "baz"}, - {"foo_baz": "baz"}, - ] - }, - Foo5, - use_async, - ) == {"FOO": [{"fooBaz": "baz"}, {"fooBaz": "baz"}]} - - -class Foo6(TypedDict): - bar: Annotated[str, PropertyInfo(alias="Bar")] - - -@parametrize -@pytest.mark.asyncio -async def test_includes_unknown_keys(use_async: bool) -> None: - assert await transform({"bar": "bar", "baz_": {"FOO": 1}}, Foo6, use_async) == { - "Bar": "bar", - "baz_": {"FOO": 1}, - } - - -class Foo7(TypedDict): - bar: Annotated[List[Bar7], PropertyInfo(alias="bAr")] - foo: Bar7 - - -class Bar7(TypedDict): - foo: str - - -@parametrize -@pytest.mark.asyncio -async def test_ignores_invalid_input(use_async: bool) -> None: - assert await transform({"bar": ""}, Foo7, use_async) == {"bAr": ""} - assert await transform({"foo": ""}, Foo7, use_async) == {"foo": ""} - - -class DatetimeDict(TypedDict, total=False): - foo: Annotated[datetime, PropertyInfo(format="iso8601")] - - bar: Annotated[Optional[datetime], PropertyInfo(format="iso8601")] - - required: Required[Annotated[Optional[datetime], PropertyInfo(format="iso8601")]] - - list_: Required[Annotated[Optional[List[datetime]], PropertyInfo(format="iso8601")]] - - union: Annotated[Union[int, datetime], PropertyInfo(format="iso8601")] - - -class DateDict(TypedDict, total=False): - foo: Annotated[date, PropertyInfo(format="iso8601")] - - -class DatetimeModel(BaseModel): - foo: datetime - - -class DateModel(BaseModel): - foo: Optional[date] - - -@parametrize -@pytest.mark.asyncio -async def test_iso8601_format(use_async: bool) -> None: - dt = datetime.fromisoformat("2023-02-23T14:16:36.337692+00:00") - tz = "Z" if PYDANTIC_V2 else "+00:00" - assert await transform({"foo": dt}, DatetimeDict, use_async) == {"foo": "2023-02-23T14:16:36.337692+00:00"} # type: ignore[comparison-overlap] - assert await transform(DatetimeModel(foo=dt), Any, use_async) == {"foo": "2023-02-23T14:16:36.337692" + tz} # type: ignore[comparison-overlap] - - dt = dt.replace(tzinfo=None) - assert await transform({"foo": dt}, DatetimeDict, use_async) == {"foo": "2023-02-23T14:16:36.337692"} # type: ignore[comparison-overlap] - assert await transform(DatetimeModel(foo=dt), Any, use_async) == {"foo": "2023-02-23T14:16:36.337692"} # type: ignore[comparison-overlap] - - assert await transform({"foo": None}, DateDict, use_async) == {"foo": None} # type: ignore[comparison-overlap] - assert await transform(DateModel(foo=None), Any, use_async) == {"foo": None} # type: ignore - assert await transform({"foo": date.fromisoformat("2023-02-23")}, DateDict, use_async) == {"foo": "2023-02-23"} # type: ignore[comparison-overlap] - assert await transform(DateModel(foo=date.fromisoformat("2023-02-23")), DateDict, use_async) == { - "foo": "2023-02-23" - } # type: ignore[comparison-overlap] - - -@parametrize -@pytest.mark.asyncio -async def test_optional_iso8601_format(use_async: bool) -> None: - dt = datetime.fromisoformat("2023-02-23T14:16:36.337692+00:00") - assert await transform({"bar": dt}, DatetimeDict, use_async) == {"bar": "2023-02-23T14:16:36.337692+00:00"} # type: ignore[comparison-overlap] - - assert await transform({"bar": None}, DatetimeDict, use_async) == {"bar": None} - - -@parametrize -@pytest.mark.asyncio -async def test_required_iso8601_format(use_async: bool) -> None: - dt = datetime.fromisoformat("2023-02-23T14:16:36.337692+00:00") - assert await transform({"required": dt}, DatetimeDict, use_async) == { - "required": "2023-02-23T14:16:36.337692+00:00" - } # type: ignore[comparison-overlap] - - assert await transform({"required": None}, DatetimeDict, use_async) == {"required": None} - - -@parametrize -@pytest.mark.asyncio -async def test_union_datetime(use_async: bool) -> None: - dt = datetime.fromisoformat("2023-02-23T14:16:36.337692+00:00") - assert await transform({"union": dt}, DatetimeDict, use_async) == { # type: ignore[comparison-overlap] - "union": "2023-02-23T14:16:36.337692+00:00" - } - - assert await transform({"union": "foo"}, DatetimeDict, use_async) == {"union": "foo"} - - -@parametrize -@pytest.mark.asyncio -async def test_nested_list_iso6801_format(use_async: bool) -> None: - dt1 = datetime.fromisoformat("2023-02-23T14:16:36.337692+00:00") - dt2 = parse_datetime("2022-01-15T06:34:23Z") - assert await transform({"list_": [dt1, dt2]}, DatetimeDict, use_async) == { # type: ignore[comparison-overlap] - "list_": ["2023-02-23T14:16:36.337692+00:00", "2022-01-15T06:34:23+00:00"] - } - - -@parametrize -@pytest.mark.asyncio -async def test_datetime_custom_format(use_async: bool) -> None: - dt = parse_datetime("2022-01-15T06:34:23Z") - - result = await transform(dt, Annotated[datetime, PropertyInfo(format="custom", format_template="%H")], use_async) - assert result == "06" # type: ignore[comparison-overlap] - - -class DateDictWithRequiredAlias(TypedDict, total=False): - required_prop: Required[Annotated[date, PropertyInfo(format="iso8601", alias="prop")]] - - -@parametrize -@pytest.mark.asyncio -async def test_datetime_with_alias(use_async: bool) -> None: - assert await transform({"required_prop": None}, DateDictWithRequiredAlias, use_async) == {"prop": None} # type: ignore[comparison-overlap] - assert await transform( - {"required_prop": date.fromisoformat("2023-02-23")}, DateDictWithRequiredAlias, use_async - ) == {"prop": "2023-02-23"} # type: ignore[comparison-overlap] - - -class MyModel(BaseModel): - foo: str - - -@parametrize -@pytest.mark.asyncio -async def test_pydantic_model_to_dictionary(use_async: bool) -> None: - assert cast(Any, await transform(MyModel(foo="hi!"), Any, use_async)) == {"foo": "hi!"} - assert cast(Any, await transform(MyModel.construct(foo="hi!"), Any, use_async)) == {"foo": "hi!"} - - -@parametrize -@pytest.mark.asyncio -async def test_pydantic_empty_model(use_async: bool) -> None: - assert cast(Any, await transform(MyModel.construct(), Any, use_async)) == {} - - -@parametrize -@pytest.mark.asyncio -async def test_pydantic_unknown_field(use_async: bool) -> None: - assert cast(Any, await transform(MyModel.construct(my_untyped_field=True), Any, use_async)) == { - "my_untyped_field": True - } - - -@parametrize -@pytest.mark.asyncio -async def test_pydantic_mismatched_types(use_async: bool) -> None: - model = MyModel.construct(foo=True) - if PYDANTIC_V2: - with pytest.warns(UserWarning): - params = await transform(model, Any, use_async) - else: - params = await transform(model, Any, use_async) - assert cast(Any, params) == {"foo": True} - - -@parametrize -@pytest.mark.asyncio -async def test_pydantic_mismatched_object_type(use_async: bool) -> None: - model = MyModel.construct(foo=MyModel.construct(hello="world")) - if PYDANTIC_V2: - with pytest.warns(UserWarning): - params = await transform(model, Any, use_async) - else: - params = await transform(model, Any, use_async) - assert cast(Any, params) == {"foo": {"hello": "world"}} - - -class ModelNestedObjects(BaseModel): - nested: MyModel - - -@parametrize -@pytest.mark.asyncio -async def test_pydantic_nested_objects(use_async: bool) -> None: - model = ModelNestedObjects.construct(nested={"foo": "stainless"}) - assert isinstance(model.nested, MyModel) - assert cast(Any, await transform(model, Any, use_async)) == {"nested": {"foo": "stainless"}} - - -class ModelWithDefaultField(BaseModel): - foo: str - with_none_default: Union[str, None] = None - with_str_default: str = "foo" - - -@parametrize -@pytest.mark.asyncio -async def test_pydantic_default_field(use_async: bool) -> None: - # should be excluded when defaults are used - model = ModelWithDefaultField.construct() - assert model.with_none_default is None - assert model.with_str_default == "foo" - assert cast(Any, await transform(model, Any, use_async)) == {} - - # should be included when the default value is explicitly given - model = ModelWithDefaultField.construct(with_none_default=None, with_str_default="foo") - assert model.with_none_default is None - assert model.with_str_default == "foo" - assert cast(Any, await transform(model, Any, use_async)) == {"with_none_default": None, "with_str_default": "foo"} - - # should be included when a non-default value is explicitly given - model = ModelWithDefaultField.construct(with_none_default="bar", with_str_default="baz") - assert model.with_none_default == "bar" - assert model.with_str_default == "baz" - assert cast(Any, await transform(model, Any, use_async)) == {"with_none_default": "bar", "with_str_default": "baz"} - - -class TypedDictIterableUnion(TypedDict): - foo: Annotated[Union[Bar8, Iterable[Baz8]], PropertyInfo(alias="FOO")] - - -class Bar8(TypedDict): - foo_bar: Annotated[str, PropertyInfo(alias="fooBar")] - - -class Baz8(TypedDict): - foo_baz: Annotated[str, PropertyInfo(alias="fooBaz")] - - -@parametrize -@pytest.mark.asyncio -async def test_iterable_of_dictionaries(use_async: bool) -> None: - assert await transform({"foo": [{"foo_baz": "bar"}]}, TypedDictIterableUnion, use_async) == { - "FOO": [{"fooBaz": "bar"}] - } - assert cast(Any, await transform({"foo": ({"foo_baz": "bar"},)}, TypedDictIterableUnion, use_async)) == { - "FOO": [{"fooBaz": "bar"}] - } - - def my_iter() -> Iterable[Baz8]: - yield {"foo_baz": "hello"} - yield {"foo_baz": "world"} - - assert await transform({"foo": my_iter()}, TypedDictIterableUnion, use_async) == { - "FOO": [{"fooBaz": "hello"}, {"fooBaz": "world"}] - } - - -@parametrize -@pytest.mark.asyncio -async def test_dictionary_items(use_async: bool) -> None: - class DictItems(TypedDict): - foo_baz: Annotated[str, PropertyInfo(alias="fooBaz")] - - assert await transform({"foo": {"foo_baz": "bar"}}, Dict[str, DictItems], use_async) == {"foo": {"fooBaz": "bar"}} - - -class TypedDictIterableUnionStr(TypedDict): - foo: Annotated[Union[str, Iterable[Baz8]], PropertyInfo(alias="FOO")] - - -@parametrize -@pytest.mark.asyncio -async def test_iterable_union_str(use_async: bool) -> None: - assert await transform({"foo": "bar"}, TypedDictIterableUnionStr, use_async) == {"FOO": "bar"} - assert cast(Any, await transform(iter([{"foo_baz": "bar"}]), Union[str, Iterable[Baz8]], use_async)) == [ - {"fooBaz": "bar"} - ] - - -class TypedDictBase64Input(TypedDict): - foo: Annotated[Union[str, Base64FileInput], PropertyInfo(format="base64")] - - -@parametrize -@pytest.mark.asyncio -async def test_base64_file_input(use_async: bool) -> None: - # strings are left as-is - assert await transform({"foo": "bar"}, TypedDictBase64Input, use_async) == {"foo": "bar"} - - # pathlib.Path is automatically converted to base64 - assert await transform({"foo": SAMPLE_FILE_PATH}, TypedDictBase64Input, use_async) == { - "foo": "SGVsbG8sIHdvcmxkIQo=" - } # type: ignore[comparison-overlap] - - # io instances are automatically converted to base64 - assert await transform({"foo": io.StringIO("Hello, world!")}, TypedDictBase64Input, use_async) == { - "foo": "SGVsbG8sIHdvcmxkIQ==" - } # type: ignore[comparison-overlap] - assert await transform({"foo": io.BytesIO(b"Hello, world!")}, TypedDictBase64Input, use_async) == { - "foo": "SGVsbG8sIHdvcmxkIQ==" - } # type: ignore[comparison-overlap] - - -@parametrize -@pytest.mark.asyncio -async def test_transform_skipping(use_async: bool) -> None: - # lists of ints are left as-is - data = [1, 2, 3] - assert await transform(data, List[int], use_async) is data - - # iterables of ints are converted to a list - data = iter([1, 2, 3]) - assert await transform(data, Iterable[int], use_async) == [1, 2, 3] - - -@parametrize -@pytest.mark.asyncio -async def test_strips_notgiven(use_async: bool) -> None: - assert await transform({"foo_bar": "bar"}, Foo1, use_async) == {"fooBar": "bar"} - assert await transform({"foo_bar": NOT_GIVEN}, Foo1, use_async) == {} diff --git a/tests/test_utils/test_proxy.py b/tests/test_utils/test_proxy.py deleted file mode 100644 index 303ab41..0000000 --- a/tests/test_utils/test_proxy.py +++ /dev/null @@ -1,34 +0,0 @@ -import operator -from typing import Any -from typing_extensions import override - -from opencode_ai._utils import LazyProxy - - -class RecursiveLazyProxy(LazyProxy[Any]): - @override - def __load__(self) -> Any: - return self - - def __call__(self, *_args: Any, **_kwds: Any) -> Any: - raise RuntimeError("This should never be called!") - - -def test_recursive_proxy() -> None: - proxy = RecursiveLazyProxy() - assert repr(proxy) == "RecursiveLazyProxy" - assert str(proxy) == "RecursiveLazyProxy" - assert dir(proxy) == [] - assert type(proxy).__name__ == "RecursiveLazyProxy" - assert type(operator.attrgetter("name.foo.bar.baz")(proxy)).__name__ == "RecursiveLazyProxy" - - -def test_isinstance_does_not_error() -> None: - class AlwaysErrorProxy(LazyProxy[Any]): - @override - def __load__(self) -> Any: - raise RuntimeError("Mocking missing dependency") - - proxy = AlwaysErrorProxy() - assert not isinstance(proxy, dict) - assert isinstance(proxy, LazyProxy) diff --git a/tests/test_utils/test_typing.py b/tests/test_utils/test_typing.py deleted file mode 100644 index 47ff1f5..0000000 --- a/tests/test_utils/test_typing.py +++ /dev/null @@ -1,73 +0,0 @@ -from __future__ import annotations - -from typing import Generic, TypeVar, cast - -from opencode_ai._utils import extract_type_var_from_base - -_T = TypeVar("_T") -_T2 = TypeVar("_T2") -_T3 = TypeVar("_T3") - - -class BaseGeneric(Generic[_T]): ... - - -class SubclassGeneric(BaseGeneric[_T]): ... - - -class BaseGenericMultipleTypeArgs(Generic[_T, _T2, _T3]): ... - - -class SubclassGenericMultipleTypeArgs(BaseGenericMultipleTypeArgs[_T, _T2, _T3]): ... - - -class SubclassDifferentOrderGenericMultipleTypeArgs(BaseGenericMultipleTypeArgs[_T2, _T, _T3]): ... - - -def test_extract_type_var() -> None: - assert ( - extract_type_var_from_base( - BaseGeneric[int], - index=0, - generic_bases=cast("tuple[type, ...]", (BaseGeneric,)), - ) - == int - ) - - -def test_extract_type_var_generic_subclass() -> None: - assert ( - extract_type_var_from_base( - SubclassGeneric[int], - index=0, - generic_bases=cast("tuple[type, ...]", (BaseGeneric,)), - ) - == int - ) - - -def test_extract_type_var_multiple() -> None: - typ = BaseGenericMultipleTypeArgs[int, str, None] - - generic_bases = cast("tuple[type, ...]", (BaseGenericMultipleTypeArgs,)) - assert extract_type_var_from_base(typ, index=0, generic_bases=generic_bases) == int - assert extract_type_var_from_base(typ, index=1, generic_bases=generic_bases) == str - assert extract_type_var_from_base(typ, index=2, generic_bases=generic_bases) == type(None) - - -def test_extract_type_var_generic_subclass_multiple() -> None: - typ = SubclassGenericMultipleTypeArgs[int, str, None] - - generic_bases = cast("tuple[type, ...]", (BaseGenericMultipleTypeArgs,)) - assert extract_type_var_from_base(typ, index=0, generic_bases=generic_bases) == int - assert extract_type_var_from_base(typ, index=1, generic_bases=generic_bases) == str - assert extract_type_var_from_base(typ, index=2, generic_bases=generic_bases) == type(None) - - -def test_extract_type_var_generic_subclass_different_ordering_multiple() -> None: - typ = SubclassDifferentOrderGenericMultipleTypeArgs[int, str, None] - - generic_bases = cast("tuple[type, ...]", (BaseGenericMultipleTypeArgs,)) - assert extract_type_var_from_base(typ, index=0, generic_bases=generic_bases) == int - assert extract_type_var_from_base(typ, index=1, generic_bases=generic_bases) == str - assert extract_type_var_from_base(typ, index=2, generic_bases=generic_bases) == type(None) diff --git a/tests/utils.py b/tests/utils.py deleted file mode 100644 index 5842949..0000000 --- a/tests/utils.py +++ /dev/null @@ -1,159 +0,0 @@ -from __future__ import annotations - -import os -import inspect -import traceback -import contextlib -from typing import Any, TypeVar, Iterator, cast -from datetime import date, datetime -from typing_extensions import Literal, get_args, get_origin, assert_type - -from opencode_ai._types import Omit, NoneType -from opencode_ai._utils import ( - is_dict, - is_list, - is_list_type, - is_union_type, - extract_type_arg, - is_annotated_type, - is_type_alias_type, -) -from opencode_ai._compat import PYDANTIC_V2, field_outer_type, get_model_fields -from opencode_ai._models import BaseModel - -BaseModelT = TypeVar("BaseModelT", bound=BaseModel) - - -def assert_matches_model(model: type[BaseModelT], value: BaseModelT, *, path: list[str]) -> bool: - for name, field in get_model_fields(model).items(): - field_value = getattr(value, name) - if PYDANTIC_V2: - allow_none = False - else: - # in v1 nullability was structured differently - # https://docs.pydantic.dev/2.0/migration/#required-optional-and-nullable-fields - allow_none = getattr(field, "allow_none", False) - - assert_matches_type( - field_outer_type(field), - field_value, - path=[*path, name], - allow_none=allow_none, - ) - - return True - - -# Note: the `path` argument is only used to improve error messages when `--showlocals` is used -def assert_matches_type( - type_: Any, - value: object, - *, - path: list[str], - allow_none: bool = False, -) -> None: - if is_type_alias_type(type_): - type_ = type_.__value__ - - # unwrap `Annotated[T, ...]` -> `T` - if is_annotated_type(type_): - type_ = extract_type_arg(type_, 0) - - if allow_none and value is None: - return - - if type_ is None or type_ is NoneType: - assert value is None - return - - origin = get_origin(type_) or type_ - - if is_list_type(type_): - return _assert_list_type(type_, value) - - if origin == str: - assert isinstance(value, str) - elif origin == int: - assert isinstance(value, int) - elif origin == bool: - assert isinstance(value, bool) - elif origin == float: - assert isinstance(value, float) - elif origin == bytes: - assert isinstance(value, bytes) - elif origin == datetime: - assert isinstance(value, datetime) - elif origin == date: - assert isinstance(value, date) - elif origin == object: - # nothing to do here, the expected type is unknown - pass - elif origin == Literal: - assert value in get_args(type_) - elif origin == dict: - assert is_dict(value) - - args = get_args(type_) - key_type = args[0] - items_type = args[1] - - for key, item in value.items(): - assert_matches_type(key_type, key, path=[*path, ""]) - assert_matches_type(items_type, item, path=[*path, ""]) - elif is_union_type(type_): - variants = get_args(type_) - - try: - none_index = variants.index(type(None)) - except ValueError: - pass - else: - # special case Optional[T] for better error messages - if len(variants) == 2: - if value is None: - # valid - return - - return assert_matches_type(type_=variants[not none_index], value=value, path=path) - - for i, variant in enumerate(variants): - try: - assert_matches_type(variant, value, path=[*path, f"variant {i}"]) - return - except AssertionError: - traceback.print_exc() - continue - - raise AssertionError("Did not match any variants") - elif issubclass(origin, BaseModel): - assert isinstance(value, type_) - assert assert_matches_model(type_, cast(Any, value), path=path) - elif inspect.isclass(origin) and origin.__name__ == "HttpxBinaryResponseContent": - assert value.__class__.__name__ == "HttpxBinaryResponseContent" - else: - assert None, f"Unhandled field type: {type_}" - - -def _assert_list_type(type_: type[object], value: object) -> None: - assert is_list(value) - - inner_type = get_args(type_)[0] - for entry in value: - assert_type(inner_type, entry) # type: ignore - - -@contextlib.contextmanager -def update_env(**new_env: str | Omit) -> Iterator[None]: - old = os.environ.copy() - - try: - for name, value in new_env.items(): - if isinstance(value, Omit): - os.environ.pop(name, None) - else: - os.environ[name] = value - - yield None - finally: - os.environ.clear() - os.environ.update(old) From 979c43dbc7f72a03ec5c16c02b800cccca95a24e Mon Sep 17 00:00:00 2001 From: "stainless-app[bot]" <142633134+stainless-app[bot]@users.noreply.github.com> Date: Wed, 3 Sep 2025 17:15:28 +0000 Subject: [PATCH 02/20] feat(api): manual updates --- .devcontainer/Dockerfile | 9 + .devcontainer/devcontainer.json | 43 + .github/workflows/ci.yml | 98 + .github/workflows/publish-pypi.yml | 31 + .github/workflows/release-doctor.yml | 21 + .gitignore | 15 + .python-version | 1 + .release-please-manifest.json | 3 + .stats.yml | 4 + .vscode/settings.json | 3 + Brewfile | 2 + CONTRIBUTING.md | 128 ++ LICENSE | 7 + README.md | 400 +++- SECURITY.md | 27 + api.md | 225 ++ bin/check-release-environment | 21 + bin/publish-pypi | 6 + examples/.keep | 4 + mypy.ini | 50 + noxfile.py | 9 + pyproject.toml | 212 ++ release-please-config.json | 66 + requirements-dev.lock | 135 ++ requirements.lock | 72 + scripts/bootstrap | 19 + scripts/format | 8 + scripts/lint | 11 + scripts/mock | 41 + scripts/test | 61 + scripts/utils/ruffen-docs.py | 167 ++ scripts/utils/upload-artifact.sh | 27 + src/opencode_ai/__init__.py | 100 + src/opencode_ai/_base_client.py | 1995 ++++++++++++++++ src/opencode_ai/_client.py | 446 ++++ src/opencode_ai/_compat.py | 219 ++ src/opencode_ai/_constants.py | 14 + src/opencode_ai/_exceptions.py | 108 + src/opencode_ai/_files.py | 123 + src/opencode_ai/_models.py | 829 +++++++ src/opencode_ai/_qs.py | 150 ++ src/opencode_ai/_resource.py | 43 + src/opencode_ai/_response.py | 832 +++++++ src/opencode_ai/_streaming.py | 333 +++ src/opencode_ai/_types.py | 253 ++ src/opencode_ai/_utils/__init__.py | 58 + src/opencode_ai/_utils/_logs.py | 25 + src/opencode_ai/_utils/_proxy.py | 65 + src/opencode_ai/_utils/_reflection.py | 42 + src/opencode_ai/_utils/_resources_proxy.py | 24 + src/opencode_ai/_utils/_streams.py | 12 + src/opencode_ai/_utils/_sync.py | 86 + src/opencode_ai/_utils/_transform.py | 453 ++++ src/opencode_ai/_utils/_typing.py | 156 ++ src/opencode_ai/_utils/_utils.py | 422 ++++ src/opencode_ai/_version.py | 4 + src/opencode_ai/lib/.keep | 4 + src/opencode_ai/py.typed | 0 src/opencode_ai/resources/__init__.py | 159 ++ src/opencode_ai/resources/agent.py | 169 ++ src/opencode_ai/resources/app.py | 297 +++ src/opencode_ai/resources/command.py | 169 ++ src/opencode_ai/resources/config.py | 169 ++ src/opencode_ai/resources/event.py | 178 ++ src/opencode_ai/resources/file.py | 363 +++ src/opencode_ai/resources/find.py | 377 +++ src/opencode_ai/resources/path.py | 169 ++ src/opencode_ai/resources/project.py | 254 ++ src/opencode_ai/resources/session/__init__.py | 33 + .../resources/session/permissions.py | 189 ++ src/opencode_ai/resources/session/session.py | 1937 ++++++++++++++++ src/opencode_ai/resources/tui.py | 887 +++++++ src/opencode_ai/types/__init__.py | 123 + src/opencode_ai/types/agent.py | 48 + src/opencode_ai/types/agent_list_params.py | 11 + src/opencode_ai/types/agent_list_response.py | 10 + src/opencode_ai/types/agent_part.py | 32 + .../types/agent_part_input_param.py | 25 + src/opencode_ai/types/app_log_params.py | 24 + src/opencode_ai/types/app_log_response.py | 7 + src/opencode_ai/types/app_providers_params.py | 11 + .../types/app_providers_response.py | 14 + src/opencode_ai/types/assistant_message.py | 82 + src/opencode_ai/types/command.py | 19 + src/opencode_ai/types/command_list_params.py | 11 + .../types/command_list_response.py | 10 + src/opencode_ai/types/config.py | 557 +++++ src/opencode_ai/types/config_get_params.py | 11 + src/opencode_ai/types/event_list_params.py | 11 + src/opencode_ai/types/event_list_response.py | 229 ++ src/opencode_ai/types/file.py | 17 + src/opencode_ai/types/file_list_params.py | 13 + src/opencode_ai/types/file_list_response.py | 10 + src/opencode_ai/types/file_node.py | 17 + src/opencode_ai/types/file_part.py | 29 + .../types/file_part_input_param.py | 23 + src/opencode_ai/types/file_part_source.py | 12 + .../types/file_part_source_param.py | 13 + .../types/file_part_source_text.py | 13 + .../types/file_part_source_text_param.py | 15 + src/opencode_ai/types/file_read_params.py | 13 + src/opencode_ai/types/file_read_response.py | 13 + src/opencode_ai/types/file_source.py | 16 + src/opencode_ai/types/file_source_param.py | 17 + src/opencode_ai/types/file_status_params.py | 11 + src/opencode_ai/types/file_status_response.py | 10 + src/opencode_ai/types/find_files_params.py | 13 + src/opencode_ai/types/find_files_response.py | 8 + src/opencode_ai/types/find_symbols_params.py | 13 + .../types/find_symbols_response.py | 10 + src/opencode_ai/types/find_text_params.py | 13 + src/opencode_ai/types/find_text_response.py | 50 + src/opencode_ai/types/keybinds_config.py | 156 ++ src/opencode_ai/types/mcp_local_config.py | 22 + src/opencode_ai/types/mcp_remote_config.py | 22 + src/opencode_ai/types/message.py | 12 + src/opencode_ai/types/model.py | 45 + src/opencode_ai/types/part.py | 41 + src/opencode_ai/types/path.py | 15 + src/opencode_ai/types/path_get_params.py | 11 + src/opencode_ai/types/project.py | 24 + .../types/project_current_params.py | 11 + src/opencode_ai/types/project_list_params.py | 11 + .../types/project_list_response.py | 10 + src/opencode_ai/types/provider.py | 22 + src/opencode_ai/types/reasoning_part.py | 32 + src/opencode_ai/types/session/__init__.py | 8 + src/opencode_ai/types/session/permission.py | 33 + .../session/permission_respond_params.py | 15 + .../session/permission_respond_response.py | 7 + src/opencode_ai/types/session/session.py | 49 + src/opencode_ai/types/session_abort_params.py | 11 + .../types/session_abort_response.py | 7 + .../types/session_children_params.py | 11 + .../types/session_children_response.py | 10 + .../types/session_command_params.py | 23 + .../types/session_command_response.py | 15 + .../types/session_create_params.py | 17 + .../types/session_delete_params.py | 11 + .../types/session_delete_response.py | 7 + src/opencode_ai/types/session_get_params.py | 11 + src/opencode_ai/types/session_init_params.py | 19 + .../types/session_init_response.py | 7 + src/opencode_ai/types/session_list_params.py | 11 + .../types/session_list_response.py | 10 + .../types/session_message_params.py | 14 + .../types/session_message_response.py | 15 + .../types/session_messages_params.py | 11 + .../types/session_messages_response.py | 19 + .../types/session_prompt_params.py | 38 + .../types/session_prompt_response.py | 15 + .../types/session_revert_params.py | 17 + src/opencode_ai/types/session_share_params.py | 11 + src/opencode_ai/types/session_shell_params.py | 15 + .../types/session_summarize_params.py | 17 + .../types/session_summarize_response.py | 7 + .../types/session_unrevert_params.py | 11 + .../types/session_unshare_params.py | 11 + .../types/session_update_params.py | 13 + src/opencode_ai/types/shared/__init__.py | 5 + .../types/shared/message_aborted_error.py | 13 + .../types/shared/provider_auth_error.py | 21 + src/opencode_ai/types/shared/unknown_error.py | 17 + src/opencode_ai/types/snapshot_part.py | 21 + src/opencode_ai/types/step_finish_part.py | 39 + src/opencode_ai/types/step_start_part.py | 19 + src/opencode_ai/types/symbol.py | 37 + src/opencode_ai/types/symbol_source.py | 40 + src/opencode_ai/types/symbol_source_param.py | 41 + src/opencode_ai/types/text_part.py | 32 + .../types/text_part_input_param.py | 25 + src/opencode_ai/types/tool_part.py | 35 + src/opencode_ai/types/tool_state_completed.py | 28 + src/opencode_ai/types/tool_state_error.py | 26 + src/opencode_ai/types/tool_state_pending.py | 11 + src/opencode_ai/types/tool_state_running.py | 24 + .../types/tui_append_prompt_params.py | 13 + .../types/tui_append_prompt_response.py | 7 + .../types/tui_clear_prompt_params.py | 11 + .../types/tui_clear_prompt_response.py | 7 + .../types/tui_execute_command_params.py | 13 + .../types/tui_execute_command_response.py | 7 + src/opencode_ai/types/tui_open_help_params.py | 11 + .../types/tui_open_help_response.py | 7 + .../types/tui_open_models_params.py | 11 + .../types/tui_open_models_response.py | 7 + .../types/tui_open_sessions_params.py | 11 + .../types/tui_open_sessions_response.py | 7 + .../types/tui_open_themes_params.py | 11 + .../types/tui_open_themes_response.py | 7 + .../types/tui_show_toast_params.py | 17 + .../types/tui_show_toast_response.py | 7 + .../types/tui_submit_prompt_params.py | 11 + .../types/tui_submit_prompt_response.py | 7 + src/opencode_ai/types/user_message.py | 23 + tests/__init__.py | 1 + tests/api_resources/__init__.py | 1 + tests/api_resources/session/__init__.py | 1 + .../api_resources/session/test_permissions.py | 160 ++ tests/api_resources/test_agent.py | 96 + tests/api_resources/test_app.py | 200 ++ tests/api_resources/test_command.py | 96 + tests/api_resources/test_config.py | 96 + tests/api_resources/test_event.py | 92 + tests/api_resources/test_file.py | 272 +++ tests/api_resources/test_find.py | 286 +++ tests/api_resources/test_path.py | 96 + tests/api_resources/test_project.py | 168 ++ tests/api_resources/test_session.py | 2037 +++++++++++++++++ tests/api_resources/test_tui.py | 734 ++++++ tests/conftest.py | 80 + tests/sample_file.txt | 1 + tests/test_client.py | 1661 ++++++++++++++ tests/test_deepcopy.py | 58 + tests/test_extract_files.py | 64 + tests/test_files.py | 51 + tests/test_models.py | 963 ++++++++ tests/test_qs.py | 78 + tests/test_required_args.py | 111 + tests/test_response.py | 277 +++ tests/test_streaming.py | 248 ++ tests/test_transform.py | 453 ++++ tests/test_utils/test_proxy.py | 34 + tests/test_utils/test_typing.py | 73 + tests/utils.py | 167 ++ 225 files changed, 25869 insertions(+), 1 deletion(-) create mode 100644 .devcontainer/Dockerfile create mode 100644 .devcontainer/devcontainer.json create mode 100644 .github/workflows/ci.yml create mode 100644 .github/workflows/publish-pypi.yml create mode 100644 .github/workflows/release-doctor.yml create mode 100644 .gitignore create mode 100644 .python-version create mode 100644 .release-please-manifest.json create mode 100644 .stats.yml create mode 100644 .vscode/settings.json create mode 100644 Brewfile create mode 100644 CONTRIBUTING.md create mode 100644 LICENSE create mode 100644 SECURITY.md create mode 100644 api.md create mode 100644 bin/check-release-environment create mode 100644 bin/publish-pypi create mode 100644 examples/.keep create mode 100644 mypy.ini create mode 100644 noxfile.py create mode 100644 pyproject.toml create mode 100644 release-please-config.json create mode 100644 requirements-dev.lock create mode 100644 requirements.lock create mode 100755 scripts/bootstrap create mode 100755 scripts/format create mode 100755 scripts/lint create mode 100755 scripts/mock create mode 100755 scripts/test create mode 100644 scripts/utils/ruffen-docs.py create mode 100755 scripts/utils/upload-artifact.sh create mode 100644 src/opencode_ai/__init__.py create mode 100644 src/opencode_ai/_base_client.py create mode 100644 src/opencode_ai/_client.py create mode 100644 src/opencode_ai/_compat.py create mode 100644 src/opencode_ai/_constants.py create mode 100644 src/opencode_ai/_exceptions.py create mode 100644 src/opencode_ai/_files.py create mode 100644 src/opencode_ai/_models.py create mode 100644 src/opencode_ai/_qs.py create mode 100644 src/opencode_ai/_resource.py create mode 100644 src/opencode_ai/_response.py create mode 100644 src/opencode_ai/_streaming.py create mode 100644 src/opencode_ai/_types.py create mode 100644 src/opencode_ai/_utils/__init__.py create mode 100644 src/opencode_ai/_utils/_logs.py create mode 100644 src/opencode_ai/_utils/_proxy.py create mode 100644 src/opencode_ai/_utils/_reflection.py create mode 100644 src/opencode_ai/_utils/_resources_proxy.py create mode 100644 src/opencode_ai/_utils/_streams.py create mode 100644 src/opencode_ai/_utils/_sync.py create mode 100644 src/opencode_ai/_utils/_transform.py create mode 100644 src/opencode_ai/_utils/_typing.py create mode 100644 src/opencode_ai/_utils/_utils.py create mode 100644 src/opencode_ai/_version.py create mode 100644 src/opencode_ai/lib/.keep create mode 100644 src/opencode_ai/py.typed create mode 100644 src/opencode_ai/resources/__init__.py create mode 100644 src/opencode_ai/resources/agent.py create mode 100644 src/opencode_ai/resources/app.py create mode 100644 src/opencode_ai/resources/command.py create mode 100644 src/opencode_ai/resources/config.py create mode 100644 src/opencode_ai/resources/event.py create mode 100644 src/opencode_ai/resources/file.py create mode 100644 src/opencode_ai/resources/find.py create mode 100644 src/opencode_ai/resources/path.py create mode 100644 src/opencode_ai/resources/project.py create mode 100644 src/opencode_ai/resources/session/__init__.py create mode 100644 src/opencode_ai/resources/session/permissions.py create mode 100644 src/opencode_ai/resources/session/session.py create mode 100644 src/opencode_ai/resources/tui.py create mode 100644 src/opencode_ai/types/__init__.py create mode 100644 src/opencode_ai/types/agent.py create mode 100644 src/opencode_ai/types/agent_list_params.py create mode 100644 src/opencode_ai/types/agent_list_response.py create mode 100644 src/opencode_ai/types/agent_part.py create mode 100644 src/opencode_ai/types/agent_part_input_param.py create mode 100644 src/opencode_ai/types/app_log_params.py create mode 100644 src/opencode_ai/types/app_log_response.py create mode 100644 src/opencode_ai/types/app_providers_params.py create mode 100644 src/opencode_ai/types/app_providers_response.py create mode 100644 src/opencode_ai/types/assistant_message.py create mode 100644 src/opencode_ai/types/command.py create mode 100644 src/opencode_ai/types/command_list_params.py create mode 100644 src/opencode_ai/types/command_list_response.py create mode 100644 src/opencode_ai/types/config.py create mode 100644 src/opencode_ai/types/config_get_params.py create mode 100644 src/opencode_ai/types/event_list_params.py create mode 100644 src/opencode_ai/types/event_list_response.py create mode 100644 src/opencode_ai/types/file.py create mode 100644 src/opencode_ai/types/file_list_params.py create mode 100644 src/opencode_ai/types/file_list_response.py create mode 100644 src/opencode_ai/types/file_node.py create mode 100644 src/opencode_ai/types/file_part.py create mode 100644 src/opencode_ai/types/file_part_input_param.py create mode 100644 src/opencode_ai/types/file_part_source.py create mode 100644 src/opencode_ai/types/file_part_source_param.py create mode 100644 src/opencode_ai/types/file_part_source_text.py create mode 100644 src/opencode_ai/types/file_part_source_text_param.py create mode 100644 src/opencode_ai/types/file_read_params.py create mode 100644 src/opencode_ai/types/file_read_response.py create mode 100644 src/opencode_ai/types/file_source.py create mode 100644 src/opencode_ai/types/file_source_param.py create mode 100644 src/opencode_ai/types/file_status_params.py create mode 100644 src/opencode_ai/types/file_status_response.py create mode 100644 src/opencode_ai/types/find_files_params.py create mode 100644 src/opencode_ai/types/find_files_response.py create mode 100644 src/opencode_ai/types/find_symbols_params.py create mode 100644 src/opencode_ai/types/find_symbols_response.py create mode 100644 src/opencode_ai/types/find_text_params.py create mode 100644 src/opencode_ai/types/find_text_response.py create mode 100644 src/opencode_ai/types/keybinds_config.py create mode 100644 src/opencode_ai/types/mcp_local_config.py create mode 100644 src/opencode_ai/types/mcp_remote_config.py create mode 100644 src/opencode_ai/types/message.py create mode 100644 src/opencode_ai/types/model.py create mode 100644 src/opencode_ai/types/part.py create mode 100644 src/opencode_ai/types/path.py create mode 100644 src/opencode_ai/types/path_get_params.py create mode 100644 src/opencode_ai/types/project.py create mode 100644 src/opencode_ai/types/project_current_params.py create mode 100644 src/opencode_ai/types/project_list_params.py create mode 100644 src/opencode_ai/types/project_list_response.py create mode 100644 src/opencode_ai/types/provider.py create mode 100644 src/opencode_ai/types/reasoning_part.py create mode 100644 src/opencode_ai/types/session/__init__.py create mode 100644 src/opencode_ai/types/session/permission.py create mode 100644 src/opencode_ai/types/session/permission_respond_params.py create mode 100644 src/opencode_ai/types/session/permission_respond_response.py create mode 100644 src/opencode_ai/types/session/session.py create mode 100644 src/opencode_ai/types/session_abort_params.py create mode 100644 src/opencode_ai/types/session_abort_response.py create mode 100644 src/opencode_ai/types/session_children_params.py create mode 100644 src/opencode_ai/types/session_children_response.py create mode 100644 src/opencode_ai/types/session_command_params.py create mode 100644 src/opencode_ai/types/session_command_response.py create mode 100644 src/opencode_ai/types/session_create_params.py create mode 100644 src/opencode_ai/types/session_delete_params.py create mode 100644 src/opencode_ai/types/session_delete_response.py create mode 100644 src/opencode_ai/types/session_get_params.py create mode 100644 src/opencode_ai/types/session_init_params.py create mode 100644 src/opencode_ai/types/session_init_response.py create mode 100644 src/opencode_ai/types/session_list_params.py create mode 100644 src/opencode_ai/types/session_list_response.py create mode 100644 src/opencode_ai/types/session_message_params.py create mode 100644 src/opencode_ai/types/session_message_response.py create mode 100644 src/opencode_ai/types/session_messages_params.py create mode 100644 src/opencode_ai/types/session_messages_response.py create mode 100644 src/opencode_ai/types/session_prompt_params.py create mode 100644 src/opencode_ai/types/session_prompt_response.py create mode 100644 src/opencode_ai/types/session_revert_params.py create mode 100644 src/opencode_ai/types/session_share_params.py create mode 100644 src/opencode_ai/types/session_shell_params.py create mode 100644 src/opencode_ai/types/session_summarize_params.py create mode 100644 src/opencode_ai/types/session_summarize_response.py create mode 100644 src/opencode_ai/types/session_unrevert_params.py create mode 100644 src/opencode_ai/types/session_unshare_params.py create mode 100644 src/opencode_ai/types/session_update_params.py create mode 100644 src/opencode_ai/types/shared/__init__.py create mode 100644 src/opencode_ai/types/shared/message_aborted_error.py create mode 100644 src/opencode_ai/types/shared/provider_auth_error.py create mode 100644 src/opencode_ai/types/shared/unknown_error.py create mode 100644 src/opencode_ai/types/snapshot_part.py create mode 100644 src/opencode_ai/types/step_finish_part.py create mode 100644 src/opencode_ai/types/step_start_part.py create mode 100644 src/opencode_ai/types/symbol.py create mode 100644 src/opencode_ai/types/symbol_source.py create mode 100644 src/opencode_ai/types/symbol_source_param.py create mode 100644 src/opencode_ai/types/text_part.py create mode 100644 src/opencode_ai/types/text_part_input_param.py create mode 100644 src/opencode_ai/types/tool_part.py create mode 100644 src/opencode_ai/types/tool_state_completed.py create mode 100644 src/opencode_ai/types/tool_state_error.py create mode 100644 src/opencode_ai/types/tool_state_pending.py create mode 100644 src/opencode_ai/types/tool_state_running.py create mode 100644 src/opencode_ai/types/tui_append_prompt_params.py create mode 100644 src/opencode_ai/types/tui_append_prompt_response.py create mode 100644 src/opencode_ai/types/tui_clear_prompt_params.py create mode 100644 src/opencode_ai/types/tui_clear_prompt_response.py create mode 100644 src/opencode_ai/types/tui_execute_command_params.py create mode 100644 src/opencode_ai/types/tui_execute_command_response.py create mode 100644 src/opencode_ai/types/tui_open_help_params.py create mode 100644 src/opencode_ai/types/tui_open_help_response.py create mode 100644 src/opencode_ai/types/tui_open_models_params.py create mode 100644 src/opencode_ai/types/tui_open_models_response.py create mode 100644 src/opencode_ai/types/tui_open_sessions_params.py create mode 100644 src/opencode_ai/types/tui_open_sessions_response.py create mode 100644 src/opencode_ai/types/tui_open_themes_params.py create mode 100644 src/opencode_ai/types/tui_open_themes_response.py create mode 100644 src/opencode_ai/types/tui_show_toast_params.py create mode 100644 src/opencode_ai/types/tui_show_toast_response.py create mode 100644 src/opencode_ai/types/tui_submit_prompt_params.py create mode 100644 src/opencode_ai/types/tui_submit_prompt_response.py create mode 100644 src/opencode_ai/types/user_message.py create mode 100644 tests/__init__.py create mode 100644 tests/api_resources/__init__.py create mode 100644 tests/api_resources/session/__init__.py create mode 100644 tests/api_resources/session/test_permissions.py create mode 100644 tests/api_resources/test_agent.py create mode 100644 tests/api_resources/test_app.py create mode 100644 tests/api_resources/test_command.py create mode 100644 tests/api_resources/test_config.py create mode 100644 tests/api_resources/test_event.py create mode 100644 tests/api_resources/test_file.py create mode 100644 tests/api_resources/test_find.py create mode 100644 tests/api_resources/test_path.py create mode 100644 tests/api_resources/test_project.py create mode 100644 tests/api_resources/test_session.py create mode 100644 tests/api_resources/test_tui.py create mode 100644 tests/conftest.py create mode 100644 tests/sample_file.txt create mode 100644 tests/test_client.py create mode 100644 tests/test_deepcopy.py create mode 100644 tests/test_extract_files.py create mode 100644 tests/test_files.py create mode 100644 tests/test_models.py create mode 100644 tests/test_qs.py create mode 100644 tests/test_required_args.py create mode 100644 tests/test_response.py create mode 100644 tests/test_streaming.py create mode 100644 tests/test_transform.py create mode 100644 tests/test_utils/test_proxy.py create mode 100644 tests/test_utils/test_typing.py create mode 100644 tests/utils.py diff --git a/.devcontainer/Dockerfile b/.devcontainer/Dockerfile new file mode 100644 index 0000000..ff261ba --- /dev/null +++ b/.devcontainer/Dockerfile @@ -0,0 +1,9 @@ +ARG VARIANT="3.9" +FROM mcr.microsoft.com/vscode/devcontainers/python:0-${VARIANT} + +USER vscode + +RUN curl -sSf https://rye.astral.sh/get | RYE_VERSION="0.44.0" RYE_INSTALL_OPTION="--yes" bash +ENV PATH=/home/vscode/.rye/shims:$PATH + +RUN echo "[[ -d .venv ]] && source .venv/bin/activate || export PATH=\$PATH" >> /home/vscode/.bashrc diff --git a/.devcontainer/devcontainer.json b/.devcontainer/devcontainer.json new file mode 100644 index 0000000..c17fdc1 --- /dev/null +++ b/.devcontainer/devcontainer.json @@ -0,0 +1,43 @@ +// For format details, see https://aka.ms/devcontainer.json. For config options, see the +// README at: https://github.com/devcontainers/templates/tree/main/src/debian +{ + "name": "Debian", + "build": { + "dockerfile": "Dockerfile", + "context": ".." + }, + + "postStartCommand": "rye sync --all-features", + + "customizations": { + "vscode": { + "extensions": [ + "ms-python.python" + ], + "settings": { + "terminal.integrated.shell.linux": "/bin/bash", + "python.pythonPath": ".venv/bin/python", + "python.defaultInterpreterPath": ".venv/bin/python", + "python.typeChecking": "basic", + "terminal.integrated.env.linux": { + "PATH": "/home/vscode/.rye/shims:${env:PATH}" + } + } + } + }, + "features": { + "ghcr.io/devcontainers/features/node:1": {} + } + + // Features to add to the dev container. More info: https://containers.dev/features. + // "features": {}, + + // Use 'forwardPorts' to make a list of ports inside the container available locally. + // "forwardPorts": [], + + // Configure tool-specific properties. + // "customizations": {}, + + // Uncomment to connect as root instead. More info: https://aka.ms/dev-containers-non-root. + // "remoteUser": "root" +} diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..04808c6 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,98 @@ +name: CI +on: + push: + branches-ignore: + - 'generated' + - 'codegen/**' + - 'integrated/**' + - 'stl-preview-head/**' + - 'stl-preview-base/**' + pull_request: + branches-ignore: + - 'stl-preview-head/**' + - 'stl-preview-base/**' + +jobs: + lint: + timeout-minutes: 10 + name: lint + runs-on: ${{ github.repository == 'stainless-sdks/opencode-python' && 'depot-ubuntu-24.04' || 'ubuntu-latest' }} + if: github.event_name == 'push' || github.event.pull_request.head.repo.fork + steps: + - uses: actions/checkout@v4 + + - name: Install Rye + run: | + curl -sSf https://rye.astral.sh/get | bash + echo "$HOME/.rye/shims" >> $GITHUB_PATH + env: + RYE_VERSION: '0.44.0' + RYE_INSTALL_OPTION: '--yes' + + - name: Install dependencies + run: rye sync --all-features + + - name: Run lints + run: ./scripts/lint + + build: + if: github.event_name == 'push' || github.event.pull_request.head.repo.fork + timeout-minutes: 10 + name: build + permissions: + contents: read + id-token: write + runs-on: ${{ github.repository == 'stainless-sdks/opencode-python' && 'depot-ubuntu-24.04' || 'ubuntu-latest' }} + steps: + - uses: actions/checkout@v4 + + - name: Install Rye + run: | + curl -sSf https://rye.astral.sh/get | bash + echo "$HOME/.rye/shims" >> $GITHUB_PATH + env: + RYE_VERSION: '0.44.0' + RYE_INSTALL_OPTION: '--yes' + + - name: Install dependencies + run: rye sync --all-features + + - name: Run build + run: rye build + + - name: Get GitHub OIDC Token + if: github.repository == 'stainless-sdks/opencode-python' + id: github-oidc + uses: actions/github-script@v6 + with: + script: core.setOutput('github_token', await core.getIDToken()); + + - name: Upload tarball + if: github.repository == 'stainless-sdks/opencode-python' + env: + URL: https://pkg.stainless.com/s + AUTH: ${{ steps.github-oidc.outputs.github_token }} + SHA: ${{ github.sha }} + run: ./scripts/utils/upload-artifact.sh + + test: + timeout-minutes: 10 + name: test + runs-on: ${{ github.repository == 'stainless-sdks/opencode-python' && 'depot-ubuntu-24.04' || 'ubuntu-latest' }} + if: github.event_name == 'push' || github.event.pull_request.head.repo.fork + steps: + - uses: actions/checkout@v4 + + - name: Install Rye + run: | + curl -sSf https://rye.astral.sh/get | bash + echo "$HOME/.rye/shims" >> $GITHUB_PATH + env: + RYE_VERSION: '0.44.0' + RYE_INSTALL_OPTION: '--yes' + + - name: Bootstrap + run: ./scripts/bootstrap + + - name: Run tests + run: ./scripts/test diff --git a/.github/workflows/publish-pypi.yml b/.github/workflows/publish-pypi.yml new file mode 100644 index 0000000..c9b531e --- /dev/null +++ b/.github/workflows/publish-pypi.yml @@ -0,0 +1,31 @@ +# This workflow is triggered when a GitHub release is created. +# It can also be run manually to re-publish to PyPI in case it failed for some reason. +# You can run this workflow by navigating to https://www.github.com/sst/opencode-sdk-python/actions/workflows/publish-pypi.yml +name: Publish PyPI +on: + workflow_dispatch: + + release: + types: [published] + +jobs: + publish: + name: publish + runs-on: ubuntu-latest + + steps: + - uses: actions/checkout@v4 + + - name: Install Rye + run: | + curl -sSf https://rye.astral.sh/get | bash + echo "$HOME/.rye/shims" >> $GITHUB_PATH + env: + RYE_VERSION: '0.44.0' + RYE_INSTALL_OPTION: '--yes' + + - name: Publish to PyPI + run: | + bash ./bin/publish-pypi + env: + PYPI_TOKEN: ${{ secrets.OPENCODE_PYPI_TOKEN || secrets.PYPI_TOKEN }} diff --git a/.github/workflows/release-doctor.yml b/.github/workflows/release-doctor.yml new file mode 100644 index 0000000..0e12a2b --- /dev/null +++ b/.github/workflows/release-doctor.yml @@ -0,0 +1,21 @@ +name: Release Doctor +on: + pull_request: + branches: + - main + workflow_dispatch: + +jobs: + release_doctor: + name: release doctor + runs-on: ubuntu-latest + if: github.repository == 'sst/opencode-sdk-python' && (github.event_name == 'push' || github.event_name == 'workflow_dispatch' || startsWith(github.head_ref, 'release-please') || github.head_ref == 'next') + + steps: + - uses: actions/checkout@v4 + + - name: Check release environment + run: | + bash ./bin/check-release-environment + env: + PYPI_TOKEN: ${{ secrets.OPENCODE_PYPI_TOKEN || secrets.PYPI_TOKEN }} diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..95ceb18 --- /dev/null +++ b/.gitignore @@ -0,0 +1,15 @@ +.prism.log +_dev + +__pycache__ +.mypy_cache + +dist + +.venv +.idea + +.env +.envrc +codegen.log +Brewfile.lock.json diff --git a/.python-version b/.python-version new file mode 100644 index 0000000..43077b2 --- /dev/null +++ b/.python-version @@ -0,0 +1 @@ +3.9.18 diff --git a/.release-please-manifest.json b/.release-please-manifest.json new file mode 100644 index 0000000..a696b6a --- /dev/null +++ b/.release-please-manifest.json @@ -0,0 +1,3 @@ +{ + ".": "0.1.0-alpha.36" +} \ No newline at end of file diff --git a/.stats.yml b/.stats.yml new file mode 100644 index 0000000..79ec5d8 --- /dev/null +++ b/.stats.yml @@ -0,0 +1,4 @@ +configured_endpoints: 43 +openapi_spec_url: https://storage.googleapis.com/stainless-sdk-openapi-specs/opencode%2Fopencode-97b61518d8666ea7cb310af04248e00bcf8dc9753ba3c7e84471df72b3232004.yml +openapi_spec_hash: a3500531973ad999c350b87c21aa3ab8 +config_hash: 026ef000d34bf2f930e7b41e77d2d3ff diff --git a/.vscode/settings.json b/.vscode/settings.json new file mode 100644 index 0000000..5b01030 --- /dev/null +++ b/.vscode/settings.json @@ -0,0 +1,3 @@ +{ + "python.analysis.importFormat": "relative", +} diff --git a/Brewfile b/Brewfile new file mode 100644 index 0000000..492ca37 --- /dev/null +++ b/Brewfile @@ -0,0 +1,2 @@ +brew "rye" + diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md new file mode 100644 index 0000000..dcef083 --- /dev/null +++ b/CONTRIBUTING.md @@ -0,0 +1,128 @@ +## Setting up the environment + +### With Rye + +We use [Rye](https://rye.astral.sh/) to manage dependencies because it will automatically provision a Python environment with the expected Python version. To set it up, run: + +```sh +$ ./scripts/bootstrap +``` + +Or [install Rye manually](https://rye.astral.sh/guide/installation/) and run: + +```sh +$ rye sync --all-features +``` + +You can then run scripts using `rye run python script.py` or by activating the virtual environment: + +```sh +# Activate the virtual environment - https://docs.python.org/3/library/venv.html#how-venvs-work +$ source .venv/bin/activate + +# now you can omit the `rye run` prefix +$ python script.py +``` + +### Without Rye + +Alternatively if you don't want to install `Rye`, you can stick with the standard `pip` setup by ensuring you have the Python version specified in `.python-version`, create a virtual environment however you desire and then install dependencies using this command: + +```sh +$ pip install -r requirements-dev.lock +``` + +## Modifying/Adding code + +Most of the SDK is generated code. Modifications to code will be persisted between generations, but may +result in merge conflicts between manual patches and changes from the generator. The generator will never +modify the contents of the `src/opencode_ai/lib/` and `examples/` directories. + +## Adding and running examples + +All files in the `examples/` directory are not modified by the generator and can be freely edited or added to. + +```py +# add an example to examples/.py + +#!/usr/bin/env -S rye run python +… +``` + +```sh +$ chmod +x examples/.py +# run the example against your api +$ ./examples/.py +``` + +## Using the repository from source + +If you’d like to use the repository from source, you can either install from git or link to a cloned repository: + +To install via git: + +```sh +$ pip install git+ssh://git@github.com/sst/opencode-sdk-python.git +``` + +Alternatively, you can build from source and install the wheel file: + +Building this package will create two files in the `dist/` directory, a `.tar.gz` containing the source files and a `.whl` that can be used to install the package efficiently. + +To create a distributable version of the library, all you have to do is run this command: + +```sh +$ rye build +# or +$ python -m build +``` + +Then to install: + +```sh +$ pip install ./path-to-wheel-file.whl +``` + +## Running tests + +Most tests require you to [set up a mock server](https://github.com/stoplightio/prism) against the OpenAPI spec to run the tests. + +```sh +# you will need npm installed +$ npx prism mock path/to/your/openapi.yml +``` + +```sh +$ ./scripts/test +``` + +## Linting and formatting + +This repository uses [ruff](https://github.com/astral-sh/ruff) and +[black](https://github.com/psf/black) to format the code in the repository. + +To lint: + +```sh +$ ./scripts/lint +``` + +To format and fix all ruff issues automatically: + +```sh +$ ./scripts/format +``` + +## Publishing and releases + +Changes made to this repository via the automated release PR pipeline should publish to PyPI automatically. If +the changes aren't made through the automated pipeline, you may want to make releases manually. + +### Publish with a GitHub workflow + +You can release to package managers by using [the `Publish PyPI` GitHub action](https://www.github.com/sst/opencode-sdk-python/actions/workflows/publish-pypi.yml). This requires a setup organization or repository secret to be set up. + +### Publish manually + +If you need to manually release a package, you can run the `bin/publish-pypi` script with a `PYPI_TOKEN` set on +the environment. diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..821edeb --- /dev/null +++ b/LICENSE @@ -0,0 +1,7 @@ +Copyright 2025 opencode + +Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. diff --git a/README.md b/README.md index e1ba7c9..dbac892 100644 --- a/README.md +++ b/README.md @@ -1 +1,399 @@ -# opencode-python \ No newline at end of file +# Opencode Python API library + + +[![PyPI version](https://img.shields.io/pypi/v/opencode-ai.svg?label=pypi%20(stable))](https://pypi.org/project/opencode-ai/) + +The Opencode Python library provides convenient access to the Opencode REST API from any Python 3.8+ +application. The library includes type definitions for all request params and response fields, +and offers both synchronous and asynchronous clients powered by [httpx](https://github.com/encode/httpx). + +It is generated with [Stainless](https://www.stainless.com/). + +## Documentation + +The REST API documentation can be found on [opencode.ai](https://opencode.ai/docs). The full API of this library can be found in [api.md](api.md). + +## Installation + +```sh +# install from PyPI +pip install --pre opencode-ai +``` + +## Usage + +The full API of this library can be found in [api.md](api.md). + +```python +from opencode_ai import Opencode + +client = Opencode() + +sessions = client.session.list() +``` + +## Async usage + +Simply import `AsyncOpencode` instead of `Opencode` and use `await` with each API call: + +```python +import asyncio +from opencode_ai import AsyncOpencode + +client = AsyncOpencode() + + +async def main() -> None: + sessions = await client.session.list() + + +asyncio.run(main()) +``` + +Functionality between the synchronous and asynchronous clients is otherwise identical. + +### With aiohttp + +By default, the async client uses `httpx` for HTTP requests. However, for improved concurrency performance you may also use `aiohttp` as the HTTP backend. + +You can enable this by installing `aiohttp`: + +```sh +# install from PyPI +pip install --pre opencode-ai[aiohttp] +``` + +Then you can enable it by instantiating the client with `http_client=DefaultAioHttpClient()`: + +```python +import asyncio +from opencode_ai import DefaultAioHttpClient +from opencode_ai import AsyncOpencode + + +async def main() -> None: + async with AsyncOpencode( + http_client=DefaultAioHttpClient(), + ) as client: + sessions = await client.session.list() + + +asyncio.run(main()) +``` + +## Streaming responses + +We provide support for streaming responses using Server Side Events (SSE). + +```python +from opencode_ai import Opencode + +client = Opencode() + +stream = client.event.list() +for events in stream: + print(events) +``` + +The async client uses the exact same interface. + +```python +from opencode_ai import AsyncOpencode + +client = AsyncOpencode() + +stream = await client.event.list() +async for events in stream: + print(events) +``` + +## Using types + +Nested request parameters are [TypedDicts](https://docs.python.org/3/library/typing.html#typing.TypedDict). Responses are [Pydantic models](https://docs.pydantic.dev) which also provide helper methods for things like: + +- Serializing back into JSON, `model.to_json()` +- Converting to a dictionary, `model.to_dict()` + +Typed requests and responses provide autocomplete and documentation within your editor. If you would like to see type errors in VS Code to help catch bugs earlier, set `python.analysis.typeCheckingMode` to `basic`. + +## Nested params + +Nested parameters are dictionaries, typed using `TypedDict`, for example: + +```python +from opencode_ai import Opencode + +client = Opencode() + +response = client.session.prompt( + id="id", + parts=[ + { + "text": "text", + "type": "text", + } + ], + model={ + "model_id": "modelID", + "provider_id": "providerID", + }, +) +print(response.model) +``` + +## Handling errors + +When the library is unable to connect to the API (for example, due to network connection problems or a timeout), a subclass of `opencode_ai.APIConnectionError` is raised. + +When the API returns a non-success status code (that is, 4xx or 5xx +response), a subclass of `opencode_ai.APIStatusError` is raised, containing `status_code` and `response` properties. + +All errors inherit from `opencode_ai.APIError`. + +```python +import opencode_ai +from opencode_ai import Opencode + +client = Opencode() + +try: + client.session.list() +except opencode_ai.APIConnectionError as e: + print("The server could not be reached") + print(e.__cause__) # an underlying Exception, likely raised within httpx. +except opencode_ai.RateLimitError as e: + print("A 429 status code was received; we should back off a bit.") +except opencode_ai.APIStatusError as e: + print("Another non-200-range status code was received") + print(e.status_code) + print(e.response) +``` + +Error codes are as follows: + +| Status Code | Error Type | +| ----------- | -------------------------- | +| 400 | `BadRequestError` | +| 401 | `AuthenticationError` | +| 403 | `PermissionDeniedError` | +| 404 | `NotFoundError` | +| 422 | `UnprocessableEntityError` | +| 429 | `RateLimitError` | +| >=500 | `InternalServerError` | +| N/A | `APIConnectionError` | + +### Retries + +Certain errors are automatically retried 2 times by default, with a short exponential backoff. +Connection errors (for example, due to a network connectivity problem), 408 Request Timeout, 409 Conflict, +429 Rate Limit, and >=500 Internal errors are all retried by default. + +You can use the `max_retries` option to configure or disable retry settings: + +```python +from opencode_ai import Opencode + +# Configure the default for all requests: +client = Opencode( + # default is 2 + max_retries=0, +) + +# Or, configure per-request: +client.with_options(max_retries=5).session.list() +``` + +### Timeouts + +By default requests time out after 1 minute. You can configure this with a `timeout` option, +which accepts a float or an [`httpx.Timeout`](https://www.python-httpx.org/advanced/timeouts/#fine-tuning-the-configuration) object: + +```python +from opencode_ai import Opencode + +# Configure the default for all requests: +client = Opencode( + # 20 seconds (default is 1 minute) + timeout=20.0, +) + +# More granular control: +client = Opencode( + timeout=httpx.Timeout(60.0, read=5.0, write=10.0, connect=2.0), +) + +# Override per-request: +client.with_options(timeout=5.0).session.list() +``` + +On timeout, an `APITimeoutError` is thrown. + +Note that requests that time out are [retried twice by default](#retries). + +## Advanced + +### Logging + +We use the standard library [`logging`](https://docs.python.org/3/library/logging.html) module. + +You can enable logging by setting the environment variable `OPENCODE_LOG` to `info`. + +```shell +$ export OPENCODE_LOG=info +``` + +Or to `debug` for more verbose logging. + +### How to tell whether `None` means `null` or missing + +In an API response, a field may be explicitly `null`, or missing entirely; in either case, its value is `None` in this library. You can differentiate the two cases with `.model_fields_set`: + +```py +if response.my_field is None: + if 'my_field' not in response.model_fields_set: + print('Got json like {}, without a "my_field" key present at all.') + else: + print('Got json like {"my_field": null}.') +``` + +### Accessing raw response data (e.g. headers) + +The "raw" Response object can be accessed by prefixing `.with_raw_response.` to any HTTP method call, e.g., + +```py +from opencode_ai import Opencode + +client = Opencode() +response = client.session.with_raw_response.list() +print(response.headers.get('X-My-Header')) + +session = response.parse() # get the object that `session.list()` would have returned +print(session) +``` + +These methods return an [`APIResponse`](https://github.com/sst/opencode-sdk-python/tree/main/src/opencode_ai/_response.py) object. + +The async client returns an [`AsyncAPIResponse`](https://github.com/sst/opencode-sdk-python/tree/main/src/opencode_ai/_response.py) with the same structure, the only difference being `await`able methods for reading the response content. + +#### `.with_streaming_response` + +The above interface eagerly reads the full response body when you make the request, which may not always be what you want. + +To stream the response body, use `.with_streaming_response` instead, which requires a context manager and only reads the response body once you call `.read()`, `.text()`, `.json()`, `.iter_bytes()`, `.iter_text()`, `.iter_lines()` or `.parse()`. In the async client, these are async methods. + +```python +with client.session.with_streaming_response.list() as response: + print(response.headers.get("X-My-Header")) + + for line in response.iter_lines(): + print(line) +``` + +The context manager is required so that the response will reliably be closed. + +### Making custom/undocumented requests + +This library is typed for convenient access to the documented API. + +If you need to access undocumented endpoints, params, or response properties, the library can still be used. + +#### Undocumented endpoints + +To make requests to undocumented endpoints, you can make requests using `client.get`, `client.post`, and other +http verbs. Options on the client will be respected (such as retries) when making this request. + +```py +import httpx + +response = client.post( + "/foo", + cast_to=httpx.Response, + body={"my_param": True}, +) + +print(response.headers.get("x-foo")) +``` + +#### Undocumented request params + +If you want to explicitly send an extra param, you can do so with the `extra_query`, `extra_body`, and `extra_headers` request +options. + +#### Undocumented response properties + +To access undocumented response properties, you can access the extra fields like `response.unknown_prop`. You +can also get all the extra fields on the Pydantic model as a dict with +[`response.model_extra`](https://docs.pydantic.dev/latest/api/base_model/#pydantic.BaseModel.model_extra). + +### Configuring the HTTP client + +You can directly override the [httpx client](https://www.python-httpx.org/api/#client) to customize it for your use case, including: + +- Support for [proxies](https://www.python-httpx.org/advanced/proxies/) +- Custom [transports](https://www.python-httpx.org/advanced/transports/) +- Additional [advanced](https://www.python-httpx.org/advanced/clients/) functionality + +```python +import httpx +from opencode_ai import Opencode, DefaultHttpxClient + +client = Opencode( + # Or use the `OPENCODE_BASE_URL` env var + base_url="http://my.test.server.example.com:8083", + http_client=DefaultHttpxClient( + proxy="http://my.test.proxy.example.com", + transport=httpx.HTTPTransport(local_address="0.0.0.0"), + ), +) +``` + +You can also customize the client on a per-request basis by using `with_options()`: + +```python +client.with_options(http_client=DefaultHttpxClient(...)) +``` + +### Managing HTTP resources + +By default the library closes underlying HTTP connections whenever the client is [garbage collected](https://docs.python.org/3/reference/datamodel.html#object.__del__). You can manually close the client using the `.close()` method if desired, or with a context manager that closes when exiting. + +```py +from opencode_ai import Opencode + +with Opencode() as client: + # make requests here + ... + +# HTTP client is now closed +``` + +## Versioning + +This package generally follows [SemVer](https://semver.org/spec/v2.0.0.html) conventions, though certain backwards-incompatible changes may be released as minor versions: + +1. Changes that only affect static types, without breaking runtime behavior. +2. Changes to library internals which are technically public but not intended or documented for external use. _(Please open a GitHub issue to let us know if you are relying on such internals.)_ +3. Changes that we do not expect to impact the vast majority of users in practice. + +We take backwards-compatibility seriously and work hard to ensure you can rely on a smooth upgrade experience. + +We are keen for your feedback; please open an [issue](https://www.github.com/sst/opencode-sdk-python/issues) with questions, bugs, or suggestions. + +### Determining the installed version + +If you've upgraded to the latest version but aren't seeing any new features you were expecting then your python environment is likely still using an older version. + +You can determine the version that is being used at runtime with: + +```py +import opencode_ai +print(opencode_ai.__version__) +``` + +## Requirements + +Python 3.8 or higher. + +## Contributing + +See [the contributing documentation](./CONTRIBUTING.md). diff --git a/SECURITY.md b/SECURITY.md new file mode 100644 index 0000000..6912e12 --- /dev/null +++ b/SECURITY.md @@ -0,0 +1,27 @@ +# Security Policy + +## Reporting Security Issues + +This SDK is generated by [Stainless Software Inc](http://stainless.com). Stainless takes security seriously, and encourages you to report any security vulnerability promptly so that appropriate action can be taken. + +To report a security issue, please contact the Stainless team at security@stainless.com. + +## Responsible Disclosure + +We appreciate the efforts of security researchers and individuals who help us maintain the security of +SDKs we generate. If you believe you have found a security vulnerability, please adhere to responsible +disclosure practices by allowing us a reasonable amount of time to investigate and address the issue +before making any information public. + +## Reporting Non-SDK Related Security Issues + +If you encounter security issues that are not directly related to SDKs but pertain to the services +or products provided by Opencode, please follow the respective company's security reporting guidelines. + +### Opencode Terms and Policies + +Please contact support@sst.dev for any questions or concerns regarding the security of our services. + +--- + +Thank you for helping us keep the SDKs and systems they interact with secure. diff --git a/api.md b/api.md new file mode 100644 index 0000000..7b123d7 --- /dev/null +++ b/api.md @@ -0,0 +1,225 @@ +# Shared Types + +```python +from opencode_ai.types import MessageAbortedError, ProviderAuthError, UnknownError +``` + +# Event + +Types: + +```python +from opencode_ai.types import EventListResponse +``` + +Methods: + +- client.event.list(\*\*params) -> EventListResponse + +# Path + +Types: + +```python +from opencode_ai.types import Path +``` + +Methods: + +- client.path.get(\*\*params) -> Path + +# App + +Types: + +```python +from opencode_ai.types import Model, Provider, AppLogResponse, AppProvidersResponse +``` + +Methods: + +- client.app.log(\*\*params) -> AppLogResponse +- client.app.providers(\*\*params) -> AppProvidersResponse + +# Agent + +Types: + +```python +from opencode_ai.types import Agent, AgentListResponse +``` + +Methods: + +- client.agent.list(\*\*params) -> AgentListResponse + +# Find + +Types: + +```python +from opencode_ai.types import Symbol, FindFilesResponse, FindSymbolsResponse, FindTextResponse +``` + +Methods: + +- client.find.files(\*\*params) -> FindFilesResponse +- client.find.symbols(\*\*params) -> FindSymbolsResponse +- client.find.text(\*\*params) -> FindTextResponse + +# File + +Types: + +```python +from opencode_ai.types import File, FileNode, FileListResponse, FileReadResponse, FileStatusResponse +``` + +Methods: + +- client.file.list(\*\*params) -> FileListResponse +- client.file.read(\*\*params) -> FileReadResponse +- client.file.status(\*\*params) -> FileStatusResponse + +# Config + +Types: + +```python +from opencode_ai.types import Config, KeybindsConfig, McpLocalConfig, McpRemoteConfig +``` + +Methods: + +- client.config.get(\*\*params) -> Config + +# Command + +Types: + +```python +from opencode_ai.types import Command, CommandListResponse +``` + +Methods: + +- client.command.list(\*\*params) -> CommandListResponse + +# Project + +Types: + +```python +from opencode_ai.types import Project, ProjectListResponse +``` + +Methods: + +- client.project.list(\*\*params) -> ProjectListResponse +- client.project.current(\*\*params) -> Project + +# Session + +Types: + +```python +from opencode_ai.types import ( + AgentPart, + AgentPartInput, + AssistantMessage, + FilePart, + FilePartInput, + FilePartSource, + FilePartSourceText, + FileSource, + Message, + Part, + ReasoningPart, + Session, + SnapshotPart, + StepFinishPart, + StepStartPart, + SymbolSource, + TextPart, + TextPartInput, + ToolPart, + ToolStateCompleted, + ToolStateError, + ToolStatePending, + ToolStateRunning, + UserMessage, + SessionListResponse, + SessionDeleteResponse, + SessionAbortResponse, + SessionChildrenResponse, + SessionCommandResponse, + SessionInitResponse, + SessionMessageResponse, + SessionMessagesResponse, + SessionPromptResponse, + SessionSummarizeResponse, +) +``` + +Methods: + +- client.session.create(\*\*params) -> Session +- client.session.update(id, \*\*params) -> Session +- client.session.list(\*\*params) -> SessionListResponse +- client.session.delete(id, \*\*params) -> SessionDeleteResponse +- client.session.abort(id, \*\*params) -> SessionAbortResponse +- client.session.children(id, \*\*params) -> SessionChildrenResponse +- client.session.command(id, \*\*params) -> SessionCommandResponse +- client.session.get(id, \*\*params) -> Session +- client.session.init(id, \*\*params) -> SessionInitResponse +- client.session.message(message_id, \*, id, \*\*params) -> SessionMessageResponse +- client.session.messages(id, \*\*params) -> SessionMessagesResponse +- client.session.prompt(id, \*\*params) -> SessionPromptResponse +- client.session.revert(id, \*\*params) -> Session +- client.session.share(id, \*\*params) -> Session +- client.session.shell(id, \*\*params) -> AssistantMessage +- client.session.summarize(id, \*\*params) -> SessionSummarizeResponse +- client.session.unrevert(id, \*\*params) -> Session +- client.session.unshare(id, \*\*params) -> Session + +## Permissions + +Types: + +```python +from opencode_ai.types.session import Permission, PermissionRespondResponse +``` + +Methods: + +- client.session.permissions.respond(permission_id, \*, id, \*\*params) -> PermissionRespondResponse + +# Tui + +Types: + +```python +from opencode_ai.types import ( + TuiAppendPromptResponse, + TuiClearPromptResponse, + TuiExecuteCommandResponse, + TuiOpenHelpResponse, + TuiOpenModelsResponse, + TuiOpenSessionsResponse, + TuiOpenThemesResponse, + TuiShowToastResponse, + TuiSubmitPromptResponse, +) +``` + +Methods: + +- client.tui.append_prompt(\*\*params) -> TuiAppendPromptResponse +- client.tui.clear_prompt(\*\*params) -> TuiClearPromptResponse +- client.tui.execute_command(\*\*params) -> TuiExecuteCommandResponse +- client.tui.open_help(\*\*params) -> TuiOpenHelpResponse +- client.tui.open_models(\*\*params) -> TuiOpenModelsResponse +- client.tui.open_sessions(\*\*params) -> TuiOpenSessionsResponse +- client.tui.open_themes(\*\*params) -> TuiOpenThemesResponse +- client.tui.show_toast(\*\*params) -> TuiShowToastResponse +- client.tui.submit_prompt(\*\*params) -> TuiSubmitPromptResponse diff --git a/bin/check-release-environment b/bin/check-release-environment new file mode 100644 index 0000000..b845b0f --- /dev/null +++ b/bin/check-release-environment @@ -0,0 +1,21 @@ +#!/usr/bin/env bash + +errors=() + +if [ -z "${PYPI_TOKEN}" ]; then + errors+=("The PYPI_TOKEN secret has not been set. Please set it in either this repository's secrets or your organization secrets.") +fi + +lenErrors=${#errors[@]} + +if [[ lenErrors -gt 0 ]]; then + echo -e "Found the following errors in the release environment:\n" + + for error in "${errors[@]}"; do + echo -e "- $error\n" + done + + exit 1 +fi + +echo "The environment is ready to push releases!" diff --git a/bin/publish-pypi b/bin/publish-pypi new file mode 100644 index 0000000..826054e --- /dev/null +++ b/bin/publish-pypi @@ -0,0 +1,6 @@ +#!/usr/bin/env bash + +set -eux +mkdir -p dist +rye build --clean +rye publish --yes --token=$PYPI_TOKEN diff --git a/examples/.keep b/examples/.keep new file mode 100644 index 0000000..d8c73e9 --- /dev/null +++ b/examples/.keep @@ -0,0 +1,4 @@ +File generated from our OpenAPI spec by Stainless. + +This directory can be used to store example files demonstrating usage of this SDK. +It is ignored by Stainless code generation and its content (other than this keep file) won't be touched. \ No newline at end of file diff --git a/mypy.ini b/mypy.ini new file mode 100644 index 0000000..34af79f --- /dev/null +++ b/mypy.ini @@ -0,0 +1,50 @@ +[mypy] +pretty = True +show_error_codes = True + +# Exclude _files.py because mypy isn't smart enough to apply +# the correct type narrowing and as this is an internal module +# it's fine to just use Pyright. +# +# We also exclude our `tests` as mypy doesn't always infer +# types correctly and Pyright will still catch any type errors. +exclude = ^(src/opencode_ai/_files\.py|_dev/.*\.py|tests/.*)$ + +strict_equality = True +implicit_reexport = True +check_untyped_defs = True +no_implicit_optional = True + +warn_return_any = True +warn_unreachable = True +warn_unused_configs = True + +# Turn these options off as it could cause conflicts +# with the Pyright options. +warn_unused_ignores = False +warn_redundant_casts = False + +disallow_any_generics = True +disallow_untyped_defs = True +disallow_untyped_calls = True +disallow_subclassing_any = True +disallow_incomplete_defs = True +disallow_untyped_decorators = True +cache_fine_grained = True + +# By default, mypy reports an error if you assign a value to the result +# of a function call that doesn't return anything. We do this in our test +# cases: +# ``` +# result = ... +# assert result is None +# ``` +# Changing this codegen to make mypy happy would increase complexity +# and would not be worth it. +disable_error_code = func-returns-value,overload-cannot-match + +# https://github.com/python/mypy/issues/12162 +[mypy.overrides] +module = "black.files.*" +ignore_errors = true +ignore_missing_imports = true diff --git a/noxfile.py b/noxfile.py new file mode 100644 index 0000000..53bca7f --- /dev/null +++ b/noxfile.py @@ -0,0 +1,9 @@ +import nox + + +@nox.session(reuse_venv=True, name="test-pydantic-v1") +def test_pydantic_v1(session: nox.Session) -> None: + session.install("-r", "requirements-dev.lock") + session.install("pydantic<2") + + session.run("pytest", "--showlocals", "--ignore=tests/functional", *session.posargs) diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..f5c99e3 --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,212 @@ +[project] +name = "opencode-ai" +version = "0.1.0-alpha.36" +description = "The official Python library for the opencode API" +dynamic = ["readme"] +license = "MIT" +authors = [ +{ name = "Opencode", email = "support@sst.dev" }, +] +dependencies = [ + "httpx>=0.23.0, <1", + "pydantic>=1.9.0, <3", + "typing-extensions>=4.10, <5", + "anyio>=3.5.0, <5", + "distro>=1.7.0, <2", + "sniffio", +] +requires-python = ">= 3.8" +classifiers = [ + "Typing :: Typed", + "Intended Audience :: Developers", + "Programming Language :: Python :: 3.8", + "Programming Language :: Python :: 3.9", + "Programming Language :: Python :: 3.10", + "Programming Language :: Python :: 3.11", + "Programming Language :: Python :: 3.12", + "Programming Language :: Python :: 3.13", + "Operating System :: OS Independent", + "Operating System :: POSIX", + "Operating System :: MacOS", + "Operating System :: POSIX :: Linux", + "Operating System :: Microsoft :: Windows", + "Topic :: Software Development :: Libraries :: Python Modules", + "License :: OSI Approved :: MIT License" +] + +[project.urls] +Homepage = "https://github.com/sst/opencode-sdk-python" +Repository = "https://github.com/sst/opencode-sdk-python" + +[project.optional-dependencies] +aiohttp = ["aiohttp", "httpx_aiohttp>=0.1.8"] + +[tool.rye] +managed = true +# version pins are in requirements-dev.lock +dev-dependencies = [ + "pyright==1.1.399", + "mypy", + "respx", + "pytest", + "pytest-asyncio", + "ruff", + "time-machine", + "nox", + "dirty-equals>=0.6.0", + "importlib-metadata>=6.7.0", + "rich>=13.7.1", + "nest_asyncio==1.6.0", + "pytest-xdist>=3.6.1", +] + +[tool.rye.scripts] +format = { chain = [ + "format:ruff", + "format:docs", + "fix:ruff", + # run formatting again to fix any inconsistencies when imports are stripped + "format:ruff", +]} +"format:docs" = "python scripts/utils/ruffen-docs.py README.md api.md" +"format:ruff" = "ruff format" + +"lint" = { chain = [ + "check:ruff", + "typecheck", + "check:importable", +]} +"check:ruff" = "ruff check ." +"fix:ruff" = "ruff check --fix ." + +"check:importable" = "python -c 'import opencode_ai'" + +typecheck = { chain = [ + "typecheck:pyright", + "typecheck:mypy" +]} +"typecheck:pyright" = "pyright" +"typecheck:verify-types" = "pyright --verifytypes opencode_ai --ignoreexternal" +"typecheck:mypy" = "mypy ." + +[build-system] +requires = ["hatchling==1.26.3", "hatch-fancy-pypi-readme"] +build-backend = "hatchling.build" + +[tool.hatch.build] +include = [ + "src/*" +] + +[tool.hatch.build.targets.wheel] +packages = ["src/opencode_ai"] + +[tool.hatch.build.targets.sdist] +# Basically everything except hidden files/directories (such as .github, .devcontainers, .python-version, etc) +include = [ + "/*.toml", + "/*.json", + "/*.lock", + "/*.md", + "/mypy.ini", + "/noxfile.py", + "bin/*", + "examples/*", + "src/*", + "tests/*", +] + +[tool.hatch.metadata.hooks.fancy-pypi-readme] +content-type = "text/markdown" + +[[tool.hatch.metadata.hooks.fancy-pypi-readme.fragments]] +path = "README.md" + +[[tool.hatch.metadata.hooks.fancy-pypi-readme.substitutions]] +# replace relative links with absolute links +pattern = '\[(.+?)\]\(((?!https?://)\S+?)\)' +replacement = '[\1](https://github.com/sst/opencode-sdk-python/tree/main/\g<2>)' + +[tool.pytest.ini_options] +testpaths = ["tests"] +addopts = "--tb=short -n auto" +xfail_strict = true +asyncio_mode = "auto" +asyncio_default_fixture_loop_scope = "session" +filterwarnings = [ + "error" +] + +[tool.pyright] +# this enables practically every flag given by pyright. +# there are a couple of flags that are still disabled by +# default in strict mode as they are experimental and niche. +typeCheckingMode = "strict" +pythonVersion = "3.8" + +exclude = [ + "_dev", + ".venv", + ".nox", + ".git", +] + +reportImplicitOverride = true +reportOverlappingOverload = false + +reportImportCycles = false +reportPrivateUsage = false + +[tool.ruff] +line-length = 120 +output-format = "grouped" +target-version = "py38" + +[tool.ruff.format] +docstring-code-format = true + +[tool.ruff.lint] +select = [ + # isort + "I", + # bugbear rules + "B", + # remove unused imports + "F401", + # bare except statements + "E722", + # unused arguments + "ARG", + # print statements + "T201", + "T203", + # misuse of typing.TYPE_CHECKING + "TC004", + # import rules + "TID251", +] +ignore = [ + # mutable defaults + "B006", +] +unfixable = [ + # disable auto fix for print statements + "T201", + "T203", +] + +[tool.ruff.lint.flake8-tidy-imports.banned-api] +"functools.lru_cache".msg = "This function does not retain type information for the wrapped function's arguments; The `lru_cache` function from `_utils` should be used instead" + +[tool.ruff.lint.isort] +length-sort = true +length-sort-straight = true +combine-as-imports = true +extra-standard-library = ["typing_extensions"] +known-first-party = ["opencode_ai", "tests"] + +[tool.ruff.lint.per-file-ignores] +"bin/**.py" = ["T201", "T203"] +"scripts/**.py" = ["T201", "T203"] +"tests/**.py" = ["T201", "T203"] +"examples/**.py" = ["T201", "T203"] diff --git a/release-please-config.json b/release-please-config.json new file mode 100644 index 0000000..c08c065 --- /dev/null +++ b/release-please-config.json @@ -0,0 +1,66 @@ +{ + "packages": { + ".": {} + }, + "$schema": "https://raw.githubusercontent.com/stainless-api/release-please/main/schemas/config.json", + "include-v-in-tag": true, + "include-component-in-tag": false, + "versioning": "prerelease", + "prerelease": true, + "bump-minor-pre-major": true, + "bump-patch-for-minor-pre-major": false, + "pull-request-header": "Automated Release PR", + "pull-request-title-pattern": "release: ${version}", + "changelog-sections": [ + { + "type": "feat", + "section": "Features" + }, + { + "type": "fix", + "section": "Bug Fixes" + }, + { + "type": "perf", + "section": "Performance Improvements" + }, + { + "type": "revert", + "section": "Reverts" + }, + { + "type": "chore", + "section": "Chores" + }, + { + "type": "docs", + "section": "Documentation" + }, + { + "type": "style", + "section": "Styles" + }, + { + "type": "refactor", + "section": "Refactors" + }, + { + "type": "test", + "section": "Tests", + "hidden": true + }, + { + "type": "build", + "section": "Build System" + }, + { + "type": "ci", + "section": "Continuous Integration", + "hidden": true + } + ], + "release-type": "python", + "extra-files": [ + "src/opencode_ai/_version.py" + ] +} \ No newline at end of file diff --git a/requirements-dev.lock b/requirements-dev.lock new file mode 100644 index 0000000..d88557d --- /dev/null +++ b/requirements-dev.lock @@ -0,0 +1,135 @@ +# generated by rye +# use `rye lock` or `rye sync` to update this lockfile +# +# last locked with the following flags: +# pre: false +# features: [] +# all-features: true +# with-sources: false +# generate-hashes: false +# universal: false + +-e file:. +aiohappyeyeballs==2.6.1 + # via aiohttp +aiohttp==3.12.8 + # via httpx-aiohttp + # via opencode-ai +aiosignal==1.3.2 + # via aiohttp +annotated-types==0.6.0 + # via pydantic +anyio==4.4.0 + # via httpx + # via opencode-ai +argcomplete==3.1.2 + # via nox +async-timeout==5.0.1 + # via aiohttp +attrs==25.3.0 + # via aiohttp +certifi==2023.7.22 + # via httpcore + # via httpx +colorlog==6.7.0 + # via nox +dirty-equals==0.6.0 +distlib==0.3.7 + # via virtualenv +distro==1.8.0 + # via opencode-ai +exceptiongroup==1.2.2 + # via anyio + # via pytest +execnet==2.1.1 + # via pytest-xdist +filelock==3.12.4 + # via virtualenv +frozenlist==1.6.2 + # via aiohttp + # via aiosignal +h11==0.16.0 + # via httpcore +httpcore==1.0.9 + # via httpx +httpx==0.28.1 + # via httpx-aiohttp + # via opencode-ai + # via respx +httpx-aiohttp==0.1.8 + # via opencode-ai +idna==3.4 + # via anyio + # via httpx + # via yarl +importlib-metadata==7.0.0 +iniconfig==2.0.0 + # via pytest +markdown-it-py==3.0.0 + # via rich +mdurl==0.1.2 + # via markdown-it-py +multidict==6.4.4 + # via aiohttp + # via yarl +mypy==1.14.1 +mypy-extensions==1.0.0 + # via mypy +nest-asyncio==1.6.0 +nodeenv==1.8.0 + # via pyright +nox==2023.4.22 +packaging==23.2 + # via nox + # via pytest +platformdirs==3.11.0 + # via virtualenv +pluggy==1.5.0 + # via pytest +propcache==0.3.1 + # via aiohttp + # via yarl +pydantic==2.10.3 + # via opencode-ai +pydantic-core==2.27.1 + # via pydantic +pygments==2.18.0 + # via rich +pyright==1.1.399 +pytest==8.3.3 + # via pytest-asyncio + # via pytest-xdist +pytest-asyncio==0.24.0 +pytest-xdist==3.7.0 +python-dateutil==2.8.2 + # via time-machine +pytz==2023.3.post1 + # via dirty-equals +respx==0.22.0 +rich==13.7.1 +ruff==0.9.4 +setuptools==68.2.2 + # via nodeenv +six==1.16.0 + # via python-dateutil +sniffio==1.3.0 + # via anyio + # via opencode-ai +time-machine==2.9.0 +tomli==2.0.2 + # via mypy + # via pytest +typing-extensions==4.12.2 + # via anyio + # via multidict + # via mypy + # via opencode-ai + # via pydantic + # via pydantic-core + # via pyright +virtualenv==20.24.5 + # via nox +yarl==1.20.0 + # via aiohttp +zipp==3.17.0 + # via importlib-metadata diff --git a/requirements.lock b/requirements.lock new file mode 100644 index 0000000..0ab22ae --- /dev/null +++ b/requirements.lock @@ -0,0 +1,72 @@ +# generated by rye +# use `rye lock` or `rye sync` to update this lockfile +# +# last locked with the following flags: +# pre: false +# features: [] +# all-features: true +# with-sources: false +# generate-hashes: false +# universal: false + +-e file:. +aiohappyeyeballs==2.6.1 + # via aiohttp +aiohttp==3.12.8 + # via httpx-aiohttp + # via opencode-ai +aiosignal==1.3.2 + # via aiohttp +annotated-types==0.6.0 + # via pydantic +anyio==4.4.0 + # via httpx + # via opencode-ai +async-timeout==5.0.1 + # via aiohttp +attrs==25.3.0 + # via aiohttp +certifi==2023.7.22 + # via httpcore + # via httpx +distro==1.8.0 + # via opencode-ai +exceptiongroup==1.2.2 + # via anyio +frozenlist==1.6.2 + # via aiohttp + # via aiosignal +h11==0.16.0 + # via httpcore +httpcore==1.0.9 + # via httpx +httpx==0.28.1 + # via httpx-aiohttp + # via opencode-ai +httpx-aiohttp==0.1.8 + # via opencode-ai +idna==3.4 + # via anyio + # via httpx + # via yarl +multidict==6.4.4 + # via aiohttp + # via yarl +propcache==0.3.1 + # via aiohttp + # via yarl +pydantic==2.10.3 + # via opencode-ai +pydantic-core==2.27.1 + # via pydantic +sniffio==1.3.0 + # via anyio + # via opencode-ai +typing-extensions==4.12.2 + # via anyio + # via multidict + # via opencode-ai + # via pydantic + # via pydantic-core +yarl==1.20.0 + # via aiohttp diff --git a/scripts/bootstrap b/scripts/bootstrap new file mode 100755 index 0000000..e84fe62 --- /dev/null +++ b/scripts/bootstrap @@ -0,0 +1,19 @@ +#!/usr/bin/env bash + +set -e + +cd "$(dirname "$0")/.." + +if ! command -v rye >/dev/null 2>&1 && [ -f "Brewfile" ] && [ "$(uname -s)" = "Darwin" ]; then + brew bundle check >/dev/null 2>&1 || { + echo "==> Installing Homebrew dependencies…" + brew bundle + } +fi + +echo "==> Installing Python dependencies…" + +# experimental uv support makes installations significantly faster +rye config --set-bool behavior.use-uv=true + +rye sync --all-features diff --git a/scripts/format b/scripts/format new file mode 100755 index 0000000..667ec2d --- /dev/null +++ b/scripts/format @@ -0,0 +1,8 @@ +#!/usr/bin/env bash + +set -e + +cd "$(dirname "$0")/.." + +echo "==> Running formatters" +rye run format diff --git a/scripts/lint b/scripts/lint new file mode 100755 index 0000000..bac00da --- /dev/null +++ b/scripts/lint @@ -0,0 +1,11 @@ +#!/usr/bin/env bash + +set -e + +cd "$(dirname "$0")/.." + +echo "==> Running lints" +rye run lint + +echo "==> Making sure it imports" +rye run python -c 'import opencode_ai' diff --git a/scripts/mock b/scripts/mock new file mode 100755 index 0000000..0b28f6e --- /dev/null +++ b/scripts/mock @@ -0,0 +1,41 @@ +#!/usr/bin/env bash + +set -e + +cd "$(dirname "$0")/.." + +if [[ -n "$1" && "$1" != '--'* ]]; then + URL="$1" + shift +else + URL="$(grep 'openapi_spec_url' .stats.yml | cut -d' ' -f2)" +fi + +# Check if the URL is empty +if [ -z "$URL" ]; then + echo "Error: No OpenAPI spec path/url provided or found in .stats.yml" + exit 1 +fi + +echo "==> Starting mock server with URL ${URL}" + +# Run prism mock on the given spec +if [ "$1" == "--daemon" ]; then + npm exec --package=@stainless-api/prism-cli@5.15.0 -- prism mock "$URL" &> .prism.log & + + # Wait for server to come online + echo -n "Waiting for server" + while ! grep -q "✖ fatal\|Prism is listening" ".prism.log" ; do + echo -n "." + sleep 0.1 + done + + if grep -q "✖ fatal" ".prism.log"; then + cat .prism.log + exit 1 + fi + + echo +else + npm exec --package=@stainless-api/prism-cli@5.15.0 -- prism mock "$URL" +fi diff --git a/scripts/test b/scripts/test new file mode 100755 index 0000000..dbeda2d --- /dev/null +++ b/scripts/test @@ -0,0 +1,61 @@ +#!/usr/bin/env bash + +set -e + +cd "$(dirname "$0")/.." + +RED='\033[0;31m' +GREEN='\033[0;32m' +YELLOW='\033[0;33m' +NC='\033[0m' # No Color + +function prism_is_running() { + curl --silent "http://localhost:4010" >/dev/null 2>&1 +} + +kill_server_on_port() { + pids=$(lsof -t -i tcp:"$1" || echo "") + if [ "$pids" != "" ]; then + kill "$pids" + echo "Stopped $pids." + fi +} + +function is_overriding_api_base_url() { + [ -n "$TEST_API_BASE_URL" ] +} + +if ! is_overriding_api_base_url && ! prism_is_running ; then + # When we exit this script, make sure to kill the background mock server process + trap 'kill_server_on_port 4010' EXIT + + # Start the dev server + ./scripts/mock --daemon +fi + +if is_overriding_api_base_url ; then + echo -e "${GREEN}✔ Running tests against ${TEST_API_BASE_URL}${NC}" + echo +elif ! prism_is_running ; then + echo -e "${RED}ERROR:${NC} The test suite will not run without a mock Prism server" + echo -e "running against your OpenAPI spec." + echo + echo -e "To run the server, pass in the path or url of your OpenAPI" + echo -e "spec to the prism command:" + echo + echo -e " \$ ${YELLOW}npm exec --package=@stainless-api/prism-cli@5.15.0 -- prism mock path/to/your.openapi.yml${NC}" + echo + + exit 1 +else + echo -e "${GREEN}✔ Mock prism server is running with your OpenAPI spec${NC}" + echo +fi + +export DEFER_PYDANTIC_BUILD=false + +echo "==> Running tests" +rye run pytest "$@" + +echo "==> Running Pydantic v1 tests" +rye run nox -s test-pydantic-v1 -- "$@" diff --git a/scripts/utils/ruffen-docs.py b/scripts/utils/ruffen-docs.py new file mode 100644 index 0000000..0cf2bd2 --- /dev/null +++ b/scripts/utils/ruffen-docs.py @@ -0,0 +1,167 @@ +# fork of https://github.com/asottile/blacken-docs adapted for ruff +from __future__ import annotations + +import re +import sys +import argparse +import textwrap +import contextlib +import subprocess +from typing import Match, Optional, Sequence, Generator, NamedTuple, cast + +MD_RE = re.compile( + r"(?P^(?P *)```\s*python\n)" r"(?P.*?)" r"(?P^(?P=indent)```\s*$)", + re.DOTALL | re.MULTILINE, +) +MD_PYCON_RE = re.compile( + r"(?P^(?P *)```\s*pycon\n)" r"(?P.*?)" r"(?P^(?P=indent)```.*$)", + re.DOTALL | re.MULTILINE, +) +PYCON_PREFIX = ">>> " +PYCON_CONTINUATION_PREFIX = "..." +PYCON_CONTINUATION_RE = re.compile( + rf"^{re.escape(PYCON_CONTINUATION_PREFIX)}( |$)", +) +DEFAULT_LINE_LENGTH = 100 + + +class CodeBlockError(NamedTuple): + offset: int + exc: Exception + + +def format_str( + src: str, +) -> tuple[str, Sequence[CodeBlockError]]: + errors: list[CodeBlockError] = [] + + @contextlib.contextmanager + def _collect_error(match: Match[str]) -> Generator[None, None, None]: + try: + yield + except Exception as e: + errors.append(CodeBlockError(match.start(), e)) + + def _md_match(match: Match[str]) -> str: + code = textwrap.dedent(match["code"]) + with _collect_error(match): + code = format_code_block(code) + code = textwrap.indent(code, match["indent"]) + return f"{match['before']}{code}{match['after']}" + + def _pycon_match(match: Match[str]) -> str: + code = "" + fragment = cast(Optional[str], None) + + def finish_fragment() -> None: + nonlocal code + nonlocal fragment + + if fragment is not None: + with _collect_error(match): + fragment = format_code_block(fragment) + fragment_lines = fragment.splitlines() + code += f"{PYCON_PREFIX}{fragment_lines[0]}\n" + for line in fragment_lines[1:]: + # Skip blank lines to handle Black adding a blank above + # functions within blocks. A blank line would end the REPL + # continuation prompt. + # + # >>> if True: + # ... def f(): + # ... pass + # ... + if line: + code += f"{PYCON_CONTINUATION_PREFIX} {line}\n" + if fragment_lines[-1].startswith(" "): + code += f"{PYCON_CONTINUATION_PREFIX}\n" + fragment = None + + indentation = None + for line in match["code"].splitlines(): + orig_line, line = line, line.lstrip() + if indentation is None and line: + indentation = len(orig_line) - len(line) + continuation_match = PYCON_CONTINUATION_RE.match(line) + if continuation_match and fragment is not None: + fragment += line[continuation_match.end() :] + "\n" + else: + finish_fragment() + if line.startswith(PYCON_PREFIX): + fragment = line[len(PYCON_PREFIX) :] + "\n" + else: + code += orig_line[indentation:] + "\n" + finish_fragment() + return code + + def _md_pycon_match(match: Match[str]) -> str: + code = _pycon_match(match) + code = textwrap.indent(code, match["indent"]) + return f"{match['before']}{code}{match['after']}" + + src = MD_RE.sub(_md_match, src) + src = MD_PYCON_RE.sub(_md_pycon_match, src) + return src, errors + + +def format_code_block(code: str) -> str: + return subprocess.check_output( + [ + sys.executable, + "-m", + "ruff", + "format", + "--stdin-filename=script.py", + f"--line-length={DEFAULT_LINE_LENGTH}", + ], + encoding="utf-8", + input=code, + ) + + +def format_file( + filename: str, + skip_errors: bool, +) -> int: + with open(filename, encoding="UTF-8") as f: + contents = f.read() + new_contents, errors = format_str(contents) + for error in errors: + lineno = contents[: error.offset].count("\n") + 1 + print(f"{filename}:{lineno}: code block parse error {error.exc}") + if errors and not skip_errors: + return 1 + if contents != new_contents: + print(f"{filename}: Rewriting...") + with open(filename, "w", encoding="UTF-8") as f: + f.write(new_contents) + return 0 + else: + return 0 + + +def main(argv: Sequence[str] | None = None) -> int: + parser = argparse.ArgumentParser() + parser.add_argument( + "-l", + "--line-length", + type=int, + default=DEFAULT_LINE_LENGTH, + ) + parser.add_argument( + "-S", + "--skip-string-normalization", + action="store_true", + ) + parser.add_argument("-E", "--skip-errors", action="store_true") + parser.add_argument("filenames", nargs="*") + args = parser.parse_args(argv) + + retv = 0 + for filename in args.filenames: + retv |= format_file(filename, skip_errors=args.skip_errors) + return retv + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/scripts/utils/upload-artifact.sh b/scripts/utils/upload-artifact.sh new file mode 100755 index 0000000..c16f6af --- /dev/null +++ b/scripts/utils/upload-artifact.sh @@ -0,0 +1,27 @@ +#!/usr/bin/env bash +set -exuo pipefail + +FILENAME=$(basename dist/*.whl) + +RESPONSE=$(curl -X POST "$URL?filename=$FILENAME" \ + -H "Authorization: Bearer $AUTH" \ + -H "Content-Type: application/json") + +SIGNED_URL=$(echo "$RESPONSE" | jq -r '.url') + +if [[ "$SIGNED_URL" == "null" ]]; then + echo -e "\033[31mFailed to get signed URL.\033[0m" + exit 1 +fi + +UPLOAD_RESPONSE=$(curl -v -X PUT \ + -H "Content-Type: binary/octet-stream" \ + --data-binary "@dist/$FILENAME" "$SIGNED_URL" 2>&1) + +if echo "$UPLOAD_RESPONSE" | grep -q "HTTP/[0-9.]* 200"; then + echo -e "\033[32mUploaded build to Stainless storage.\033[0m" + echo -e "\033[32mInstallation: pip install 'https://pkg.stainless.com/s/opencode-python/$SHA/$FILENAME'\033[0m" +else + echo -e "\033[31mFailed to upload artifact.\033[0m" + exit 1 +fi diff --git a/src/opencode_ai/__init__.py b/src/opencode_ai/__init__.py new file mode 100644 index 0000000..7d8c13c --- /dev/null +++ b/src/opencode_ai/__init__.py @@ -0,0 +1,100 @@ +# File generated from our OpenAPI spec by Stainless. See CONTRIBUTING.md for details. + +import typing as _t + +from . import types +from ._types import NOT_GIVEN, Omit, NoneType, NotGiven, Transport, ProxiesTypes +from ._utils import file_from_path +from ._client import ( + Client, + Stream, + Timeout, + Opencode, + Transport, + AsyncClient, + AsyncStream, + AsyncOpencode, + RequestOptions, +) +from ._models import BaseModel +from ._version import __title__, __version__ +from ._response import APIResponse as APIResponse, AsyncAPIResponse as AsyncAPIResponse +from ._constants import DEFAULT_TIMEOUT, DEFAULT_MAX_RETRIES, DEFAULT_CONNECTION_LIMITS +from ._exceptions import ( + APIError, + ConflictError, + NotFoundError, + OpencodeError, + APIStatusError, + RateLimitError, + APITimeoutError, + BadRequestError, + APIConnectionError, + AuthenticationError, + InternalServerError, + PermissionDeniedError, + UnprocessableEntityError, + APIResponseValidationError, +) +from ._base_client import DefaultHttpxClient, DefaultAioHttpClient, DefaultAsyncHttpxClient +from ._utils._logs import setup_logging as _setup_logging + +__all__ = [ + "types", + "__version__", + "__title__", + "NoneType", + "Transport", + "ProxiesTypes", + "NotGiven", + "NOT_GIVEN", + "Omit", + "OpencodeError", + "APIError", + "APIStatusError", + "APITimeoutError", + "APIConnectionError", + "APIResponseValidationError", + "BadRequestError", + "AuthenticationError", + "PermissionDeniedError", + "NotFoundError", + "ConflictError", + "UnprocessableEntityError", + "RateLimitError", + "InternalServerError", + "Timeout", + "RequestOptions", + "Client", + "AsyncClient", + "Stream", + "AsyncStream", + "Opencode", + "AsyncOpencode", + "file_from_path", + "BaseModel", + "DEFAULT_TIMEOUT", + "DEFAULT_MAX_RETRIES", + "DEFAULT_CONNECTION_LIMITS", + "DefaultHttpxClient", + "DefaultAsyncHttpxClient", + "DefaultAioHttpClient", +] + +if not _t.TYPE_CHECKING: + from ._utils._resources_proxy import resources as resources + +_setup_logging() + +# Update the __module__ attribute for exported symbols so that +# error messages point to this module instead of the module +# it was originally defined in, e.g. +# opencode_ai._exceptions.NotFoundError -> opencode_ai.NotFoundError +__locals = locals() +for __name in __all__: + if not __name.startswith("__"): + try: + __locals[__name].__module__ = "opencode_ai" + except (TypeError, AttributeError): + # Some of our exported symbols are builtins which we can't set attributes for. + pass diff --git a/src/opencode_ai/_base_client.py b/src/opencode_ai/_base_client.py new file mode 100644 index 0000000..eb05756 --- /dev/null +++ b/src/opencode_ai/_base_client.py @@ -0,0 +1,1995 @@ +from __future__ import annotations + +import sys +import json +import time +import uuid +import email +import asyncio +import inspect +import logging +import platform +import email.utils +from types import TracebackType +from random import random +from typing import ( + TYPE_CHECKING, + Any, + Dict, + Type, + Union, + Generic, + Mapping, + TypeVar, + Iterable, + Iterator, + Optional, + Generator, + AsyncIterator, + cast, + overload, +) +from typing_extensions import Literal, override, get_origin + +import anyio +import httpx +import distro +import pydantic +from httpx import URL +from pydantic import PrivateAttr + +from . import _exceptions +from ._qs import Querystring +from ._files import to_httpx_files, async_to_httpx_files +from ._types import ( + NOT_GIVEN, + Body, + Omit, + Query, + Headers, + Timeout, + NotGiven, + ResponseT, + AnyMapping, + PostParser, + RequestFiles, + HttpxSendArgs, + RequestOptions, + HttpxRequestFiles, + ModelBuilderProtocol, +) +from ._utils import is_dict, is_list, asyncify, is_given, lru_cache, is_mapping +from ._compat import PYDANTIC_V2, model_copy, model_dump +from ._models import GenericModel, FinalRequestOptions, validate_type, construct_type +from ._response import ( + APIResponse, + BaseAPIResponse, + AsyncAPIResponse, + extract_response_type, +) +from ._constants import ( + DEFAULT_TIMEOUT, + MAX_RETRY_DELAY, + DEFAULT_MAX_RETRIES, + INITIAL_RETRY_DELAY, + RAW_RESPONSE_HEADER, + OVERRIDE_CAST_TO_HEADER, + DEFAULT_CONNECTION_LIMITS, +) +from ._streaming import Stream, SSEDecoder, AsyncStream, SSEBytesDecoder +from ._exceptions import ( + APIStatusError, + APITimeoutError, + APIConnectionError, + APIResponseValidationError, +) + +log: logging.Logger = logging.getLogger(__name__) + +# TODO: make base page type vars covariant +SyncPageT = TypeVar("SyncPageT", bound="BaseSyncPage[Any]") +AsyncPageT = TypeVar("AsyncPageT", bound="BaseAsyncPage[Any]") + + +_T = TypeVar("_T") +_T_co = TypeVar("_T_co", covariant=True) + +_StreamT = TypeVar("_StreamT", bound=Stream[Any]) +_AsyncStreamT = TypeVar("_AsyncStreamT", bound=AsyncStream[Any]) + +if TYPE_CHECKING: + from httpx._config import ( + DEFAULT_TIMEOUT_CONFIG, # pyright: ignore[reportPrivateImportUsage] + ) + + HTTPX_DEFAULT_TIMEOUT = DEFAULT_TIMEOUT_CONFIG +else: + try: + from httpx._config import DEFAULT_TIMEOUT_CONFIG as HTTPX_DEFAULT_TIMEOUT + except ImportError: + # taken from https://github.com/encode/httpx/blob/3ba5fe0d7ac70222590e759c31442b1cab263791/httpx/_config.py#L366 + HTTPX_DEFAULT_TIMEOUT = Timeout(5.0) + + +class PageInfo: + """Stores the necessary information to build the request to retrieve the next page. + + Either `url` or `params` must be set. + """ + + url: URL | NotGiven + params: Query | NotGiven + json: Body | NotGiven + + @overload + def __init__( + self, + *, + url: URL, + ) -> None: ... + + @overload + def __init__( + self, + *, + params: Query, + ) -> None: ... + + @overload + def __init__( + self, + *, + json: Body, + ) -> None: ... + + def __init__( + self, + *, + url: URL | NotGiven = NOT_GIVEN, + json: Body | NotGiven = NOT_GIVEN, + params: Query | NotGiven = NOT_GIVEN, + ) -> None: + self.url = url + self.json = json + self.params = params + + @override + def __repr__(self) -> str: + if self.url: + return f"{self.__class__.__name__}(url={self.url})" + if self.json: + return f"{self.__class__.__name__}(json={self.json})" + return f"{self.__class__.__name__}(params={self.params})" + + +class BasePage(GenericModel, Generic[_T]): + """ + Defines the core interface for pagination. + + Type Args: + ModelT: The pydantic model that represents an item in the response. + + Methods: + has_next_page(): Check if there is another page available + next_page_info(): Get the necessary information to make a request for the next page + """ + + _options: FinalRequestOptions = PrivateAttr() + _model: Type[_T] = PrivateAttr() + + def has_next_page(self) -> bool: + items = self._get_page_items() + if not items: + return False + return self.next_page_info() is not None + + def next_page_info(self) -> Optional[PageInfo]: ... + + def _get_page_items(self) -> Iterable[_T]: # type: ignore[empty-body] + ... + + def _params_from_url(self, url: URL) -> httpx.QueryParams: + # TODO: do we have to preprocess params here? + return httpx.QueryParams(cast(Any, self._options.params)).merge(url.params) + + def _info_to_options(self, info: PageInfo) -> FinalRequestOptions: + options = model_copy(self._options) + options._strip_raw_response_header() + + if not isinstance(info.params, NotGiven): + options.params = {**options.params, **info.params} + return options + + if not isinstance(info.url, NotGiven): + params = self._params_from_url(info.url) + url = info.url.copy_with(params=params) + options.params = dict(url.params) + options.url = str(url) + return options + + if not isinstance(info.json, NotGiven): + if not is_mapping(info.json): + raise TypeError("Pagination is only supported with mappings") + + if not options.json_data: + options.json_data = {**info.json} + else: + if not is_mapping(options.json_data): + raise TypeError("Pagination is only supported with mappings") + + options.json_data = {**options.json_data, **info.json} + return options + + raise ValueError("Unexpected PageInfo state") + + +class BaseSyncPage(BasePage[_T], Generic[_T]): + _client: SyncAPIClient = pydantic.PrivateAttr() + + def _set_private_attributes( + self, + client: SyncAPIClient, + model: Type[_T], + options: FinalRequestOptions, + ) -> None: + if PYDANTIC_V2 and getattr(self, "__pydantic_private__", None) is None: + self.__pydantic_private__ = {} + + self._model = model + self._client = client + self._options = options + + # Pydantic uses a custom `__iter__` method to support casting BaseModels + # to dictionaries. e.g. dict(model). + # As we want to support `for item in page`, this is inherently incompatible + # with the default pydantic behaviour. It is not possible to support both + # use cases at once. Fortunately, this is not a big deal as all other pydantic + # methods should continue to work as expected as there is an alternative method + # to cast a model to a dictionary, model.dict(), which is used internally + # by pydantic. + def __iter__(self) -> Iterator[_T]: # type: ignore + for page in self.iter_pages(): + for item in page._get_page_items(): + yield item + + def iter_pages(self: SyncPageT) -> Iterator[SyncPageT]: + page = self + while True: + yield page + if page.has_next_page(): + page = page.get_next_page() + else: + return + + def get_next_page(self: SyncPageT) -> SyncPageT: + info = self.next_page_info() + if not info: + raise RuntimeError( + "No next page expected; please check `.has_next_page()` before calling `.get_next_page()`." + ) + + options = self._info_to_options(info) + return self._client._request_api_list(self._model, page=self.__class__, options=options) + + +class AsyncPaginator(Generic[_T, AsyncPageT]): + def __init__( + self, + client: AsyncAPIClient, + options: FinalRequestOptions, + page_cls: Type[AsyncPageT], + model: Type[_T], + ) -> None: + self._model = model + self._client = client + self._options = options + self._page_cls = page_cls + + def __await__(self) -> Generator[Any, None, AsyncPageT]: + return self._get_page().__await__() + + async def _get_page(self) -> AsyncPageT: + def _parser(resp: AsyncPageT) -> AsyncPageT: + resp._set_private_attributes( + model=self._model, + options=self._options, + client=self._client, + ) + return resp + + self._options.post_parser = _parser + + return await self._client.request(self._page_cls, self._options) + + async def __aiter__(self) -> AsyncIterator[_T]: + # https://github.com/microsoft/pyright/issues/3464 + page = cast( + AsyncPageT, + await self, # type: ignore + ) + async for item in page: + yield item + + +class BaseAsyncPage(BasePage[_T], Generic[_T]): + _client: AsyncAPIClient = pydantic.PrivateAttr() + + def _set_private_attributes( + self, + model: Type[_T], + client: AsyncAPIClient, + options: FinalRequestOptions, + ) -> None: + if PYDANTIC_V2 and getattr(self, "__pydantic_private__", None) is None: + self.__pydantic_private__ = {} + + self._model = model + self._client = client + self._options = options + + async def __aiter__(self) -> AsyncIterator[_T]: + async for page in self.iter_pages(): + for item in page._get_page_items(): + yield item + + async def iter_pages(self: AsyncPageT) -> AsyncIterator[AsyncPageT]: + page = self + while True: + yield page + if page.has_next_page(): + page = await page.get_next_page() + else: + return + + async def get_next_page(self: AsyncPageT) -> AsyncPageT: + info = self.next_page_info() + if not info: + raise RuntimeError( + "No next page expected; please check `.has_next_page()` before calling `.get_next_page()`." + ) + + options = self._info_to_options(info) + return await self._client._request_api_list(self._model, page=self.__class__, options=options) + + +_HttpxClientT = TypeVar("_HttpxClientT", bound=Union[httpx.Client, httpx.AsyncClient]) +_DefaultStreamT = TypeVar("_DefaultStreamT", bound=Union[Stream[Any], AsyncStream[Any]]) + + +class BaseClient(Generic[_HttpxClientT, _DefaultStreamT]): + _client: _HttpxClientT + _version: str + _base_url: URL + max_retries: int + timeout: Union[float, Timeout, None] + _strict_response_validation: bool + _idempotency_header: str | None + _default_stream_cls: type[_DefaultStreamT] | None = None + + def __init__( + self, + *, + version: str, + base_url: str | URL, + _strict_response_validation: bool, + max_retries: int = DEFAULT_MAX_RETRIES, + timeout: float | Timeout | None = DEFAULT_TIMEOUT, + custom_headers: Mapping[str, str] | None = None, + custom_query: Mapping[str, object] | None = None, + ) -> None: + self._version = version + self._base_url = self._enforce_trailing_slash(URL(base_url)) + self.max_retries = max_retries + self.timeout = timeout + self._custom_headers = custom_headers or {} + self._custom_query = custom_query or {} + self._strict_response_validation = _strict_response_validation + self._idempotency_header = None + self._platform: Platform | None = None + + if max_retries is None: # pyright: ignore[reportUnnecessaryComparison] + raise TypeError( + "max_retries cannot be None. If you want to disable retries, pass `0`; if you want unlimited retries, pass `math.inf` or a very high number; if you want the default behavior, pass `opencode_ai.DEFAULT_MAX_RETRIES`" + ) + + def _enforce_trailing_slash(self, url: URL) -> URL: + if url.raw_path.endswith(b"/"): + return url + return url.copy_with(raw_path=url.raw_path + b"/") + + def _make_status_error_from_response( + self, + response: httpx.Response, + ) -> APIStatusError: + if response.is_closed and not response.is_stream_consumed: + # We can't read the response body as it has been closed + # before it was read. This can happen if an event hook + # raises a status error. + body = None + err_msg = f"Error code: {response.status_code}" + else: + err_text = response.text.strip() + body = err_text + + try: + body = json.loads(err_text) + err_msg = f"Error code: {response.status_code} - {body}" + except Exception: + err_msg = err_text or f"Error code: {response.status_code}" + + return self._make_status_error(err_msg, body=body, response=response) + + def _make_status_error( + self, + err_msg: str, + *, + body: object, + response: httpx.Response, + ) -> _exceptions.APIStatusError: + raise NotImplementedError() + + def _build_headers(self, options: FinalRequestOptions, *, retries_taken: int = 0) -> httpx.Headers: + custom_headers = options.headers or {} + headers_dict = _merge_mappings(self.default_headers, custom_headers) + self._validate_headers(headers_dict, custom_headers) + + # headers are case-insensitive while dictionaries are not. + headers = httpx.Headers(headers_dict) + + idempotency_header = self._idempotency_header + if idempotency_header and options.idempotency_key and idempotency_header not in headers: + headers[idempotency_header] = options.idempotency_key + + # Don't set these headers if they were already set or removed by the caller. We check + # `custom_headers`, which can contain `Omit()`, instead of `headers` to account for the removal case. + lower_custom_headers = [header.lower() for header in custom_headers] + if "x-stainless-retry-count" not in lower_custom_headers: + headers["x-stainless-retry-count"] = str(retries_taken) + if "x-stainless-read-timeout" not in lower_custom_headers: + timeout = self.timeout if isinstance(options.timeout, NotGiven) else options.timeout + if isinstance(timeout, Timeout): + timeout = timeout.read + if timeout is not None: + headers["x-stainless-read-timeout"] = str(timeout) + + return headers + + def _prepare_url(self, url: str) -> URL: + """ + Merge a URL argument together with any 'base_url' on the client, + to create the URL used for the outgoing request. + """ + # Copied from httpx's `_merge_url` method. + merge_url = URL(url) + if merge_url.is_relative_url: + merge_raw_path = self.base_url.raw_path + merge_url.raw_path.lstrip(b"/") + return self.base_url.copy_with(raw_path=merge_raw_path) + + return merge_url + + def _make_sse_decoder(self) -> SSEDecoder | SSEBytesDecoder: + return SSEDecoder() + + def _build_request( + self, + options: FinalRequestOptions, + *, + retries_taken: int = 0, + ) -> httpx.Request: + if log.isEnabledFor(logging.DEBUG): + log.debug("Request options: %s", model_dump(options, exclude_unset=True)) + + kwargs: dict[str, Any] = {} + + json_data = options.json_data + if options.extra_json is not None: + if json_data is None: + json_data = cast(Body, options.extra_json) + elif is_mapping(json_data): + json_data = _merge_mappings(json_data, options.extra_json) + else: + raise RuntimeError(f"Unexpected JSON data type, {type(json_data)}, cannot merge with `extra_body`") + + headers = self._build_headers(options, retries_taken=retries_taken) + params = _merge_mappings(self.default_query, options.params) + content_type = headers.get("Content-Type") + files = options.files + + # If the given Content-Type header is multipart/form-data then it + # has to be removed so that httpx can generate the header with + # additional information for us as it has to be in this form + # for the server to be able to correctly parse the request: + # multipart/form-data; boundary=---abc-- + if content_type is not None and content_type.startswith("multipart/form-data"): + if "boundary" not in content_type: + # only remove the header if the boundary hasn't been explicitly set + # as the caller doesn't want httpx to come up with their own boundary + headers.pop("Content-Type") + + # As we are now sending multipart/form-data instead of application/json + # we need to tell httpx to use it, https://www.python-httpx.org/advanced/clients/#multipart-file-encoding + if json_data: + if not is_dict(json_data): + raise TypeError( + f"Expected query input to be a dictionary for multipart requests but got {type(json_data)} instead." + ) + kwargs["data"] = self._serialize_multipartform(json_data) + + # httpx determines whether or not to send a "multipart/form-data" + # request based on the truthiness of the "files" argument. + # This gets around that issue by generating a dict value that + # evaluates to true. + # + # https://github.com/encode/httpx/discussions/2399#discussioncomment-3814186 + if not files: + files = cast(HttpxRequestFiles, ForceMultipartDict()) + + prepared_url = self._prepare_url(options.url) + if "_" in prepared_url.host: + # work around https://github.com/encode/httpx/discussions/2880 + kwargs["extensions"] = {"sni_hostname": prepared_url.host.replace("_", "-")} + + is_body_allowed = options.method.lower() != "get" + + if is_body_allowed: + if isinstance(json_data, bytes): + kwargs["content"] = json_data + else: + kwargs["json"] = json_data if is_given(json_data) else None + kwargs["files"] = files + else: + headers.pop("Content-Type", None) + kwargs.pop("data", None) + + # TODO: report this error to httpx + return self._client.build_request( # pyright: ignore[reportUnknownMemberType] + headers=headers, + timeout=self.timeout if isinstance(options.timeout, NotGiven) else options.timeout, + method=options.method, + url=prepared_url, + # the `Query` type that we use is incompatible with qs' + # `Params` type as it needs to be typed as `Mapping[str, object]` + # so that passing a `TypedDict` doesn't cause an error. + # https://github.com/microsoft/pyright/issues/3526#event-6715453066 + params=self.qs.stringify(cast(Mapping[str, Any], params)) if params else None, + **kwargs, + ) + + def _serialize_multipartform(self, data: Mapping[object, object]) -> dict[str, object]: + items = self.qs.stringify_items( + # TODO: type ignore is required as stringify_items is well typed but we can't be + # well typed without heavy validation. + data, # type: ignore + array_format="brackets", + ) + serialized: dict[str, object] = {} + for key, value in items: + existing = serialized.get(key) + + if not existing: + serialized[key] = value + continue + + # If a value has already been set for this key then that + # means we're sending data like `array[]=[1, 2, 3]` and we + # need to tell httpx that we want to send multiple values with + # the same key which is done by using a list or a tuple. + # + # Note: 2d arrays should never result in the same key at both + # levels so it's safe to assume that if the value is a list, + # it was because we changed it to be a list. + if is_list(existing): + existing.append(value) + else: + serialized[key] = [existing, value] + + return serialized + + def _maybe_override_cast_to(self, cast_to: type[ResponseT], options: FinalRequestOptions) -> type[ResponseT]: + if not is_given(options.headers): + return cast_to + + # make a copy of the headers so we don't mutate user-input + headers = dict(options.headers) + + # we internally support defining a temporary header to override the + # default `cast_to` type for use with `.with_raw_response` and `.with_streaming_response` + # see _response.py for implementation details + override_cast_to = headers.pop(OVERRIDE_CAST_TO_HEADER, NOT_GIVEN) + if is_given(override_cast_to): + options.headers = headers + return cast(Type[ResponseT], override_cast_to) + + return cast_to + + def _should_stream_response_body(self, request: httpx.Request) -> bool: + return request.headers.get(RAW_RESPONSE_HEADER) == "stream" # type: ignore[no-any-return] + + def _process_response_data( + self, + *, + data: object, + cast_to: type[ResponseT], + response: httpx.Response, + ) -> ResponseT: + if data is None: + return cast(ResponseT, None) + + if cast_to is object: + return cast(ResponseT, data) + + try: + if inspect.isclass(cast_to) and issubclass(cast_to, ModelBuilderProtocol): + return cast(ResponseT, cast_to.build(response=response, data=data)) + + if self._strict_response_validation: + return cast(ResponseT, validate_type(type_=cast_to, value=data)) + + return cast(ResponseT, construct_type(type_=cast_to, value=data)) + except pydantic.ValidationError as err: + raise APIResponseValidationError(response=response, body=data) from err + + @property + def qs(self) -> Querystring: + return Querystring() + + @property + def custom_auth(self) -> httpx.Auth | None: + return None + + @property + def auth_headers(self) -> dict[str, str]: + return {} + + @property + def default_headers(self) -> dict[str, str | Omit]: + return { + "Accept": "application/json", + "Content-Type": "application/json", + "User-Agent": self.user_agent, + **self.platform_headers(), + **self.auth_headers, + **self._custom_headers, + } + + @property + def default_query(self) -> dict[str, object]: + return { + **self._custom_query, + } + + def _validate_headers( + self, + headers: Headers, # noqa: ARG002 + custom_headers: Headers, # noqa: ARG002 + ) -> None: + """Validate the given default headers and custom headers. + + Does nothing by default. + """ + return + + @property + def user_agent(self) -> str: + return f"{self.__class__.__name__}/Python {self._version}" + + @property + def base_url(self) -> URL: + return self._base_url + + @base_url.setter + def base_url(self, url: URL | str) -> None: + self._base_url = self._enforce_trailing_slash(url if isinstance(url, URL) else URL(url)) + + def platform_headers(self) -> Dict[str, str]: + # the actual implementation is in a separate `lru_cache` decorated + # function because adding `lru_cache` to methods will leak memory + # https://github.com/python/cpython/issues/88476 + return platform_headers(self._version, platform=self._platform) + + def _parse_retry_after_header(self, response_headers: Optional[httpx.Headers] = None) -> float | None: + """Returns a float of the number of seconds (not milliseconds) to wait after retrying, or None if unspecified. + + About the Retry-After header: https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Retry-After + See also https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Retry-After#syntax + """ + if response_headers is None: + return None + + # First, try the non-standard `retry-after-ms` header for milliseconds, + # which is more precise than integer-seconds `retry-after` + try: + retry_ms_header = response_headers.get("retry-after-ms", None) + return float(retry_ms_header) / 1000 + except (TypeError, ValueError): + pass + + # Next, try parsing `retry-after` header as seconds (allowing nonstandard floats). + retry_header = response_headers.get("retry-after") + try: + # note: the spec indicates that this should only ever be an integer + # but if someone sends a float there's no reason for us to not respect it + return float(retry_header) + except (TypeError, ValueError): + pass + + # Last, try parsing `retry-after` as a date. + retry_date_tuple = email.utils.parsedate_tz(retry_header) + if retry_date_tuple is None: + return None + + retry_date = email.utils.mktime_tz(retry_date_tuple) + return float(retry_date - time.time()) + + def _calculate_retry_timeout( + self, + remaining_retries: int, + options: FinalRequestOptions, + response_headers: Optional[httpx.Headers] = None, + ) -> float: + max_retries = options.get_max_retries(self.max_retries) + + # If the API asks us to wait a certain amount of time (and it's a reasonable amount), just do what it says. + retry_after = self._parse_retry_after_header(response_headers) + if retry_after is not None and 0 < retry_after <= 60: + return retry_after + + # Also cap retry count to 1000 to avoid any potential overflows with `pow` + nb_retries = min(max_retries - remaining_retries, 1000) + + # Apply exponential backoff, but not more than the max. + sleep_seconds = min(INITIAL_RETRY_DELAY * pow(2.0, nb_retries), MAX_RETRY_DELAY) + + # Apply some jitter, plus-or-minus half a second. + jitter = 1 - 0.25 * random() + timeout = sleep_seconds * jitter + return timeout if timeout >= 0 else 0 + + def _should_retry(self, response: httpx.Response) -> bool: + # Note: this is not a standard header + should_retry_header = response.headers.get("x-should-retry") + + # If the server explicitly says whether or not to retry, obey. + if should_retry_header == "true": + log.debug("Retrying as header `x-should-retry` is set to `true`") + return True + if should_retry_header == "false": + log.debug("Not retrying as header `x-should-retry` is set to `false`") + return False + + # Retry on request timeouts. + if response.status_code == 408: + log.debug("Retrying due to status code %i", response.status_code) + return True + + # Retry on lock timeouts. + if response.status_code == 409: + log.debug("Retrying due to status code %i", response.status_code) + return True + + # Retry on rate limits. + if response.status_code == 429: + log.debug("Retrying due to status code %i", response.status_code) + return True + + # Retry internal errors. + if response.status_code >= 500: + log.debug("Retrying due to status code %i", response.status_code) + return True + + log.debug("Not retrying") + return False + + def _idempotency_key(self) -> str: + return f"stainless-python-retry-{uuid.uuid4()}" + + +class _DefaultHttpxClient(httpx.Client): + def __init__(self, **kwargs: Any) -> None: + kwargs.setdefault("timeout", DEFAULT_TIMEOUT) + kwargs.setdefault("limits", DEFAULT_CONNECTION_LIMITS) + kwargs.setdefault("follow_redirects", True) + super().__init__(**kwargs) + + +if TYPE_CHECKING: + DefaultHttpxClient = httpx.Client + """An alias to `httpx.Client` that provides the same defaults that this SDK + uses internally. + + This is useful because overriding the `http_client` with your own instance of + `httpx.Client` will result in httpx's defaults being used, not ours. + """ +else: + DefaultHttpxClient = _DefaultHttpxClient + + +class SyncHttpxClientWrapper(DefaultHttpxClient): + def __del__(self) -> None: + if self.is_closed: + return + + try: + self.close() + except Exception: + pass + + +class SyncAPIClient(BaseClient[httpx.Client, Stream[Any]]): + _client: httpx.Client + _default_stream_cls: type[Stream[Any]] | None = None + + def __init__( + self, + *, + version: str, + base_url: str | URL, + max_retries: int = DEFAULT_MAX_RETRIES, + timeout: float | Timeout | None | NotGiven = NOT_GIVEN, + http_client: httpx.Client | None = None, + custom_headers: Mapping[str, str] | None = None, + custom_query: Mapping[str, object] | None = None, + _strict_response_validation: bool, + ) -> None: + if not is_given(timeout): + # if the user passed in a custom http client with a non-default + # timeout set then we use that timeout. + # + # note: there is an edge case here where the user passes in a client + # where they've explicitly set the timeout to match the default timeout + # as this check is structural, meaning that we'll think they didn't + # pass in a timeout and will ignore it + if http_client and http_client.timeout != HTTPX_DEFAULT_TIMEOUT: + timeout = http_client.timeout + else: + timeout = DEFAULT_TIMEOUT + + if http_client is not None and not isinstance(http_client, httpx.Client): # pyright: ignore[reportUnnecessaryIsInstance] + raise TypeError( + f"Invalid `http_client` argument; Expected an instance of `httpx.Client` but got {type(http_client)}" + ) + + super().__init__( + version=version, + # cast to a valid type because mypy doesn't understand our type narrowing + timeout=cast(Timeout, timeout), + base_url=base_url, + max_retries=max_retries, + custom_query=custom_query, + custom_headers=custom_headers, + _strict_response_validation=_strict_response_validation, + ) + self._client = http_client or SyncHttpxClientWrapper( + base_url=base_url, + # cast to a valid type because mypy doesn't understand our type narrowing + timeout=cast(Timeout, timeout), + ) + + def is_closed(self) -> bool: + return self._client.is_closed + + def close(self) -> None: + """Close the underlying HTTPX client. + + The client will *not* be usable after this. + """ + # If an error is thrown while constructing a client, self._client + # may not be present + if hasattr(self, "_client"): + self._client.close() + + def __enter__(self: _T) -> _T: + return self + + def __exit__( + self, + exc_type: type[BaseException] | None, + exc: BaseException | None, + exc_tb: TracebackType | None, + ) -> None: + self.close() + + def _prepare_options( + self, + options: FinalRequestOptions, # noqa: ARG002 + ) -> FinalRequestOptions: + """Hook for mutating the given options""" + return options + + def _prepare_request( + self, + request: httpx.Request, # noqa: ARG002 + ) -> None: + """This method is used as a callback for mutating the `Request` object + after it has been constructed. + This is useful for cases where you want to add certain headers based off of + the request properties, e.g. `url`, `method` etc. + """ + return None + + @overload + def request( + self, + cast_to: Type[ResponseT], + options: FinalRequestOptions, + *, + stream: Literal[True], + stream_cls: Type[_StreamT], + ) -> _StreamT: ... + + @overload + def request( + self, + cast_to: Type[ResponseT], + options: FinalRequestOptions, + *, + stream: Literal[False] = False, + ) -> ResponseT: ... + + @overload + def request( + self, + cast_to: Type[ResponseT], + options: FinalRequestOptions, + *, + stream: bool = False, + stream_cls: Type[_StreamT] | None = None, + ) -> ResponseT | _StreamT: ... + + def request( + self, + cast_to: Type[ResponseT], + options: FinalRequestOptions, + *, + stream: bool = False, + stream_cls: type[_StreamT] | None = None, + ) -> ResponseT | _StreamT: + cast_to = self._maybe_override_cast_to(cast_to, options) + + # create a copy of the options we were given so that if the + # options are mutated later & we then retry, the retries are + # given the original options + input_options = model_copy(options) + if input_options.idempotency_key is None and input_options.method.lower() != "get": + # ensure the idempotency key is reused between requests + input_options.idempotency_key = self._idempotency_key() + + response: httpx.Response | None = None + max_retries = input_options.get_max_retries(self.max_retries) + + retries_taken = 0 + for retries_taken in range(max_retries + 1): + options = model_copy(input_options) + options = self._prepare_options(options) + + remaining_retries = max_retries - retries_taken + request = self._build_request(options, retries_taken=retries_taken) + self._prepare_request(request) + + kwargs: HttpxSendArgs = {} + if self.custom_auth is not None: + kwargs["auth"] = self.custom_auth + + if options.follow_redirects is not None: + kwargs["follow_redirects"] = options.follow_redirects + + log.debug("Sending HTTP Request: %s %s", request.method, request.url) + + response = None + try: + response = self._client.send( + request, + stream=stream or self._should_stream_response_body(request=request), + **kwargs, + ) + except httpx.TimeoutException as err: + log.debug("Encountered httpx.TimeoutException", exc_info=True) + + if remaining_retries > 0: + self._sleep_for_retry( + retries_taken=retries_taken, + max_retries=max_retries, + options=input_options, + response=None, + ) + continue + + log.debug("Raising timeout error") + raise APITimeoutError(request=request) from err + except Exception as err: + log.debug("Encountered Exception", exc_info=True) + + if remaining_retries > 0: + self._sleep_for_retry( + retries_taken=retries_taken, + max_retries=max_retries, + options=input_options, + response=None, + ) + continue + + log.debug("Raising connection error") + raise APIConnectionError(request=request) from err + + log.debug( + 'HTTP Response: %s %s "%i %s" %s', + request.method, + request.url, + response.status_code, + response.reason_phrase, + response.headers, + ) + + try: + response.raise_for_status() + except httpx.HTTPStatusError as err: # thrown on 4xx and 5xx status code + log.debug("Encountered httpx.HTTPStatusError", exc_info=True) + + if remaining_retries > 0 and self._should_retry(err.response): + err.response.close() + self._sleep_for_retry( + retries_taken=retries_taken, + max_retries=max_retries, + options=input_options, + response=response, + ) + continue + + # If the response is streamed then we need to explicitly read the response + # to completion before attempting to access the response text. + if not err.response.is_closed: + err.response.read() + + log.debug("Re-raising status error") + raise self._make_status_error_from_response(err.response) from None + + break + + assert response is not None, "could not resolve response (should never happen)" + return self._process_response( + cast_to=cast_to, + options=options, + response=response, + stream=stream, + stream_cls=stream_cls, + retries_taken=retries_taken, + ) + + def _sleep_for_retry( + self, *, retries_taken: int, max_retries: int, options: FinalRequestOptions, response: httpx.Response | None + ) -> None: + remaining_retries = max_retries - retries_taken + if remaining_retries == 1: + log.debug("1 retry left") + else: + log.debug("%i retries left", remaining_retries) + + timeout = self._calculate_retry_timeout(remaining_retries, options, response.headers if response else None) + log.info("Retrying request to %s in %f seconds", options.url, timeout) + + time.sleep(timeout) + + def _process_response( + self, + *, + cast_to: Type[ResponseT], + options: FinalRequestOptions, + response: httpx.Response, + stream: bool, + stream_cls: type[Stream[Any]] | type[AsyncStream[Any]] | None, + retries_taken: int = 0, + ) -> ResponseT: + origin = get_origin(cast_to) or cast_to + + if ( + inspect.isclass(origin) + and issubclass(origin, BaseAPIResponse) + # we only want to actually return the custom BaseAPIResponse class if we're + # returning the raw response, or if we're not streaming SSE, as if we're streaming + # SSE then `cast_to` doesn't actively reflect the type we need to parse into + and (not stream or bool(response.request.headers.get(RAW_RESPONSE_HEADER))) + ): + if not issubclass(origin, APIResponse): + raise TypeError(f"API Response types must subclass {APIResponse}; Received {origin}") + + response_cls = cast("type[BaseAPIResponse[Any]]", cast_to) + return cast( + ResponseT, + response_cls( + raw=response, + client=self, + cast_to=extract_response_type(response_cls), + stream=stream, + stream_cls=stream_cls, + options=options, + retries_taken=retries_taken, + ), + ) + + if cast_to == httpx.Response: + return cast(ResponseT, response) + + api_response = APIResponse( + raw=response, + client=self, + cast_to=cast("type[ResponseT]", cast_to), # pyright: ignore[reportUnnecessaryCast] + stream=stream, + stream_cls=stream_cls, + options=options, + retries_taken=retries_taken, + ) + if bool(response.request.headers.get(RAW_RESPONSE_HEADER)): + return cast(ResponseT, api_response) + + return api_response.parse() + + def _request_api_list( + self, + model: Type[object], + page: Type[SyncPageT], + options: FinalRequestOptions, + ) -> SyncPageT: + def _parser(resp: SyncPageT) -> SyncPageT: + resp._set_private_attributes( + client=self, + model=model, + options=options, + ) + return resp + + options.post_parser = _parser + + return self.request(page, options, stream=False) + + @overload + def get( + self, + path: str, + *, + cast_to: Type[ResponseT], + options: RequestOptions = {}, + stream: Literal[False] = False, + ) -> ResponseT: ... + + @overload + def get( + self, + path: str, + *, + cast_to: Type[ResponseT], + options: RequestOptions = {}, + stream: Literal[True], + stream_cls: type[_StreamT], + ) -> _StreamT: ... + + @overload + def get( + self, + path: str, + *, + cast_to: Type[ResponseT], + options: RequestOptions = {}, + stream: bool, + stream_cls: type[_StreamT] | None = None, + ) -> ResponseT | _StreamT: ... + + def get( + self, + path: str, + *, + cast_to: Type[ResponseT], + options: RequestOptions = {}, + stream: bool = False, + stream_cls: type[_StreamT] | None = None, + ) -> ResponseT | _StreamT: + opts = FinalRequestOptions.construct(method="get", url=path, **options) + # cast is required because mypy complains about returning Any even though + # it understands the type variables + return cast(ResponseT, self.request(cast_to, opts, stream=stream, stream_cls=stream_cls)) + + @overload + def post( + self, + path: str, + *, + cast_to: Type[ResponseT], + body: Body | None = None, + options: RequestOptions = {}, + files: RequestFiles | None = None, + stream: Literal[False] = False, + ) -> ResponseT: ... + + @overload + def post( + self, + path: str, + *, + cast_to: Type[ResponseT], + body: Body | None = None, + options: RequestOptions = {}, + files: RequestFiles | None = None, + stream: Literal[True], + stream_cls: type[_StreamT], + ) -> _StreamT: ... + + @overload + def post( + self, + path: str, + *, + cast_to: Type[ResponseT], + body: Body | None = None, + options: RequestOptions = {}, + files: RequestFiles | None = None, + stream: bool, + stream_cls: type[_StreamT] | None = None, + ) -> ResponseT | _StreamT: ... + + def post( + self, + path: str, + *, + cast_to: Type[ResponseT], + body: Body | None = None, + options: RequestOptions = {}, + files: RequestFiles | None = None, + stream: bool = False, + stream_cls: type[_StreamT] | None = None, + ) -> ResponseT | _StreamT: + opts = FinalRequestOptions.construct( + method="post", url=path, json_data=body, files=to_httpx_files(files), **options + ) + return cast(ResponseT, self.request(cast_to, opts, stream=stream, stream_cls=stream_cls)) + + def patch( + self, + path: str, + *, + cast_to: Type[ResponseT], + body: Body | None = None, + options: RequestOptions = {}, + ) -> ResponseT: + opts = FinalRequestOptions.construct(method="patch", url=path, json_data=body, **options) + return self.request(cast_to, opts) + + def put( + self, + path: str, + *, + cast_to: Type[ResponseT], + body: Body | None = None, + files: RequestFiles | None = None, + options: RequestOptions = {}, + ) -> ResponseT: + opts = FinalRequestOptions.construct( + method="put", url=path, json_data=body, files=to_httpx_files(files), **options + ) + return self.request(cast_to, opts) + + def delete( + self, + path: str, + *, + cast_to: Type[ResponseT], + body: Body | None = None, + options: RequestOptions = {}, + ) -> ResponseT: + opts = FinalRequestOptions.construct(method="delete", url=path, json_data=body, **options) + return self.request(cast_to, opts) + + def get_api_list( + self, + path: str, + *, + model: Type[object], + page: Type[SyncPageT], + body: Body | None = None, + options: RequestOptions = {}, + method: str = "get", + ) -> SyncPageT: + opts = FinalRequestOptions.construct(method=method, url=path, json_data=body, **options) + return self._request_api_list(model, page, opts) + + +class _DefaultAsyncHttpxClient(httpx.AsyncClient): + def __init__(self, **kwargs: Any) -> None: + kwargs.setdefault("timeout", DEFAULT_TIMEOUT) + kwargs.setdefault("limits", DEFAULT_CONNECTION_LIMITS) + kwargs.setdefault("follow_redirects", True) + super().__init__(**kwargs) + + +try: + import httpx_aiohttp +except ImportError: + + class _DefaultAioHttpClient(httpx.AsyncClient): + def __init__(self, **_kwargs: Any) -> None: + raise RuntimeError("To use the aiohttp client you must have installed the package with the `aiohttp` extra") +else: + + class _DefaultAioHttpClient(httpx_aiohttp.HttpxAiohttpClient): # type: ignore + def __init__(self, **kwargs: Any) -> None: + kwargs.setdefault("timeout", DEFAULT_TIMEOUT) + kwargs.setdefault("limits", DEFAULT_CONNECTION_LIMITS) + kwargs.setdefault("follow_redirects", True) + + super().__init__(**kwargs) + + +if TYPE_CHECKING: + DefaultAsyncHttpxClient = httpx.AsyncClient + """An alias to `httpx.AsyncClient` that provides the same defaults that this SDK + uses internally. + + This is useful because overriding the `http_client` with your own instance of + `httpx.AsyncClient` will result in httpx's defaults being used, not ours. + """ + + DefaultAioHttpClient = httpx.AsyncClient + """An alias to `httpx.AsyncClient` that changes the default HTTP transport to `aiohttp`.""" +else: + DefaultAsyncHttpxClient = _DefaultAsyncHttpxClient + DefaultAioHttpClient = _DefaultAioHttpClient + + +class AsyncHttpxClientWrapper(DefaultAsyncHttpxClient): + def __del__(self) -> None: + if self.is_closed: + return + + try: + # TODO(someday): support non asyncio runtimes here + asyncio.get_running_loop().create_task(self.aclose()) + except Exception: + pass + + +class AsyncAPIClient(BaseClient[httpx.AsyncClient, AsyncStream[Any]]): + _client: httpx.AsyncClient + _default_stream_cls: type[AsyncStream[Any]] | None = None + + def __init__( + self, + *, + version: str, + base_url: str | URL, + _strict_response_validation: bool, + max_retries: int = DEFAULT_MAX_RETRIES, + timeout: float | Timeout | None | NotGiven = NOT_GIVEN, + http_client: httpx.AsyncClient | None = None, + custom_headers: Mapping[str, str] | None = None, + custom_query: Mapping[str, object] | None = None, + ) -> None: + if not is_given(timeout): + # if the user passed in a custom http client with a non-default + # timeout set then we use that timeout. + # + # note: there is an edge case here where the user passes in a client + # where they've explicitly set the timeout to match the default timeout + # as this check is structural, meaning that we'll think they didn't + # pass in a timeout and will ignore it + if http_client and http_client.timeout != HTTPX_DEFAULT_TIMEOUT: + timeout = http_client.timeout + else: + timeout = DEFAULT_TIMEOUT + + if http_client is not None and not isinstance(http_client, httpx.AsyncClient): # pyright: ignore[reportUnnecessaryIsInstance] + raise TypeError( + f"Invalid `http_client` argument; Expected an instance of `httpx.AsyncClient` but got {type(http_client)}" + ) + + super().__init__( + version=version, + base_url=base_url, + # cast to a valid type because mypy doesn't understand our type narrowing + timeout=cast(Timeout, timeout), + max_retries=max_retries, + custom_query=custom_query, + custom_headers=custom_headers, + _strict_response_validation=_strict_response_validation, + ) + self._client = http_client or AsyncHttpxClientWrapper( + base_url=base_url, + # cast to a valid type because mypy doesn't understand our type narrowing + timeout=cast(Timeout, timeout), + ) + + def is_closed(self) -> bool: + return self._client.is_closed + + async def close(self) -> None: + """Close the underlying HTTPX client. + + The client will *not* be usable after this. + """ + await self._client.aclose() + + async def __aenter__(self: _T) -> _T: + return self + + async def __aexit__( + self, + exc_type: type[BaseException] | None, + exc: BaseException | None, + exc_tb: TracebackType | None, + ) -> None: + await self.close() + + async def _prepare_options( + self, + options: FinalRequestOptions, # noqa: ARG002 + ) -> FinalRequestOptions: + """Hook for mutating the given options""" + return options + + async def _prepare_request( + self, + request: httpx.Request, # noqa: ARG002 + ) -> None: + """This method is used as a callback for mutating the `Request` object + after it has been constructed. + This is useful for cases where you want to add certain headers based off of + the request properties, e.g. `url`, `method` etc. + """ + return None + + @overload + async def request( + self, + cast_to: Type[ResponseT], + options: FinalRequestOptions, + *, + stream: Literal[False] = False, + ) -> ResponseT: ... + + @overload + async def request( + self, + cast_to: Type[ResponseT], + options: FinalRequestOptions, + *, + stream: Literal[True], + stream_cls: type[_AsyncStreamT], + ) -> _AsyncStreamT: ... + + @overload + async def request( + self, + cast_to: Type[ResponseT], + options: FinalRequestOptions, + *, + stream: bool, + stream_cls: type[_AsyncStreamT] | None = None, + ) -> ResponseT | _AsyncStreamT: ... + + async def request( + self, + cast_to: Type[ResponseT], + options: FinalRequestOptions, + *, + stream: bool = False, + stream_cls: type[_AsyncStreamT] | None = None, + ) -> ResponseT | _AsyncStreamT: + if self._platform is None: + # `get_platform` can make blocking IO calls so we + # execute it earlier while we are in an async context + self._platform = await asyncify(get_platform)() + + cast_to = self._maybe_override_cast_to(cast_to, options) + + # create a copy of the options we were given so that if the + # options are mutated later & we then retry, the retries are + # given the original options + input_options = model_copy(options) + if input_options.idempotency_key is None and input_options.method.lower() != "get": + # ensure the idempotency key is reused between requests + input_options.idempotency_key = self._idempotency_key() + + response: httpx.Response | None = None + max_retries = input_options.get_max_retries(self.max_retries) + + retries_taken = 0 + for retries_taken in range(max_retries + 1): + options = model_copy(input_options) + options = await self._prepare_options(options) + + remaining_retries = max_retries - retries_taken + request = self._build_request(options, retries_taken=retries_taken) + await self._prepare_request(request) + + kwargs: HttpxSendArgs = {} + if self.custom_auth is not None: + kwargs["auth"] = self.custom_auth + + if options.follow_redirects is not None: + kwargs["follow_redirects"] = options.follow_redirects + + log.debug("Sending HTTP Request: %s %s", request.method, request.url) + + response = None + try: + response = await self._client.send( + request, + stream=stream or self._should_stream_response_body(request=request), + **kwargs, + ) + except httpx.TimeoutException as err: + log.debug("Encountered httpx.TimeoutException", exc_info=True) + + if remaining_retries > 0: + await self._sleep_for_retry( + retries_taken=retries_taken, + max_retries=max_retries, + options=input_options, + response=None, + ) + continue + + log.debug("Raising timeout error") + raise APITimeoutError(request=request) from err + except Exception as err: + log.debug("Encountered Exception", exc_info=True) + + if remaining_retries > 0: + await self._sleep_for_retry( + retries_taken=retries_taken, + max_retries=max_retries, + options=input_options, + response=None, + ) + continue + + log.debug("Raising connection error") + raise APIConnectionError(request=request) from err + + log.debug( + 'HTTP Response: %s %s "%i %s" %s', + request.method, + request.url, + response.status_code, + response.reason_phrase, + response.headers, + ) + + try: + response.raise_for_status() + except httpx.HTTPStatusError as err: # thrown on 4xx and 5xx status code + log.debug("Encountered httpx.HTTPStatusError", exc_info=True) + + if remaining_retries > 0 and self._should_retry(err.response): + await err.response.aclose() + await self._sleep_for_retry( + retries_taken=retries_taken, + max_retries=max_retries, + options=input_options, + response=response, + ) + continue + + # If the response is streamed then we need to explicitly read the response + # to completion before attempting to access the response text. + if not err.response.is_closed: + await err.response.aread() + + log.debug("Re-raising status error") + raise self._make_status_error_from_response(err.response) from None + + break + + assert response is not None, "could not resolve response (should never happen)" + return await self._process_response( + cast_to=cast_to, + options=options, + response=response, + stream=stream, + stream_cls=stream_cls, + retries_taken=retries_taken, + ) + + async def _sleep_for_retry( + self, *, retries_taken: int, max_retries: int, options: FinalRequestOptions, response: httpx.Response | None + ) -> None: + remaining_retries = max_retries - retries_taken + if remaining_retries == 1: + log.debug("1 retry left") + else: + log.debug("%i retries left", remaining_retries) + + timeout = self._calculate_retry_timeout(remaining_retries, options, response.headers if response else None) + log.info("Retrying request to %s in %f seconds", options.url, timeout) + + await anyio.sleep(timeout) + + async def _process_response( + self, + *, + cast_to: Type[ResponseT], + options: FinalRequestOptions, + response: httpx.Response, + stream: bool, + stream_cls: type[Stream[Any]] | type[AsyncStream[Any]] | None, + retries_taken: int = 0, + ) -> ResponseT: + origin = get_origin(cast_to) or cast_to + + if ( + inspect.isclass(origin) + and issubclass(origin, BaseAPIResponse) + # we only want to actually return the custom BaseAPIResponse class if we're + # returning the raw response, or if we're not streaming SSE, as if we're streaming + # SSE then `cast_to` doesn't actively reflect the type we need to parse into + and (not stream or bool(response.request.headers.get(RAW_RESPONSE_HEADER))) + ): + if not issubclass(origin, AsyncAPIResponse): + raise TypeError(f"API Response types must subclass {AsyncAPIResponse}; Received {origin}") + + response_cls = cast("type[BaseAPIResponse[Any]]", cast_to) + return cast( + "ResponseT", + response_cls( + raw=response, + client=self, + cast_to=extract_response_type(response_cls), + stream=stream, + stream_cls=stream_cls, + options=options, + retries_taken=retries_taken, + ), + ) + + if cast_to == httpx.Response: + return cast(ResponseT, response) + + api_response = AsyncAPIResponse( + raw=response, + client=self, + cast_to=cast("type[ResponseT]", cast_to), # pyright: ignore[reportUnnecessaryCast] + stream=stream, + stream_cls=stream_cls, + options=options, + retries_taken=retries_taken, + ) + if bool(response.request.headers.get(RAW_RESPONSE_HEADER)): + return cast(ResponseT, api_response) + + return await api_response.parse() + + def _request_api_list( + self, + model: Type[_T], + page: Type[AsyncPageT], + options: FinalRequestOptions, + ) -> AsyncPaginator[_T, AsyncPageT]: + return AsyncPaginator(client=self, options=options, page_cls=page, model=model) + + @overload + async def get( + self, + path: str, + *, + cast_to: Type[ResponseT], + options: RequestOptions = {}, + stream: Literal[False] = False, + ) -> ResponseT: ... + + @overload + async def get( + self, + path: str, + *, + cast_to: Type[ResponseT], + options: RequestOptions = {}, + stream: Literal[True], + stream_cls: type[_AsyncStreamT], + ) -> _AsyncStreamT: ... + + @overload + async def get( + self, + path: str, + *, + cast_to: Type[ResponseT], + options: RequestOptions = {}, + stream: bool, + stream_cls: type[_AsyncStreamT] | None = None, + ) -> ResponseT | _AsyncStreamT: ... + + async def get( + self, + path: str, + *, + cast_to: Type[ResponseT], + options: RequestOptions = {}, + stream: bool = False, + stream_cls: type[_AsyncStreamT] | None = None, + ) -> ResponseT | _AsyncStreamT: + opts = FinalRequestOptions.construct(method="get", url=path, **options) + return await self.request(cast_to, opts, stream=stream, stream_cls=stream_cls) + + @overload + async def post( + self, + path: str, + *, + cast_to: Type[ResponseT], + body: Body | None = None, + files: RequestFiles | None = None, + options: RequestOptions = {}, + stream: Literal[False] = False, + ) -> ResponseT: ... + + @overload + async def post( + self, + path: str, + *, + cast_to: Type[ResponseT], + body: Body | None = None, + files: RequestFiles | None = None, + options: RequestOptions = {}, + stream: Literal[True], + stream_cls: type[_AsyncStreamT], + ) -> _AsyncStreamT: ... + + @overload + async def post( + self, + path: str, + *, + cast_to: Type[ResponseT], + body: Body | None = None, + files: RequestFiles | None = None, + options: RequestOptions = {}, + stream: bool, + stream_cls: type[_AsyncStreamT] | None = None, + ) -> ResponseT | _AsyncStreamT: ... + + async def post( + self, + path: str, + *, + cast_to: Type[ResponseT], + body: Body | None = None, + files: RequestFiles | None = None, + options: RequestOptions = {}, + stream: bool = False, + stream_cls: type[_AsyncStreamT] | None = None, + ) -> ResponseT | _AsyncStreamT: + opts = FinalRequestOptions.construct( + method="post", url=path, json_data=body, files=await async_to_httpx_files(files), **options + ) + return await self.request(cast_to, opts, stream=stream, stream_cls=stream_cls) + + async def patch( + self, + path: str, + *, + cast_to: Type[ResponseT], + body: Body | None = None, + options: RequestOptions = {}, + ) -> ResponseT: + opts = FinalRequestOptions.construct(method="patch", url=path, json_data=body, **options) + return await self.request(cast_to, opts) + + async def put( + self, + path: str, + *, + cast_to: Type[ResponseT], + body: Body | None = None, + files: RequestFiles | None = None, + options: RequestOptions = {}, + ) -> ResponseT: + opts = FinalRequestOptions.construct( + method="put", url=path, json_data=body, files=await async_to_httpx_files(files), **options + ) + return await self.request(cast_to, opts) + + async def delete( + self, + path: str, + *, + cast_to: Type[ResponseT], + body: Body | None = None, + options: RequestOptions = {}, + ) -> ResponseT: + opts = FinalRequestOptions.construct(method="delete", url=path, json_data=body, **options) + return await self.request(cast_to, opts) + + def get_api_list( + self, + path: str, + *, + model: Type[_T], + page: Type[AsyncPageT], + body: Body | None = None, + options: RequestOptions = {}, + method: str = "get", + ) -> AsyncPaginator[_T, AsyncPageT]: + opts = FinalRequestOptions.construct(method=method, url=path, json_data=body, **options) + return self._request_api_list(model, page, opts) + + +def make_request_options( + *, + query: Query | None = None, + extra_headers: Headers | None = None, + extra_query: Query | None = None, + extra_body: Body | None = None, + idempotency_key: str | None = None, + timeout: float | httpx.Timeout | None | NotGiven = NOT_GIVEN, + post_parser: PostParser | NotGiven = NOT_GIVEN, +) -> RequestOptions: + """Create a dict of type RequestOptions without keys of NotGiven values.""" + options: RequestOptions = {} + if extra_headers is not None: + options["headers"] = extra_headers + + if extra_body is not None: + options["extra_json"] = cast(AnyMapping, extra_body) + + if query is not None: + options["params"] = query + + if extra_query is not None: + options["params"] = {**options.get("params", {}), **extra_query} + + if not isinstance(timeout, NotGiven): + options["timeout"] = timeout + + if idempotency_key is not None: + options["idempotency_key"] = idempotency_key + + if is_given(post_parser): + # internal + options["post_parser"] = post_parser # type: ignore + + return options + + +class ForceMultipartDict(Dict[str, None]): + def __bool__(self) -> bool: + return True + + +class OtherPlatform: + def __init__(self, name: str) -> None: + self.name = name + + @override + def __str__(self) -> str: + return f"Other:{self.name}" + + +Platform = Union[ + OtherPlatform, + Literal[ + "MacOS", + "Linux", + "Windows", + "FreeBSD", + "OpenBSD", + "iOS", + "Android", + "Unknown", + ], +] + + +def get_platform() -> Platform: + try: + system = platform.system().lower() + platform_name = platform.platform().lower() + except Exception: + return "Unknown" + + if "iphone" in platform_name or "ipad" in platform_name: + # Tested using Python3IDE on an iPhone 11 and Pythonista on an iPad 7 + # system is Darwin and platform_name is a string like: + # - Darwin-21.6.0-iPhone12,1-64bit + # - Darwin-21.6.0-iPad7,11-64bit + return "iOS" + + if system == "darwin": + return "MacOS" + + if system == "windows": + return "Windows" + + if "android" in platform_name: + # Tested using Pydroid 3 + # system is Linux and platform_name is a string like 'Linux-5.10.81-android12-9-00001-geba40aecb3b7-ab8534902-aarch64-with-libc' + return "Android" + + if system == "linux": + # https://distro.readthedocs.io/en/latest/#distro.id + distro_id = distro.id() + if distro_id == "freebsd": + return "FreeBSD" + + if distro_id == "openbsd": + return "OpenBSD" + + return "Linux" + + if platform_name: + return OtherPlatform(platform_name) + + return "Unknown" + + +@lru_cache(maxsize=None) +def platform_headers(version: str, *, platform: Platform | None) -> Dict[str, str]: + return { + "X-Stainless-Lang": "python", + "X-Stainless-Package-Version": version, + "X-Stainless-OS": str(platform or get_platform()), + "X-Stainless-Arch": str(get_architecture()), + "X-Stainless-Runtime": get_python_runtime(), + "X-Stainless-Runtime-Version": get_python_version(), + } + + +class OtherArch: + def __init__(self, name: str) -> None: + self.name = name + + @override + def __str__(self) -> str: + return f"other:{self.name}" + + +Arch = Union[OtherArch, Literal["x32", "x64", "arm", "arm64", "unknown"]] + + +def get_python_runtime() -> str: + try: + return platform.python_implementation() + except Exception: + return "unknown" + + +def get_python_version() -> str: + try: + return platform.python_version() + except Exception: + return "unknown" + + +def get_architecture() -> Arch: + try: + machine = platform.machine().lower() + except Exception: + return "unknown" + + if machine in ("arm64", "aarch64"): + return "arm64" + + # TODO: untested + if machine == "arm": + return "arm" + + if machine == "x86_64": + return "x64" + + # TODO: untested + if sys.maxsize <= 2**32: + return "x32" + + if machine: + return OtherArch(machine) + + return "unknown" + + +def _merge_mappings( + obj1: Mapping[_T_co, Union[_T, Omit]], + obj2: Mapping[_T_co, Union[_T, Omit]], +) -> Dict[_T_co, _T]: + """Merge two mappings of the same type, removing any values that are instances of `Omit`. + + In cases with duplicate keys the second mapping takes precedence. + """ + merged = {**obj1, **obj2} + return {key: value for key, value in merged.items() if not isinstance(value, Omit)} diff --git a/src/opencode_ai/_client.py b/src/opencode_ai/_client.py new file mode 100644 index 0000000..133e1e5 --- /dev/null +++ b/src/opencode_ai/_client.py @@ -0,0 +1,446 @@ +# File generated from our OpenAPI spec by Stainless. See CONTRIBUTING.md for details. + +from __future__ import annotations + +import os +from typing import Any, Union, Mapping +from typing_extensions import Self, override + +import httpx + +from . import _exceptions +from ._qs import Querystring +from ._types import ( + NOT_GIVEN, + Omit, + Timeout, + NotGiven, + Transport, + ProxiesTypes, + RequestOptions, +) +from ._utils import is_given, get_async_library +from ._version import __version__ +from .resources import app, tui, file, find, path, agent, event, config, command, project +from ._streaming import Stream as Stream, AsyncStream as AsyncStream +from ._exceptions import APIStatusError +from ._base_client import ( + DEFAULT_MAX_RETRIES, + SyncAPIClient, + AsyncAPIClient, +) +from .resources.session import session + +__all__ = [ + "Timeout", + "Transport", + "ProxiesTypes", + "RequestOptions", + "Opencode", + "AsyncOpencode", + "Client", + "AsyncClient", +] + + +class Opencode(SyncAPIClient): + event: event.EventResource + path: path.PathResource + app: app.AppResource + agent: agent.AgentResource + find: find.FindResource + file: file.FileResource + config: config.ConfigResource + command: command.CommandResource + project: project.ProjectResource + session: session.SessionResource + tui: tui.TuiResource + with_raw_response: OpencodeWithRawResponse + with_streaming_response: OpencodeWithStreamedResponse + + # client options + + def __init__( + self, + *, + base_url: str | httpx.URL | None = None, + timeout: Union[float, Timeout, None, NotGiven] = NOT_GIVEN, + max_retries: int = DEFAULT_MAX_RETRIES, + default_headers: Mapping[str, str] | None = None, + default_query: Mapping[str, object] | None = None, + # Configure a custom httpx client. + # We provide a `DefaultHttpxClient` class that you can pass to retain the default values we use for `limits`, `timeout` & `follow_redirects`. + # See the [httpx documentation](https://www.python-httpx.org/api/#client) for more details. + http_client: httpx.Client | None = None, + # Enable or disable schema validation for data returned by the API. + # When enabled an error APIResponseValidationError is raised + # if the API responds with invalid data for the expected schema. + # + # This parameter may be removed or changed in the future. + # If you rely on this feature, please open a GitHub issue + # outlining your use-case to help us decide if it should be + # part of our public interface in the future. + _strict_response_validation: bool = False, + ) -> None: + """Construct a new synchronous Opencode client instance.""" + if base_url is None: + base_url = os.environ.get("OPENCODE_BASE_URL") + if base_url is None: + base_url = f"http://localhost:54321" + + super().__init__( + version=__version__, + base_url=base_url, + max_retries=max_retries, + timeout=timeout, + http_client=http_client, + custom_headers=default_headers, + custom_query=default_query, + _strict_response_validation=_strict_response_validation, + ) + + self._default_stream_cls = Stream + + self.event = event.EventResource(self) + self.path = path.PathResource(self) + self.app = app.AppResource(self) + self.agent = agent.AgentResource(self) + self.find = find.FindResource(self) + self.file = file.FileResource(self) + self.config = config.ConfigResource(self) + self.command = command.CommandResource(self) + self.project = project.ProjectResource(self) + self.session = session.SessionResource(self) + self.tui = tui.TuiResource(self) + self.with_raw_response = OpencodeWithRawResponse(self) + self.with_streaming_response = OpencodeWithStreamedResponse(self) + + @property + @override + def qs(self) -> Querystring: + return Querystring(array_format="comma") + + @property + @override + def default_headers(self) -> dict[str, str | Omit]: + return { + **super().default_headers, + "X-Stainless-Async": "false", + **self._custom_headers, + } + + def copy( + self, + *, + base_url: str | httpx.URL | None = None, + timeout: float | Timeout | None | NotGiven = NOT_GIVEN, + http_client: httpx.Client | None = None, + max_retries: int | NotGiven = NOT_GIVEN, + default_headers: Mapping[str, str] | None = None, + set_default_headers: Mapping[str, str] | None = None, + default_query: Mapping[str, object] | None = None, + set_default_query: Mapping[str, object] | None = None, + _extra_kwargs: Mapping[str, Any] = {}, + ) -> Self: + """ + Create a new client instance re-using the same options given to the current client with optional overriding. + """ + if default_headers is not None and set_default_headers is not None: + raise ValueError("The `default_headers` and `set_default_headers` arguments are mutually exclusive") + + if default_query is not None and set_default_query is not None: + raise ValueError("The `default_query` and `set_default_query` arguments are mutually exclusive") + + headers = self._custom_headers + if default_headers is not None: + headers = {**headers, **default_headers} + elif set_default_headers is not None: + headers = set_default_headers + + params = self._custom_query + if default_query is not None: + params = {**params, **default_query} + elif set_default_query is not None: + params = set_default_query + + http_client = http_client or self._client + return self.__class__( + base_url=base_url or self.base_url, + timeout=self.timeout if isinstance(timeout, NotGiven) else timeout, + http_client=http_client, + max_retries=max_retries if is_given(max_retries) else self.max_retries, + default_headers=headers, + default_query=params, + **_extra_kwargs, + ) + + # Alias for `copy` for nicer inline usage, e.g. + # client.with_options(timeout=10).foo.create(...) + with_options = copy + + @override + def _make_status_error( + self, + err_msg: str, + *, + body: object, + response: httpx.Response, + ) -> APIStatusError: + if response.status_code == 400: + return _exceptions.BadRequestError(err_msg, response=response, body=body) + + if response.status_code == 401: + return _exceptions.AuthenticationError(err_msg, response=response, body=body) + + if response.status_code == 403: + return _exceptions.PermissionDeniedError(err_msg, response=response, body=body) + + if response.status_code == 404: + return _exceptions.NotFoundError(err_msg, response=response, body=body) + + if response.status_code == 409: + return _exceptions.ConflictError(err_msg, response=response, body=body) + + if response.status_code == 422: + return _exceptions.UnprocessableEntityError(err_msg, response=response, body=body) + + if response.status_code == 429: + return _exceptions.RateLimitError(err_msg, response=response, body=body) + + if response.status_code >= 500: + return _exceptions.InternalServerError(err_msg, response=response, body=body) + return APIStatusError(err_msg, response=response, body=body) + + +class AsyncOpencode(AsyncAPIClient): + event: event.AsyncEventResource + path: path.AsyncPathResource + app: app.AsyncAppResource + agent: agent.AsyncAgentResource + find: find.AsyncFindResource + file: file.AsyncFileResource + config: config.AsyncConfigResource + command: command.AsyncCommandResource + project: project.AsyncProjectResource + session: session.AsyncSessionResource + tui: tui.AsyncTuiResource + with_raw_response: AsyncOpencodeWithRawResponse + with_streaming_response: AsyncOpencodeWithStreamedResponse + + # client options + + def __init__( + self, + *, + base_url: str | httpx.URL | None = None, + timeout: Union[float, Timeout, None, NotGiven] = NOT_GIVEN, + max_retries: int = DEFAULT_MAX_RETRIES, + default_headers: Mapping[str, str] | None = None, + default_query: Mapping[str, object] | None = None, + # Configure a custom httpx client. + # We provide a `DefaultAsyncHttpxClient` class that you can pass to retain the default values we use for `limits`, `timeout` & `follow_redirects`. + # See the [httpx documentation](https://www.python-httpx.org/api/#asyncclient) for more details. + http_client: httpx.AsyncClient | None = None, + # Enable or disable schema validation for data returned by the API. + # When enabled an error APIResponseValidationError is raised + # if the API responds with invalid data for the expected schema. + # + # This parameter may be removed or changed in the future. + # If you rely on this feature, please open a GitHub issue + # outlining your use-case to help us decide if it should be + # part of our public interface in the future. + _strict_response_validation: bool = False, + ) -> None: + """Construct a new async AsyncOpencode client instance.""" + if base_url is None: + base_url = os.environ.get("OPENCODE_BASE_URL") + if base_url is None: + base_url = f"http://localhost:54321" + + super().__init__( + version=__version__, + base_url=base_url, + max_retries=max_retries, + timeout=timeout, + http_client=http_client, + custom_headers=default_headers, + custom_query=default_query, + _strict_response_validation=_strict_response_validation, + ) + + self._default_stream_cls = AsyncStream + + self.event = event.AsyncEventResource(self) + self.path = path.AsyncPathResource(self) + self.app = app.AsyncAppResource(self) + self.agent = agent.AsyncAgentResource(self) + self.find = find.AsyncFindResource(self) + self.file = file.AsyncFileResource(self) + self.config = config.AsyncConfigResource(self) + self.command = command.AsyncCommandResource(self) + self.project = project.AsyncProjectResource(self) + self.session = session.AsyncSessionResource(self) + self.tui = tui.AsyncTuiResource(self) + self.with_raw_response = AsyncOpencodeWithRawResponse(self) + self.with_streaming_response = AsyncOpencodeWithStreamedResponse(self) + + @property + @override + def qs(self) -> Querystring: + return Querystring(array_format="comma") + + @property + @override + def default_headers(self) -> dict[str, str | Omit]: + return { + **super().default_headers, + "X-Stainless-Async": f"async:{get_async_library()}", + **self._custom_headers, + } + + def copy( + self, + *, + base_url: str | httpx.URL | None = None, + timeout: float | Timeout | None | NotGiven = NOT_GIVEN, + http_client: httpx.AsyncClient | None = None, + max_retries: int | NotGiven = NOT_GIVEN, + default_headers: Mapping[str, str] | None = None, + set_default_headers: Mapping[str, str] | None = None, + default_query: Mapping[str, object] | None = None, + set_default_query: Mapping[str, object] | None = None, + _extra_kwargs: Mapping[str, Any] = {}, + ) -> Self: + """ + Create a new client instance re-using the same options given to the current client with optional overriding. + """ + if default_headers is not None and set_default_headers is not None: + raise ValueError("The `default_headers` and `set_default_headers` arguments are mutually exclusive") + + if default_query is not None and set_default_query is not None: + raise ValueError("The `default_query` and `set_default_query` arguments are mutually exclusive") + + headers = self._custom_headers + if default_headers is not None: + headers = {**headers, **default_headers} + elif set_default_headers is not None: + headers = set_default_headers + + params = self._custom_query + if default_query is not None: + params = {**params, **default_query} + elif set_default_query is not None: + params = set_default_query + + http_client = http_client or self._client + return self.__class__( + base_url=base_url or self.base_url, + timeout=self.timeout if isinstance(timeout, NotGiven) else timeout, + http_client=http_client, + max_retries=max_retries if is_given(max_retries) else self.max_retries, + default_headers=headers, + default_query=params, + **_extra_kwargs, + ) + + # Alias for `copy` for nicer inline usage, e.g. + # client.with_options(timeout=10).foo.create(...) + with_options = copy + + @override + def _make_status_error( + self, + err_msg: str, + *, + body: object, + response: httpx.Response, + ) -> APIStatusError: + if response.status_code == 400: + return _exceptions.BadRequestError(err_msg, response=response, body=body) + + if response.status_code == 401: + return _exceptions.AuthenticationError(err_msg, response=response, body=body) + + if response.status_code == 403: + return _exceptions.PermissionDeniedError(err_msg, response=response, body=body) + + if response.status_code == 404: + return _exceptions.NotFoundError(err_msg, response=response, body=body) + + if response.status_code == 409: + return _exceptions.ConflictError(err_msg, response=response, body=body) + + if response.status_code == 422: + return _exceptions.UnprocessableEntityError(err_msg, response=response, body=body) + + if response.status_code == 429: + return _exceptions.RateLimitError(err_msg, response=response, body=body) + + if response.status_code >= 500: + return _exceptions.InternalServerError(err_msg, response=response, body=body) + return APIStatusError(err_msg, response=response, body=body) + + +class OpencodeWithRawResponse: + def __init__(self, client: Opencode) -> None: + self.event = event.EventResourceWithRawResponse(client.event) + self.path = path.PathResourceWithRawResponse(client.path) + self.app = app.AppResourceWithRawResponse(client.app) + self.agent = agent.AgentResourceWithRawResponse(client.agent) + self.find = find.FindResourceWithRawResponse(client.find) + self.file = file.FileResourceWithRawResponse(client.file) + self.config = config.ConfigResourceWithRawResponse(client.config) + self.command = command.CommandResourceWithRawResponse(client.command) + self.project = project.ProjectResourceWithRawResponse(client.project) + self.session = session.SessionResourceWithRawResponse(client.session) + self.tui = tui.TuiResourceWithRawResponse(client.tui) + + +class AsyncOpencodeWithRawResponse: + def __init__(self, client: AsyncOpencode) -> None: + self.event = event.AsyncEventResourceWithRawResponse(client.event) + self.path = path.AsyncPathResourceWithRawResponse(client.path) + self.app = app.AsyncAppResourceWithRawResponse(client.app) + self.agent = agent.AsyncAgentResourceWithRawResponse(client.agent) + self.find = find.AsyncFindResourceWithRawResponse(client.find) + self.file = file.AsyncFileResourceWithRawResponse(client.file) + self.config = config.AsyncConfigResourceWithRawResponse(client.config) + self.command = command.AsyncCommandResourceWithRawResponse(client.command) + self.project = project.AsyncProjectResourceWithRawResponse(client.project) + self.session = session.AsyncSessionResourceWithRawResponse(client.session) + self.tui = tui.AsyncTuiResourceWithRawResponse(client.tui) + + +class OpencodeWithStreamedResponse: + def __init__(self, client: Opencode) -> None: + self.event = event.EventResourceWithStreamingResponse(client.event) + self.path = path.PathResourceWithStreamingResponse(client.path) + self.app = app.AppResourceWithStreamingResponse(client.app) + self.agent = agent.AgentResourceWithStreamingResponse(client.agent) + self.find = find.FindResourceWithStreamingResponse(client.find) + self.file = file.FileResourceWithStreamingResponse(client.file) + self.config = config.ConfigResourceWithStreamingResponse(client.config) + self.command = command.CommandResourceWithStreamingResponse(client.command) + self.project = project.ProjectResourceWithStreamingResponse(client.project) + self.session = session.SessionResourceWithStreamingResponse(client.session) + self.tui = tui.TuiResourceWithStreamingResponse(client.tui) + + +class AsyncOpencodeWithStreamedResponse: + def __init__(self, client: AsyncOpencode) -> None: + self.event = event.AsyncEventResourceWithStreamingResponse(client.event) + self.path = path.AsyncPathResourceWithStreamingResponse(client.path) + self.app = app.AsyncAppResourceWithStreamingResponse(client.app) + self.agent = agent.AsyncAgentResourceWithStreamingResponse(client.agent) + self.find = find.AsyncFindResourceWithStreamingResponse(client.find) + self.file = file.AsyncFileResourceWithStreamingResponse(client.file) + self.config = config.AsyncConfigResourceWithStreamingResponse(client.config) + self.command = command.AsyncCommandResourceWithStreamingResponse(client.command) + self.project = project.AsyncProjectResourceWithStreamingResponse(client.project) + self.session = session.AsyncSessionResourceWithStreamingResponse(client.session) + self.tui = tui.AsyncTuiResourceWithStreamingResponse(client.tui) + + +Client = Opencode + +AsyncClient = AsyncOpencode diff --git a/src/opencode_ai/_compat.py b/src/opencode_ai/_compat.py new file mode 100644 index 0000000..92d9ee6 --- /dev/null +++ b/src/opencode_ai/_compat.py @@ -0,0 +1,219 @@ +from __future__ import annotations + +from typing import TYPE_CHECKING, Any, Union, Generic, TypeVar, Callable, cast, overload +from datetime import date, datetime +from typing_extensions import Self, Literal + +import pydantic +from pydantic.fields import FieldInfo + +from ._types import IncEx, StrBytesIntFloat + +_T = TypeVar("_T") +_ModelT = TypeVar("_ModelT", bound=pydantic.BaseModel) + +# --------------- Pydantic v2 compatibility --------------- + +# Pyright incorrectly reports some of our functions as overriding a method when they don't +# pyright: reportIncompatibleMethodOverride=false + +PYDANTIC_V2 = pydantic.VERSION.startswith("2.") + +# v1 re-exports +if TYPE_CHECKING: + + def parse_date(value: date | StrBytesIntFloat) -> date: # noqa: ARG001 + ... + + def parse_datetime(value: Union[datetime, StrBytesIntFloat]) -> datetime: # noqa: ARG001 + ... + + def get_args(t: type[Any]) -> tuple[Any, ...]: # noqa: ARG001 + ... + + def is_union(tp: type[Any] | None) -> bool: # noqa: ARG001 + ... + + def get_origin(t: type[Any]) -> type[Any] | None: # noqa: ARG001 + ... + + def is_literal_type(type_: type[Any]) -> bool: # noqa: ARG001 + ... + + def is_typeddict(type_: type[Any]) -> bool: # noqa: ARG001 + ... + +else: + if PYDANTIC_V2: + from pydantic.v1.typing import ( + get_args as get_args, + is_union as is_union, + get_origin as get_origin, + is_typeddict as is_typeddict, + is_literal_type as is_literal_type, + ) + from pydantic.v1.datetime_parse import parse_date as parse_date, parse_datetime as parse_datetime + else: + from pydantic.typing import ( + get_args as get_args, + is_union as is_union, + get_origin as get_origin, + is_typeddict as is_typeddict, + is_literal_type as is_literal_type, + ) + from pydantic.datetime_parse import parse_date as parse_date, parse_datetime as parse_datetime + + +# refactored config +if TYPE_CHECKING: + from pydantic import ConfigDict as ConfigDict +else: + if PYDANTIC_V2: + from pydantic import ConfigDict + else: + # TODO: provide an error message here? + ConfigDict = None + + +# renamed methods / properties +def parse_obj(model: type[_ModelT], value: object) -> _ModelT: + if PYDANTIC_V2: + return model.model_validate(value) + else: + return cast(_ModelT, model.parse_obj(value)) # pyright: ignore[reportDeprecated, reportUnnecessaryCast] + + +def field_is_required(field: FieldInfo) -> bool: + if PYDANTIC_V2: + return field.is_required() + return field.required # type: ignore + + +def field_get_default(field: FieldInfo) -> Any: + value = field.get_default() + if PYDANTIC_V2: + from pydantic_core import PydanticUndefined + + if value == PydanticUndefined: + return None + return value + return value + + +def field_outer_type(field: FieldInfo) -> Any: + if PYDANTIC_V2: + return field.annotation + return field.outer_type_ # type: ignore + + +def get_model_config(model: type[pydantic.BaseModel]) -> Any: + if PYDANTIC_V2: + return model.model_config + return model.__config__ # type: ignore + + +def get_model_fields(model: type[pydantic.BaseModel]) -> dict[str, FieldInfo]: + if PYDANTIC_V2: + return model.model_fields + return model.__fields__ # type: ignore + + +def model_copy(model: _ModelT, *, deep: bool = False) -> _ModelT: + if PYDANTIC_V2: + return model.model_copy(deep=deep) + return model.copy(deep=deep) # type: ignore + + +def model_json(model: pydantic.BaseModel, *, indent: int | None = None) -> str: + if PYDANTIC_V2: + return model.model_dump_json(indent=indent) + return model.json(indent=indent) # type: ignore + + +def model_dump( + model: pydantic.BaseModel, + *, + exclude: IncEx | None = None, + exclude_unset: bool = False, + exclude_defaults: bool = False, + warnings: bool = True, + mode: Literal["json", "python"] = "python", +) -> dict[str, Any]: + if PYDANTIC_V2 or hasattr(model, "model_dump"): + return model.model_dump( + mode=mode, + exclude=exclude, + exclude_unset=exclude_unset, + exclude_defaults=exclude_defaults, + # warnings are not supported in Pydantic v1 + warnings=warnings if PYDANTIC_V2 else True, + ) + return cast( + "dict[str, Any]", + model.dict( # pyright: ignore[reportDeprecated, reportUnnecessaryCast] + exclude=exclude, + exclude_unset=exclude_unset, + exclude_defaults=exclude_defaults, + ), + ) + + +def model_parse(model: type[_ModelT], data: Any) -> _ModelT: + if PYDANTIC_V2: + return model.model_validate(data) + return model.parse_obj(data) # pyright: ignore[reportDeprecated] + + +# generic models +if TYPE_CHECKING: + + class GenericModel(pydantic.BaseModel): ... + +else: + if PYDANTIC_V2: + # there no longer needs to be a distinction in v2 but + # we still have to create our own subclass to avoid + # inconsistent MRO ordering errors + class GenericModel(pydantic.BaseModel): ... + + else: + import pydantic.generics + + class GenericModel(pydantic.generics.GenericModel, pydantic.BaseModel): ... + + +# cached properties +if TYPE_CHECKING: + cached_property = property + + # we define a separate type (copied from typeshed) + # that represents that `cached_property` is `set`able + # at runtime, which differs from `@property`. + # + # this is a separate type as editors likely special case + # `@property` and we don't want to cause issues just to have + # more helpful internal types. + + class typed_cached_property(Generic[_T]): + func: Callable[[Any], _T] + attrname: str | None + + def __init__(self, func: Callable[[Any], _T]) -> None: ... + + @overload + def __get__(self, instance: None, owner: type[Any] | None = None) -> Self: ... + + @overload + def __get__(self, instance: object, owner: type[Any] | None = None) -> _T: ... + + def __get__(self, instance: object, owner: type[Any] | None = None) -> _T | Self: + raise NotImplementedError() + + def __set_name__(self, owner: type[Any], name: str) -> None: ... + + # __set__ is not defined at runtime, but @cached_property is designed to be settable + def __set__(self, instance: object, value: _T) -> None: ... +else: + from functools import cached_property as cached_property + + typed_cached_property = cached_property diff --git a/src/opencode_ai/_constants.py b/src/opencode_ai/_constants.py new file mode 100644 index 0000000..6ddf2c7 --- /dev/null +++ b/src/opencode_ai/_constants.py @@ -0,0 +1,14 @@ +# File generated from our OpenAPI spec by Stainless. See CONTRIBUTING.md for details. + +import httpx + +RAW_RESPONSE_HEADER = "X-Stainless-Raw-Response" +OVERRIDE_CAST_TO_HEADER = "____stainless_override_cast_to" + +# default timeout is 1 minute +DEFAULT_TIMEOUT = httpx.Timeout(timeout=60, connect=5.0) +DEFAULT_MAX_RETRIES = 2 +DEFAULT_CONNECTION_LIMITS = httpx.Limits(max_connections=100, max_keepalive_connections=20) + +INITIAL_RETRY_DELAY = 0.5 +MAX_RETRY_DELAY = 8.0 diff --git a/src/opencode_ai/_exceptions.py b/src/opencode_ai/_exceptions.py new file mode 100644 index 0000000..54376ee --- /dev/null +++ b/src/opencode_ai/_exceptions.py @@ -0,0 +1,108 @@ +# File generated from our OpenAPI spec by Stainless. See CONTRIBUTING.md for details. + +from __future__ import annotations + +from typing_extensions import Literal + +import httpx + +__all__ = [ + "BadRequestError", + "AuthenticationError", + "PermissionDeniedError", + "NotFoundError", + "ConflictError", + "UnprocessableEntityError", + "RateLimitError", + "InternalServerError", +] + + +class OpencodeError(Exception): + pass + + +class APIError(OpencodeError): + message: str + request: httpx.Request + + body: object | None + """The API response body. + + If the API responded with a valid JSON structure then this property will be the + decoded result. + + If it isn't a valid JSON structure then this will be the raw response. + + If there was no response associated with this error then it will be `None`. + """ + + def __init__(self, message: str, request: httpx.Request, *, body: object | None) -> None: # noqa: ARG002 + super().__init__(message) + self.request = request + self.message = message + self.body = body + + +class APIResponseValidationError(APIError): + response: httpx.Response + status_code: int + + def __init__(self, response: httpx.Response, body: object | None, *, message: str | None = None) -> None: + super().__init__(message or "Data returned by API invalid for expected schema.", response.request, body=body) + self.response = response + self.status_code = response.status_code + + +class APIStatusError(APIError): + """Raised when an API response has a status code of 4xx or 5xx.""" + + response: httpx.Response + status_code: int + + def __init__(self, message: str, *, response: httpx.Response, body: object | None) -> None: + super().__init__(message, response.request, body=body) + self.response = response + self.status_code = response.status_code + + +class APIConnectionError(APIError): + def __init__(self, *, message: str = "Connection error.", request: httpx.Request) -> None: + super().__init__(message, request, body=None) + + +class APITimeoutError(APIConnectionError): + def __init__(self, request: httpx.Request) -> None: + super().__init__(message="Request timed out.", request=request) + + +class BadRequestError(APIStatusError): + status_code: Literal[400] = 400 # pyright: ignore[reportIncompatibleVariableOverride] + + +class AuthenticationError(APIStatusError): + status_code: Literal[401] = 401 # pyright: ignore[reportIncompatibleVariableOverride] + + +class PermissionDeniedError(APIStatusError): + status_code: Literal[403] = 403 # pyright: ignore[reportIncompatibleVariableOverride] + + +class NotFoundError(APIStatusError): + status_code: Literal[404] = 404 # pyright: ignore[reportIncompatibleVariableOverride] + + +class ConflictError(APIStatusError): + status_code: Literal[409] = 409 # pyright: ignore[reportIncompatibleVariableOverride] + + +class UnprocessableEntityError(APIStatusError): + status_code: Literal[422] = 422 # pyright: ignore[reportIncompatibleVariableOverride] + + +class RateLimitError(APIStatusError): + status_code: Literal[429] = 429 # pyright: ignore[reportIncompatibleVariableOverride] + + +class InternalServerError(APIStatusError): + pass diff --git a/src/opencode_ai/_files.py b/src/opencode_ai/_files.py new file mode 100644 index 0000000..cc14c14 --- /dev/null +++ b/src/opencode_ai/_files.py @@ -0,0 +1,123 @@ +from __future__ import annotations + +import io +import os +import pathlib +from typing import overload +from typing_extensions import TypeGuard + +import anyio + +from ._types import ( + FileTypes, + FileContent, + RequestFiles, + HttpxFileTypes, + Base64FileInput, + HttpxFileContent, + HttpxRequestFiles, +) +from ._utils import is_tuple_t, is_mapping_t, is_sequence_t + + +def is_base64_file_input(obj: object) -> TypeGuard[Base64FileInput]: + return isinstance(obj, io.IOBase) or isinstance(obj, os.PathLike) + + +def is_file_content(obj: object) -> TypeGuard[FileContent]: + return ( + isinstance(obj, bytes) or isinstance(obj, tuple) or isinstance(obj, io.IOBase) or isinstance(obj, os.PathLike) + ) + + +def assert_is_file_content(obj: object, *, key: str | None = None) -> None: + if not is_file_content(obj): + prefix = f"Expected entry at `{key}`" if key is not None else f"Expected file input `{obj!r}`" + raise RuntimeError( + f"{prefix} to be bytes, an io.IOBase instance, PathLike or a tuple but received {type(obj)} instead." + ) from None + + +@overload +def to_httpx_files(files: None) -> None: ... + + +@overload +def to_httpx_files(files: RequestFiles) -> HttpxRequestFiles: ... + + +def to_httpx_files(files: RequestFiles | None) -> HttpxRequestFiles | None: + if files is None: + return None + + if is_mapping_t(files): + files = {key: _transform_file(file) for key, file in files.items()} + elif is_sequence_t(files): + files = [(key, _transform_file(file)) for key, file in files] + else: + raise TypeError(f"Unexpected file type input {type(files)}, expected mapping or sequence") + + return files + + +def _transform_file(file: FileTypes) -> HttpxFileTypes: + if is_file_content(file): + if isinstance(file, os.PathLike): + path = pathlib.Path(file) + return (path.name, path.read_bytes()) + + return file + + if is_tuple_t(file): + return (file[0], read_file_content(file[1]), *file[2:]) + + raise TypeError(f"Expected file types input to be a FileContent type or to be a tuple") + + +def read_file_content(file: FileContent) -> HttpxFileContent: + if isinstance(file, os.PathLike): + return pathlib.Path(file).read_bytes() + return file + + +@overload +async def async_to_httpx_files(files: None) -> None: ... + + +@overload +async def async_to_httpx_files(files: RequestFiles) -> HttpxRequestFiles: ... + + +async def async_to_httpx_files(files: RequestFiles | None) -> HttpxRequestFiles | None: + if files is None: + return None + + if is_mapping_t(files): + files = {key: await _async_transform_file(file) for key, file in files.items()} + elif is_sequence_t(files): + files = [(key, await _async_transform_file(file)) for key, file in files] + else: + raise TypeError("Unexpected file type input {type(files)}, expected mapping or sequence") + + return files + + +async def _async_transform_file(file: FileTypes) -> HttpxFileTypes: + if is_file_content(file): + if isinstance(file, os.PathLike): + path = anyio.Path(file) + return (path.name, await path.read_bytes()) + + return file + + if is_tuple_t(file): + return (file[0], await async_read_file_content(file[1]), *file[2:]) + + raise TypeError(f"Expected file types input to be a FileContent type or to be a tuple") + + +async def async_read_file_content(file: FileContent) -> HttpxFileContent: + if isinstance(file, os.PathLike): + return await anyio.Path(file).read_bytes() + + return file diff --git a/src/opencode_ai/_models.py b/src/opencode_ai/_models.py new file mode 100644 index 0000000..92f7c10 --- /dev/null +++ b/src/opencode_ai/_models.py @@ -0,0 +1,829 @@ +from __future__ import annotations + +import os +import inspect +from typing import TYPE_CHECKING, Any, Type, Union, Generic, TypeVar, Callable, Optional, cast +from datetime import date, datetime +from typing_extensions import ( + List, + Unpack, + Literal, + ClassVar, + Protocol, + Required, + ParamSpec, + TypedDict, + TypeGuard, + final, + override, + runtime_checkable, +) + +import pydantic +from pydantic.fields import FieldInfo + +from ._types import ( + Body, + IncEx, + Query, + ModelT, + Headers, + Timeout, + NotGiven, + AnyMapping, + HttpxRequestFiles, +) +from ._utils import ( + PropertyInfo, + is_list, + is_given, + json_safe, + lru_cache, + is_mapping, + parse_date, + coerce_boolean, + parse_datetime, + strip_not_given, + extract_type_arg, + is_annotated_type, + is_type_alias_type, + strip_annotated_type, +) +from ._compat import ( + PYDANTIC_V2, + ConfigDict, + GenericModel as BaseGenericModel, + get_args, + is_union, + parse_obj, + get_origin, + is_literal_type, + get_model_config, + get_model_fields, + field_get_default, +) +from ._constants import RAW_RESPONSE_HEADER + +if TYPE_CHECKING: + from pydantic_core.core_schema import ModelField, ModelSchema, LiteralSchema, ModelFieldsSchema + +__all__ = ["BaseModel", "GenericModel"] + +_T = TypeVar("_T") +_BaseModelT = TypeVar("_BaseModelT", bound="BaseModel") + +P = ParamSpec("P") + + +@runtime_checkable +class _ConfigProtocol(Protocol): + allow_population_by_field_name: bool + + +class BaseModel(pydantic.BaseModel): + if PYDANTIC_V2: + model_config: ClassVar[ConfigDict] = ConfigDict( + extra="allow", defer_build=coerce_boolean(os.environ.get("DEFER_PYDANTIC_BUILD", "true")) + ) + else: + + @property + @override + def model_fields_set(self) -> set[str]: + # a forwards-compat shim for pydantic v2 + return self.__fields_set__ # type: ignore + + class Config(pydantic.BaseConfig): # pyright: ignore[reportDeprecated] + extra: Any = pydantic.Extra.allow # type: ignore + + def to_dict( + self, + *, + mode: Literal["json", "python"] = "python", + use_api_names: bool = True, + exclude_unset: bool = True, + exclude_defaults: bool = False, + exclude_none: bool = False, + warnings: bool = True, + ) -> dict[str, object]: + """Recursively generate a dictionary representation of the model, optionally specifying which fields to include or exclude. + + By default, fields that were not set by the API will not be included, + and keys will match the API response, *not* the property names from the model. + + For example, if the API responds with `"fooBar": true` but we've defined a `foo_bar: bool` property, + the output will use the `"fooBar"` key (unless `use_api_names=False` is passed). + + Args: + mode: + If mode is 'json', the dictionary will only contain JSON serializable types. e.g. `datetime` will be turned into a string, `"2024-3-22T18:11:19.117000Z"`. + If mode is 'python', the dictionary may contain any Python objects. e.g. `datetime(2024, 3, 22)` + + use_api_names: Whether to use the key that the API responded with or the property name. Defaults to `True`. + exclude_unset: Whether to exclude fields that have not been explicitly set. + exclude_defaults: Whether to exclude fields that are set to their default value from the output. + exclude_none: Whether to exclude fields that have a value of `None` from the output. + warnings: Whether to log warnings when invalid fields are encountered. This is only supported in Pydantic v2. + """ + return self.model_dump( + mode=mode, + by_alias=use_api_names, + exclude_unset=exclude_unset, + exclude_defaults=exclude_defaults, + exclude_none=exclude_none, + warnings=warnings, + ) + + def to_json( + self, + *, + indent: int | None = 2, + use_api_names: bool = True, + exclude_unset: bool = True, + exclude_defaults: bool = False, + exclude_none: bool = False, + warnings: bool = True, + ) -> str: + """Generates a JSON string representing this model as it would be received from or sent to the API (but with indentation). + + By default, fields that were not set by the API will not be included, + and keys will match the API response, *not* the property names from the model. + + For example, if the API responds with `"fooBar": true` but we've defined a `foo_bar: bool` property, + the output will use the `"fooBar"` key (unless `use_api_names=False` is passed). + + Args: + indent: Indentation to use in the JSON output. If `None` is passed, the output will be compact. Defaults to `2` + use_api_names: Whether to use the key that the API responded with or the property name. Defaults to `True`. + exclude_unset: Whether to exclude fields that have not been explicitly set. + exclude_defaults: Whether to exclude fields that have the default value. + exclude_none: Whether to exclude fields that have a value of `None`. + warnings: Whether to show any warnings that occurred during serialization. This is only supported in Pydantic v2. + """ + return self.model_dump_json( + indent=indent, + by_alias=use_api_names, + exclude_unset=exclude_unset, + exclude_defaults=exclude_defaults, + exclude_none=exclude_none, + warnings=warnings, + ) + + @override + def __str__(self) -> str: + # mypy complains about an invalid self arg + return f"{self.__repr_name__()}({self.__repr_str__(', ')})" # type: ignore[misc] + + # Override the 'construct' method in a way that supports recursive parsing without validation. + # Based on https://github.com/samuelcolvin/pydantic/issues/1168#issuecomment-817742836. + @classmethod + @override + def construct( # pyright: ignore[reportIncompatibleMethodOverride] + __cls: Type[ModelT], + _fields_set: set[str] | None = None, + **values: object, + ) -> ModelT: + m = __cls.__new__(__cls) + fields_values: dict[str, object] = {} + + config = get_model_config(__cls) + populate_by_name = ( + config.allow_population_by_field_name + if isinstance(config, _ConfigProtocol) + else config.get("populate_by_name") + ) + + if _fields_set is None: + _fields_set = set() + + model_fields = get_model_fields(__cls) + for name, field in model_fields.items(): + key = field.alias + if key is None or (key not in values and populate_by_name): + key = name + + if key in values: + fields_values[name] = _construct_field(value=values[key], field=field, key=key) + _fields_set.add(name) + else: + fields_values[name] = field_get_default(field) + + extra_field_type = _get_extra_fields_type(__cls) + + _extra = {} + for key, value in values.items(): + if key not in model_fields: + parsed = construct_type(value=value, type_=extra_field_type) if extra_field_type is not None else value + + if PYDANTIC_V2: + _extra[key] = parsed + else: + _fields_set.add(key) + fields_values[key] = parsed + + object.__setattr__(m, "__dict__", fields_values) + + if PYDANTIC_V2: + # these properties are copied from Pydantic's `model_construct()` method + object.__setattr__(m, "__pydantic_private__", None) + object.__setattr__(m, "__pydantic_extra__", _extra) + object.__setattr__(m, "__pydantic_fields_set__", _fields_set) + else: + # init_private_attributes() does not exist in v2 + m._init_private_attributes() # type: ignore + + # copied from Pydantic v1's `construct()` method + object.__setattr__(m, "__fields_set__", _fields_set) + + return m + + if not TYPE_CHECKING: + # type checkers incorrectly complain about this assignment + # because the type signatures are technically different + # although not in practice + model_construct = construct + + if not PYDANTIC_V2: + # we define aliases for some of the new pydantic v2 methods so + # that we can just document these methods without having to specify + # a specific pydantic version as some users may not know which + # pydantic version they are currently using + + @override + def model_dump( + self, + *, + mode: Literal["json", "python"] | str = "python", + include: IncEx | None = None, + exclude: IncEx | None = None, + by_alias: bool = False, + exclude_unset: bool = False, + exclude_defaults: bool = False, + exclude_none: bool = False, + round_trip: bool = False, + warnings: bool | Literal["none", "warn", "error"] = True, + context: dict[str, Any] | None = None, + serialize_as_any: bool = False, + ) -> dict[str, Any]: + """Usage docs: https://docs.pydantic.dev/2.4/concepts/serialization/#modelmodel_dump + + Generate a dictionary representation of the model, optionally specifying which fields to include or exclude. + + Args: + mode: The mode in which `to_python` should run. + If mode is 'json', the dictionary will only contain JSON serializable types. + If mode is 'python', the dictionary may contain any Python objects. + include: A list of fields to include in the output. + exclude: A list of fields to exclude from the output. + by_alias: Whether to use the field's alias in the dictionary key if defined. + exclude_unset: Whether to exclude fields that are unset or None from the output. + exclude_defaults: Whether to exclude fields that are set to their default value from the output. + exclude_none: Whether to exclude fields that have a value of `None` from the output. + round_trip: Whether to enable serialization and deserialization round-trip support. + warnings: Whether to log warnings when invalid fields are encountered. + + Returns: + A dictionary representation of the model. + """ + if mode not in {"json", "python"}: + raise ValueError("mode must be either 'json' or 'python'") + if round_trip != False: + raise ValueError("round_trip is only supported in Pydantic v2") + if warnings != True: + raise ValueError("warnings is only supported in Pydantic v2") + if context is not None: + raise ValueError("context is only supported in Pydantic v2") + if serialize_as_any != False: + raise ValueError("serialize_as_any is only supported in Pydantic v2") + dumped = super().dict( # pyright: ignore[reportDeprecated] + include=include, + exclude=exclude, + by_alias=by_alias, + exclude_unset=exclude_unset, + exclude_defaults=exclude_defaults, + exclude_none=exclude_none, + ) + + return cast("dict[str, Any]", json_safe(dumped)) if mode == "json" else dumped + + @override + def model_dump_json( + self, + *, + indent: int | None = None, + include: IncEx | None = None, + exclude: IncEx | None = None, + by_alias: bool = False, + exclude_unset: bool = False, + exclude_defaults: bool = False, + exclude_none: bool = False, + round_trip: bool = False, + warnings: bool | Literal["none", "warn", "error"] = True, + context: dict[str, Any] | None = None, + serialize_as_any: bool = False, + ) -> str: + """Usage docs: https://docs.pydantic.dev/2.4/concepts/serialization/#modelmodel_dump_json + + Generates a JSON representation of the model using Pydantic's `to_json` method. + + Args: + indent: Indentation to use in the JSON output. If None is passed, the output will be compact. + include: Field(s) to include in the JSON output. Can take either a string or set of strings. + exclude: Field(s) to exclude from the JSON output. Can take either a string or set of strings. + by_alias: Whether to serialize using field aliases. + exclude_unset: Whether to exclude fields that have not been explicitly set. + exclude_defaults: Whether to exclude fields that have the default value. + exclude_none: Whether to exclude fields that have a value of `None`. + round_trip: Whether to use serialization/deserialization between JSON and class instance. + warnings: Whether to show any warnings that occurred during serialization. + + Returns: + A JSON string representation of the model. + """ + if round_trip != False: + raise ValueError("round_trip is only supported in Pydantic v2") + if warnings != True: + raise ValueError("warnings is only supported in Pydantic v2") + if context is not None: + raise ValueError("context is only supported in Pydantic v2") + if serialize_as_any != False: + raise ValueError("serialize_as_any is only supported in Pydantic v2") + return super().json( # type: ignore[reportDeprecated] + indent=indent, + include=include, + exclude=exclude, + by_alias=by_alias, + exclude_unset=exclude_unset, + exclude_defaults=exclude_defaults, + exclude_none=exclude_none, + ) + + +def _construct_field(value: object, field: FieldInfo, key: str) -> object: + if value is None: + return field_get_default(field) + + if PYDANTIC_V2: + type_ = field.annotation + else: + type_ = cast(type, field.outer_type_) # type: ignore + + if type_ is None: + raise RuntimeError(f"Unexpected field type is None for {key}") + + return construct_type(value=value, type_=type_, metadata=getattr(field, "metadata", None)) + + +def _get_extra_fields_type(cls: type[pydantic.BaseModel]) -> type | None: + if not PYDANTIC_V2: + # TODO + return None + + schema = cls.__pydantic_core_schema__ + if schema["type"] == "model": + fields = schema["schema"] + if fields["type"] == "model-fields": + extras = fields.get("extras_schema") + if extras and "cls" in extras: + # mypy can't narrow the type + return extras["cls"] # type: ignore[no-any-return] + + return None + + +def is_basemodel(type_: type) -> bool: + """Returns whether or not the given type is either a `BaseModel` or a union of `BaseModel`""" + if is_union(type_): + for variant in get_args(type_): + if is_basemodel(variant): + return True + + return False + + return is_basemodel_type(type_) + + +def is_basemodel_type(type_: type) -> TypeGuard[type[BaseModel] | type[GenericModel]]: + origin = get_origin(type_) or type_ + if not inspect.isclass(origin): + return False + return issubclass(origin, BaseModel) or issubclass(origin, GenericModel) + + +def build( + base_model_cls: Callable[P, _BaseModelT], + *args: P.args, + **kwargs: P.kwargs, +) -> _BaseModelT: + """Construct a BaseModel class without validation. + + This is useful for cases where you need to instantiate a `BaseModel` + from an API response as this provides type-safe params which isn't supported + by helpers like `construct_type()`. + + ```py + build(MyModel, my_field_a="foo", my_field_b=123) + ``` + """ + if args: + raise TypeError( + "Received positional arguments which are not supported; Keyword arguments must be used instead", + ) + + return cast(_BaseModelT, construct_type(type_=base_model_cls, value=kwargs)) + + +def construct_type_unchecked(*, value: object, type_: type[_T]) -> _T: + """Loose coercion to the expected type with construction of nested values. + + Note: the returned value from this function is not guaranteed to match the + given type. + """ + return cast(_T, construct_type(value=value, type_=type_)) + + +def construct_type(*, value: object, type_: object, metadata: Optional[List[Any]] = None) -> object: + """Loose coercion to the expected type with construction of nested values. + + If the given value does not match the expected type then it is returned as-is. + """ + + # store a reference to the original type we were given before we extract any inner + # types so that we can properly resolve forward references in `TypeAliasType` annotations + original_type = None + + # we allow `object` as the input type because otherwise, passing things like + # `Literal['value']` will be reported as a type error by type checkers + type_ = cast("type[object]", type_) + if is_type_alias_type(type_): + original_type = type_ # type: ignore[unreachable] + type_ = type_.__value__ # type: ignore[unreachable] + + # unwrap `Annotated[T, ...]` -> `T` + if metadata is not None and len(metadata) > 0: + meta: tuple[Any, ...] = tuple(metadata) + elif is_annotated_type(type_): + meta = get_args(type_)[1:] + type_ = extract_type_arg(type_, 0) + else: + meta = tuple() + + # we need to use the origin class for any types that are subscripted generics + # e.g. Dict[str, object] + origin = get_origin(type_) or type_ + args = get_args(type_) + + if is_union(origin): + try: + return validate_type(type_=cast("type[object]", original_type or type_), value=value) + except Exception: + pass + + # if the type is a discriminated union then we want to construct the right variant + # in the union, even if the data doesn't match exactly, otherwise we'd break code + # that relies on the constructed class types, e.g. + # + # class FooType: + # kind: Literal['foo'] + # value: str + # + # class BarType: + # kind: Literal['bar'] + # value: int + # + # without this block, if the data we get is something like `{'kind': 'bar', 'value': 'foo'}` then + # we'd end up constructing `FooType` when it should be `BarType`. + discriminator = _build_discriminated_union_meta(union=type_, meta_annotations=meta) + if discriminator and is_mapping(value): + variant_value = value.get(discriminator.field_alias_from or discriminator.field_name) + if variant_value and isinstance(variant_value, str): + variant_type = discriminator.mapping.get(variant_value) + if variant_type: + return construct_type(type_=variant_type, value=value) + + # if the data is not valid, use the first variant that doesn't fail while deserializing + for variant in args: + try: + return construct_type(value=value, type_=variant) + except Exception: + continue + + raise RuntimeError(f"Could not convert data into a valid instance of {type_}") + + if origin == dict: + if not is_mapping(value): + return value + + _, items_type = get_args(type_) # Dict[_, items_type] + return {key: construct_type(value=item, type_=items_type) for key, item in value.items()} + + if ( + not is_literal_type(type_) + and inspect.isclass(origin) + and (issubclass(origin, BaseModel) or issubclass(origin, GenericModel)) + ): + if is_list(value): + return [cast(Any, type_).construct(**entry) if is_mapping(entry) else entry for entry in value] + + if is_mapping(value): + if issubclass(type_, BaseModel): + return type_.construct(**value) # type: ignore[arg-type] + + return cast(Any, type_).construct(**value) + + if origin == list: + if not is_list(value): + return value + + inner_type = args[0] # List[inner_type] + return [construct_type(value=entry, type_=inner_type) for entry in value] + + if origin == float: + if isinstance(value, int): + coerced = float(value) + if coerced != value: + return value + return coerced + + return value + + if type_ == datetime: + try: + return parse_datetime(value) # type: ignore + except Exception: + return value + + if type_ == date: + try: + return parse_date(value) # type: ignore + except Exception: + return value + + return value + + +@runtime_checkable +class CachedDiscriminatorType(Protocol): + __discriminator__: DiscriminatorDetails + + +class DiscriminatorDetails: + field_name: str + """The name of the discriminator field in the variant class, e.g. + + ```py + class Foo(BaseModel): + type: Literal['foo'] + ``` + + Will result in field_name='type' + """ + + field_alias_from: str | None + """The name of the discriminator field in the API response, e.g. + + ```py + class Foo(BaseModel): + type: Literal['foo'] = Field(alias='type_from_api') + ``` + + Will result in field_alias_from='type_from_api' + """ + + mapping: dict[str, type] + """Mapping of discriminator value to variant type, e.g. + + {'foo': FooVariant, 'bar': BarVariant} + """ + + def __init__( + self, + *, + mapping: dict[str, type], + discriminator_field: str, + discriminator_alias: str | None, + ) -> None: + self.mapping = mapping + self.field_name = discriminator_field + self.field_alias_from = discriminator_alias + + +def _build_discriminated_union_meta(*, union: type, meta_annotations: tuple[Any, ...]) -> DiscriminatorDetails | None: + if isinstance(union, CachedDiscriminatorType): + return union.__discriminator__ + + discriminator_field_name: str | None = None + + for annotation in meta_annotations: + if isinstance(annotation, PropertyInfo) and annotation.discriminator is not None: + discriminator_field_name = annotation.discriminator + break + + if not discriminator_field_name: + return None + + mapping: dict[str, type] = {} + discriminator_alias: str | None = None + + for variant in get_args(union): + variant = strip_annotated_type(variant) + if is_basemodel_type(variant): + if PYDANTIC_V2: + field = _extract_field_schema_pv2(variant, discriminator_field_name) + if not field: + continue + + # Note: if one variant defines an alias then they all should + discriminator_alias = field.get("serialization_alias") + + field_schema = field["schema"] + + if field_schema["type"] == "literal": + for entry in cast("LiteralSchema", field_schema)["expected"]: + if isinstance(entry, str): + mapping[entry] = variant + else: + field_info = cast("dict[str, FieldInfo]", variant.__fields__).get(discriminator_field_name) # pyright: ignore[reportDeprecated, reportUnnecessaryCast] + if not field_info: + continue + + # Note: if one variant defines an alias then they all should + discriminator_alias = field_info.alias + + if (annotation := getattr(field_info, "annotation", None)) and is_literal_type(annotation): + for entry in get_args(annotation): + if isinstance(entry, str): + mapping[entry] = variant + + if not mapping: + return None + + details = DiscriminatorDetails( + mapping=mapping, + discriminator_field=discriminator_field_name, + discriminator_alias=discriminator_alias, + ) + cast(CachedDiscriminatorType, union).__discriminator__ = details + return details + + +def _extract_field_schema_pv2(model: type[BaseModel], field_name: str) -> ModelField | None: + schema = model.__pydantic_core_schema__ + if schema["type"] == "definitions": + schema = schema["schema"] + + if schema["type"] != "model": + return None + + schema = cast("ModelSchema", schema) + fields_schema = schema["schema"] + if fields_schema["type"] != "model-fields": + return None + + fields_schema = cast("ModelFieldsSchema", fields_schema) + field = fields_schema["fields"].get(field_name) + if not field: + return None + + return cast("ModelField", field) # pyright: ignore[reportUnnecessaryCast] + + +def validate_type(*, type_: type[_T], value: object) -> _T: + """Strict validation that the given value matches the expected type""" + if inspect.isclass(type_) and issubclass(type_, pydantic.BaseModel): + return cast(_T, parse_obj(type_, value)) + + return cast(_T, _validate_non_model_type(type_=type_, value=value)) + + +def set_pydantic_config(typ: Any, config: pydantic.ConfigDict) -> None: + """Add a pydantic config for the given type. + + Note: this is a no-op on Pydantic v1. + """ + setattr(typ, "__pydantic_config__", config) # noqa: B010 + + +# our use of subclassing here causes weirdness for type checkers, +# so we just pretend that we don't subclass +if TYPE_CHECKING: + GenericModel = BaseModel +else: + + class GenericModel(BaseGenericModel, BaseModel): + pass + + +if PYDANTIC_V2: + from pydantic import TypeAdapter as _TypeAdapter + + _CachedTypeAdapter = cast("TypeAdapter[object]", lru_cache(maxsize=None)(_TypeAdapter)) + + if TYPE_CHECKING: + from pydantic import TypeAdapter + else: + TypeAdapter = _CachedTypeAdapter + + def _validate_non_model_type(*, type_: type[_T], value: object) -> _T: + return TypeAdapter(type_).validate_python(value) + +elif not TYPE_CHECKING: # TODO: condition is weird + + class RootModel(GenericModel, Generic[_T]): + """Used as a placeholder to easily convert runtime types to a Pydantic format + to provide validation. + + For example: + ```py + validated = RootModel[int](__root__="5").__root__ + # validated: 5 + ``` + """ + + __root__: _T + + def _validate_non_model_type(*, type_: type[_T], value: object) -> _T: + model = _create_pydantic_model(type_).validate(value) + return cast(_T, model.__root__) + + def _create_pydantic_model(type_: _T) -> Type[RootModel[_T]]: + return RootModel[type_] # type: ignore + + +class FinalRequestOptionsInput(TypedDict, total=False): + method: Required[str] + url: Required[str] + params: Query + headers: Headers + max_retries: int + timeout: float | Timeout | None + files: HttpxRequestFiles | None + idempotency_key: str + json_data: Body + extra_json: AnyMapping + follow_redirects: bool + + +@final +class FinalRequestOptions(pydantic.BaseModel): + method: str + url: str + params: Query = {} + headers: Union[Headers, NotGiven] = NotGiven() + max_retries: Union[int, NotGiven] = NotGiven() + timeout: Union[float, Timeout, None, NotGiven] = NotGiven() + files: Union[HttpxRequestFiles, None] = None + idempotency_key: Union[str, None] = None + post_parser: Union[Callable[[Any], Any], NotGiven] = NotGiven() + follow_redirects: Union[bool, None] = None + + # It should be noted that we cannot use `json` here as that would override + # a BaseModel method in an incompatible fashion. + json_data: Union[Body, None] = None + extra_json: Union[AnyMapping, None] = None + + if PYDANTIC_V2: + model_config: ClassVar[ConfigDict] = ConfigDict(arbitrary_types_allowed=True) + else: + + class Config(pydantic.BaseConfig): # pyright: ignore[reportDeprecated] + arbitrary_types_allowed: bool = True + + def get_max_retries(self, max_retries: int) -> int: + if isinstance(self.max_retries, NotGiven): + return max_retries + return self.max_retries + + def _strip_raw_response_header(self) -> None: + if not is_given(self.headers): + return + + if self.headers.get(RAW_RESPONSE_HEADER): + self.headers = {**self.headers} + self.headers.pop(RAW_RESPONSE_HEADER) + + # override the `construct` method so that we can run custom transformations. + # this is necessary as we don't want to do any actual runtime type checking + # (which means we can't use validators) but we do want to ensure that `NotGiven` + # values are not present + # + # type ignore required because we're adding explicit types to `**values` + @classmethod + def construct( # type: ignore + cls, + _fields_set: set[str] | None = None, + **values: Unpack[FinalRequestOptionsInput], + ) -> FinalRequestOptions: + kwargs: dict[str, Any] = { + # we unconditionally call `strip_not_given` on any value + # as it will just ignore any non-mapping types + key: strip_not_given(value) + for key, value in values.items() + } + if PYDANTIC_V2: + return super().model_construct(_fields_set, **kwargs) + return cast(FinalRequestOptions, super().construct(_fields_set, **kwargs)) # pyright: ignore[reportDeprecated] + + if not TYPE_CHECKING: + # type checkers incorrectly complain about this assignment + model_construct = construct diff --git a/src/opencode_ai/_qs.py b/src/opencode_ai/_qs.py new file mode 100644 index 0000000..274320c --- /dev/null +++ b/src/opencode_ai/_qs.py @@ -0,0 +1,150 @@ +from __future__ import annotations + +from typing import Any, List, Tuple, Union, Mapping, TypeVar +from urllib.parse import parse_qs, urlencode +from typing_extensions import Literal, get_args + +from ._types import NOT_GIVEN, NotGiven, NotGivenOr +from ._utils import flatten + +_T = TypeVar("_T") + + +ArrayFormat = Literal["comma", "repeat", "indices", "brackets"] +NestedFormat = Literal["dots", "brackets"] + +PrimitiveData = Union[str, int, float, bool, None] +# this should be Data = Union[PrimitiveData, "List[Data]", "Tuple[Data]", "Mapping[str, Data]"] +# https://github.com/microsoft/pyright/issues/3555 +Data = Union[PrimitiveData, List[Any], Tuple[Any], "Mapping[str, Any]"] +Params = Mapping[str, Data] + + +class Querystring: + array_format: ArrayFormat + nested_format: NestedFormat + + def __init__( + self, + *, + array_format: ArrayFormat = "repeat", + nested_format: NestedFormat = "brackets", + ) -> None: + self.array_format = array_format + self.nested_format = nested_format + + def parse(self, query: str) -> Mapping[str, object]: + # Note: custom format syntax is not supported yet + return parse_qs(query) + + def stringify( + self, + params: Params, + *, + array_format: NotGivenOr[ArrayFormat] = NOT_GIVEN, + nested_format: NotGivenOr[NestedFormat] = NOT_GIVEN, + ) -> str: + return urlencode( + self.stringify_items( + params, + array_format=array_format, + nested_format=nested_format, + ) + ) + + def stringify_items( + self, + params: Params, + *, + array_format: NotGivenOr[ArrayFormat] = NOT_GIVEN, + nested_format: NotGivenOr[NestedFormat] = NOT_GIVEN, + ) -> list[tuple[str, str]]: + opts = Options( + qs=self, + array_format=array_format, + nested_format=nested_format, + ) + return flatten([self._stringify_item(key, value, opts) for key, value in params.items()]) + + def _stringify_item( + self, + key: str, + value: Data, + opts: Options, + ) -> list[tuple[str, str]]: + if isinstance(value, Mapping): + items: list[tuple[str, str]] = [] + nested_format = opts.nested_format + for subkey, subvalue in value.items(): + items.extend( + self._stringify_item( + # TODO: error if unknown format + f"{key}.{subkey}" if nested_format == "dots" else f"{key}[{subkey}]", + subvalue, + opts, + ) + ) + return items + + if isinstance(value, (list, tuple)): + array_format = opts.array_format + if array_format == "comma": + return [ + ( + key, + ",".join(self._primitive_value_to_str(item) for item in value if item is not None), + ), + ] + elif array_format == "repeat": + items = [] + for item in value: + items.extend(self._stringify_item(key, item, opts)) + return items + elif array_format == "indices": + raise NotImplementedError("The array indices format is not supported yet") + elif array_format == "brackets": + items = [] + key = key + "[]" + for item in value: + items.extend(self._stringify_item(key, item, opts)) + return items + else: + raise NotImplementedError( + f"Unknown array_format value: {array_format}, choose from {', '.join(get_args(ArrayFormat))}" + ) + + serialised = self._primitive_value_to_str(value) + if not serialised: + return [] + return [(key, serialised)] + + def _primitive_value_to_str(self, value: PrimitiveData) -> str: + # copied from httpx + if value is True: + return "true" + elif value is False: + return "false" + elif value is None: + return "" + return str(value) + + +_qs = Querystring() +parse = _qs.parse +stringify = _qs.stringify +stringify_items = _qs.stringify_items + + +class Options: + array_format: ArrayFormat + nested_format: NestedFormat + + def __init__( + self, + qs: Querystring = _qs, + *, + array_format: NotGivenOr[ArrayFormat] = NOT_GIVEN, + nested_format: NotGivenOr[NestedFormat] = NOT_GIVEN, + ) -> None: + self.array_format = qs.array_format if isinstance(array_format, NotGiven) else array_format + self.nested_format = qs.nested_format if isinstance(nested_format, NotGiven) else nested_format diff --git a/src/opencode_ai/_resource.py b/src/opencode_ai/_resource.py new file mode 100644 index 0000000..80802c9 --- /dev/null +++ b/src/opencode_ai/_resource.py @@ -0,0 +1,43 @@ +# File generated from our OpenAPI spec by Stainless. See CONTRIBUTING.md for details. + +from __future__ import annotations + +import time +from typing import TYPE_CHECKING + +import anyio + +if TYPE_CHECKING: + from ._client import Opencode, AsyncOpencode + + +class SyncAPIResource: + _client: Opencode + + def __init__(self, client: Opencode) -> None: + self._client = client + self._get = client.get + self._post = client.post + self._patch = client.patch + self._put = client.put + self._delete = client.delete + self._get_api_list = client.get_api_list + + def _sleep(self, seconds: float) -> None: + time.sleep(seconds) + + +class AsyncAPIResource: + _client: AsyncOpencode + + def __init__(self, client: AsyncOpencode) -> None: + self._client = client + self._get = client.get + self._post = client.post + self._patch = client.patch + self._put = client.put + self._delete = client.delete + self._get_api_list = client.get_api_list + + async def _sleep(self, seconds: float) -> None: + await anyio.sleep(seconds) diff --git a/src/opencode_ai/_response.py b/src/opencode_ai/_response.py new file mode 100644 index 0000000..203be5d --- /dev/null +++ b/src/opencode_ai/_response.py @@ -0,0 +1,832 @@ +from __future__ import annotations + +import os +import inspect +import logging +import datetime +import functools +from types import TracebackType +from typing import ( + TYPE_CHECKING, + Any, + Union, + Generic, + TypeVar, + Callable, + Iterator, + AsyncIterator, + cast, + overload, +) +from typing_extensions import Awaitable, ParamSpec, override, get_origin + +import anyio +import httpx +import pydantic + +from ._types import NoneType +from ._utils import is_given, extract_type_arg, is_annotated_type, is_type_alias_type, extract_type_var_from_base +from ._models import BaseModel, is_basemodel +from ._constants import RAW_RESPONSE_HEADER, OVERRIDE_CAST_TO_HEADER +from ._streaming import Stream, AsyncStream, is_stream_class_type, extract_stream_chunk_type +from ._exceptions import OpencodeError, APIResponseValidationError + +if TYPE_CHECKING: + from ._models import FinalRequestOptions + from ._base_client import BaseClient + + +P = ParamSpec("P") +R = TypeVar("R") +_T = TypeVar("_T") +_APIResponseT = TypeVar("_APIResponseT", bound="APIResponse[Any]") +_AsyncAPIResponseT = TypeVar("_AsyncAPIResponseT", bound="AsyncAPIResponse[Any]") + +log: logging.Logger = logging.getLogger(__name__) + + +class BaseAPIResponse(Generic[R]): + _cast_to: type[R] + _client: BaseClient[Any, Any] + _parsed_by_type: dict[type[Any], Any] + _is_sse_stream: bool + _stream_cls: type[Stream[Any]] | type[AsyncStream[Any]] | None + _options: FinalRequestOptions + + http_response: httpx.Response + + retries_taken: int + """The number of retries made. If no retries happened this will be `0`""" + + def __init__( + self, + *, + raw: httpx.Response, + cast_to: type[R], + client: BaseClient[Any, Any], + stream: bool, + stream_cls: type[Stream[Any]] | type[AsyncStream[Any]] | None, + options: FinalRequestOptions, + retries_taken: int = 0, + ) -> None: + self._cast_to = cast_to + self._client = client + self._parsed_by_type = {} + self._is_sse_stream = stream + self._stream_cls = stream_cls + self._options = options + self.http_response = raw + self.retries_taken = retries_taken + + @property + def headers(self) -> httpx.Headers: + return self.http_response.headers + + @property + def http_request(self) -> httpx.Request: + """Returns the httpx Request instance associated with the current response.""" + return self.http_response.request + + @property + def status_code(self) -> int: + return self.http_response.status_code + + @property + def url(self) -> httpx.URL: + """Returns the URL for which the request was made.""" + return self.http_response.url + + @property + def method(self) -> str: + return self.http_request.method + + @property + def http_version(self) -> str: + return self.http_response.http_version + + @property + def elapsed(self) -> datetime.timedelta: + """The time taken for the complete request/response cycle to complete.""" + return self.http_response.elapsed + + @property + def is_closed(self) -> bool: + """Whether or not the response body has been closed. + + If this is False then there is response data that has not been read yet. + You must either fully consume the response body or call `.close()` + before discarding the response to prevent resource leaks. + """ + return self.http_response.is_closed + + @override + def __repr__(self) -> str: + return ( + f"<{self.__class__.__name__} [{self.status_code} {self.http_response.reason_phrase}] type={self._cast_to}>" + ) + + def _parse(self, *, to: type[_T] | None = None) -> R | _T: + cast_to = to if to is not None else self._cast_to + + # unwrap `TypeAlias('Name', T)` -> `T` + if is_type_alias_type(cast_to): + cast_to = cast_to.__value__ # type: ignore[unreachable] + + # unwrap `Annotated[T, ...]` -> `T` + if cast_to and is_annotated_type(cast_to): + cast_to = extract_type_arg(cast_to, 0) + + origin = get_origin(cast_to) or cast_to + + if self._is_sse_stream: + if to: + if not is_stream_class_type(to): + raise TypeError(f"Expected custom parse type to be a subclass of {Stream} or {AsyncStream}") + + return cast( + _T, + to( + cast_to=extract_stream_chunk_type( + to, + failure_message="Expected custom stream type to be passed with a type argument, e.g. Stream[ChunkType]", + ), + response=self.http_response, + client=cast(Any, self._client), + ), + ) + + if self._stream_cls: + return cast( + R, + self._stream_cls( + cast_to=extract_stream_chunk_type(self._stream_cls), + response=self.http_response, + client=cast(Any, self._client), + ), + ) + + stream_cls = cast("type[Stream[Any]] | type[AsyncStream[Any]] | None", self._client._default_stream_cls) + if stream_cls is None: + raise MissingStreamClassError() + + return cast( + R, + stream_cls( + cast_to=cast_to, + response=self.http_response, + client=cast(Any, self._client), + ), + ) + + if cast_to is NoneType: + return cast(R, None) + + response = self.http_response + if cast_to == str: + return cast(R, response.text) + + if cast_to == bytes: + return cast(R, response.content) + + if cast_to == int: + return cast(R, int(response.text)) + + if cast_to == float: + return cast(R, float(response.text)) + + if cast_to == bool: + return cast(R, response.text.lower() == "true") + + if origin == APIResponse: + raise RuntimeError("Unexpected state - cast_to is `APIResponse`") + + if inspect.isclass(origin) and issubclass(origin, httpx.Response): + # Because of the invariance of our ResponseT TypeVar, users can subclass httpx.Response + # and pass that class to our request functions. We cannot change the variance to be either + # covariant or contravariant as that makes our usage of ResponseT illegal. We could construct + # the response class ourselves but that is something that should be supported directly in httpx + # as it would be easy to incorrectly construct the Response object due to the multitude of arguments. + if cast_to != httpx.Response: + raise ValueError(f"Subclasses of httpx.Response cannot be passed to `cast_to`") + return cast(R, response) + + if ( + inspect.isclass( + origin # pyright: ignore[reportUnknownArgumentType] + ) + and not issubclass(origin, BaseModel) + and issubclass(origin, pydantic.BaseModel) + ): + raise TypeError( + "Pydantic models must subclass our base model type, e.g. `from opencode_ai import BaseModel`" + ) + + if ( + cast_to is not object + and not origin is list + and not origin is dict + and not origin is Union + and not issubclass(origin, BaseModel) + ): + raise RuntimeError( + f"Unsupported type, expected {cast_to} to be a subclass of {BaseModel}, {dict}, {list}, {Union}, {NoneType}, {str} or {httpx.Response}." + ) + + # split is required to handle cases where additional information is included + # in the response, e.g. application/json; charset=utf-8 + content_type, *_ = response.headers.get("content-type", "*").split(";") + if not content_type.endswith("json"): + if is_basemodel(cast_to): + try: + data = response.json() + except Exception as exc: + log.debug("Could not read JSON from response data due to %s - %s", type(exc), exc) + else: + return self._client._process_response_data( + data=data, + cast_to=cast_to, # type: ignore + response=response, + ) + + if self._client._strict_response_validation: + raise APIResponseValidationError( + response=response, + message=f"Expected Content-Type response header to be `application/json` but received `{content_type}` instead.", + body=response.text, + ) + + # If the API responds with content that isn't JSON then we just return + # the (decoded) text without performing any parsing so that you can still + # handle the response however you need to. + return response.text # type: ignore + + data = response.json() + + return self._client._process_response_data( + data=data, + cast_to=cast_to, # type: ignore + response=response, + ) + + +class APIResponse(BaseAPIResponse[R]): + @overload + def parse(self, *, to: type[_T]) -> _T: ... + + @overload + def parse(self) -> R: ... + + def parse(self, *, to: type[_T] | None = None) -> R | _T: + """Returns the rich python representation of this response's data. + + For lower-level control, see `.read()`, `.json()`, `.iter_bytes()`. + + You can customise the type that the response is parsed into through + the `to` argument, e.g. + + ```py + from opencode_ai import BaseModel + + + class MyModel(BaseModel): + foo: str + + + obj = response.parse(to=MyModel) + print(obj.foo) + ``` + + We support parsing: + - `BaseModel` + - `dict` + - `list` + - `Union` + - `str` + - `int` + - `float` + - `httpx.Response` + """ + cache_key = to if to is not None else self._cast_to + cached = self._parsed_by_type.get(cache_key) + if cached is not None: + return cached # type: ignore[no-any-return] + + if not self._is_sse_stream: + self.read() + + parsed = self._parse(to=to) + if is_given(self._options.post_parser): + parsed = self._options.post_parser(parsed) + + self._parsed_by_type[cache_key] = parsed + return parsed + + def read(self) -> bytes: + """Read and return the binary response content.""" + try: + return self.http_response.read() + except httpx.StreamConsumed as exc: + # The default error raised by httpx isn't very + # helpful in our case so we re-raise it with + # a different error message. + raise StreamAlreadyConsumed() from exc + + def text(self) -> str: + """Read and decode the response content into a string.""" + self.read() + return self.http_response.text + + def json(self) -> object: + """Read and decode the JSON response content.""" + self.read() + return self.http_response.json() + + def close(self) -> None: + """Close the response and release the connection. + + Automatically called if the response body is read to completion. + """ + self.http_response.close() + + def iter_bytes(self, chunk_size: int | None = None) -> Iterator[bytes]: + """ + A byte-iterator over the decoded response content. + + This automatically handles gzip, deflate and brotli encoded responses. + """ + for chunk in self.http_response.iter_bytes(chunk_size): + yield chunk + + def iter_text(self, chunk_size: int | None = None) -> Iterator[str]: + """A str-iterator over the decoded response content + that handles both gzip, deflate, etc but also detects the content's + string encoding. + """ + for chunk in self.http_response.iter_text(chunk_size): + yield chunk + + def iter_lines(self) -> Iterator[str]: + """Like `iter_text()` but will only yield chunks for each line""" + for chunk in self.http_response.iter_lines(): + yield chunk + + +class AsyncAPIResponse(BaseAPIResponse[R]): + @overload + async def parse(self, *, to: type[_T]) -> _T: ... + + @overload + async def parse(self) -> R: ... + + async def parse(self, *, to: type[_T] | None = None) -> R | _T: + """Returns the rich python representation of this response's data. + + For lower-level control, see `.read()`, `.json()`, `.iter_bytes()`. + + You can customise the type that the response is parsed into through + the `to` argument, e.g. + + ```py + from opencode_ai import BaseModel + + + class MyModel(BaseModel): + foo: str + + + obj = response.parse(to=MyModel) + print(obj.foo) + ``` + + We support parsing: + - `BaseModel` + - `dict` + - `list` + - `Union` + - `str` + - `httpx.Response` + """ + cache_key = to if to is not None else self._cast_to + cached = self._parsed_by_type.get(cache_key) + if cached is not None: + return cached # type: ignore[no-any-return] + + if not self._is_sse_stream: + await self.read() + + parsed = self._parse(to=to) + if is_given(self._options.post_parser): + parsed = self._options.post_parser(parsed) + + self._parsed_by_type[cache_key] = parsed + return parsed + + async def read(self) -> bytes: + """Read and return the binary response content.""" + try: + return await self.http_response.aread() + except httpx.StreamConsumed as exc: + # the default error raised by httpx isn't very + # helpful in our case so we re-raise it with + # a different error message + raise StreamAlreadyConsumed() from exc + + async def text(self) -> str: + """Read and decode the response content into a string.""" + await self.read() + return self.http_response.text + + async def json(self) -> object: + """Read and decode the JSON response content.""" + await self.read() + return self.http_response.json() + + async def close(self) -> None: + """Close the response and release the connection. + + Automatically called if the response body is read to completion. + """ + await self.http_response.aclose() + + async def iter_bytes(self, chunk_size: int | None = None) -> AsyncIterator[bytes]: + """ + A byte-iterator over the decoded response content. + + This automatically handles gzip, deflate and brotli encoded responses. + """ + async for chunk in self.http_response.aiter_bytes(chunk_size): + yield chunk + + async def iter_text(self, chunk_size: int | None = None) -> AsyncIterator[str]: + """A str-iterator over the decoded response content + that handles both gzip, deflate, etc but also detects the content's + string encoding. + """ + async for chunk in self.http_response.aiter_text(chunk_size): + yield chunk + + async def iter_lines(self) -> AsyncIterator[str]: + """Like `iter_text()` but will only yield chunks for each line""" + async for chunk in self.http_response.aiter_lines(): + yield chunk + + +class BinaryAPIResponse(APIResponse[bytes]): + """Subclass of APIResponse providing helpers for dealing with binary data. + + Note: If you want to stream the response data instead of eagerly reading it + all at once then you should use `.with_streaming_response` when making + the API request, e.g. `.with_streaming_response.get_binary_response()` + """ + + def write_to_file( + self, + file: str | os.PathLike[str], + ) -> None: + """Write the output to the given file. + + Accepts a filename or any path-like object, e.g. pathlib.Path + + Note: if you want to stream the data to the file instead of writing + all at once then you should use `.with_streaming_response` when making + the API request, e.g. `.with_streaming_response.get_binary_response()` + """ + with open(file, mode="wb") as f: + for data in self.iter_bytes(): + f.write(data) + + +class AsyncBinaryAPIResponse(AsyncAPIResponse[bytes]): + """Subclass of APIResponse providing helpers for dealing with binary data. + + Note: If you want to stream the response data instead of eagerly reading it + all at once then you should use `.with_streaming_response` when making + the API request, e.g. `.with_streaming_response.get_binary_response()` + """ + + async def write_to_file( + self, + file: str | os.PathLike[str], + ) -> None: + """Write the output to the given file. + + Accepts a filename or any path-like object, e.g. pathlib.Path + + Note: if you want to stream the data to the file instead of writing + all at once then you should use `.with_streaming_response` when making + the API request, e.g. `.with_streaming_response.get_binary_response()` + """ + path = anyio.Path(file) + async with await path.open(mode="wb") as f: + async for data in self.iter_bytes(): + await f.write(data) + + +class StreamedBinaryAPIResponse(APIResponse[bytes]): + def stream_to_file( + self, + file: str | os.PathLike[str], + *, + chunk_size: int | None = None, + ) -> None: + """Streams the output to the given file. + + Accepts a filename or any path-like object, e.g. pathlib.Path + """ + with open(file, mode="wb") as f: + for data in self.iter_bytes(chunk_size): + f.write(data) + + +class AsyncStreamedBinaryAPIResponse(AsyncAPIResponse[bytes]): + async def stream_to_file( + self, + file: str | os.PathLike[str], + *, + chunk_size: int | None = None, + ) -> None: + """Streams the output to the given file. + + Accepts a filename or any path-like object, e.g. pathlib.Path + """ + path = anyio.Path(file) + async with await path.open(mode="wb") as f: + async for data in self.iter_bytes(chunk_size): + await f.write(data) + + +class MissingStreamClassError(TypeError): + def __init__(self) -> None: + super().__init__( + "The `stream` argument was set to `True` but the `stream_cls` argument was not given. See `opencode_ai._streaming` for reference", + ) + + +class StreamAlreadyConsumed(OpencodeError): + """ + Attempted to read or stream content, but the content has already + been streamed. + + This can happen if you use a method like `.iter_lines()` and then attempt + to read th entire response body afterwards, e.g. + + ```py + response = await client.post(...) + async for line in response.iter_lines(): + ... # do something with `line` + + content = await response.read() + # ^ error + ``` + + If you want this behaviour you'll need to either manually accumulate the response + content or call `await response.read()` before iterating over the stream. + """ + + def __init__(self) -> None: + message = ( + "Attempted to read or stream some content, but the content has " + "already been streamed. " + "This could be due to attempting to stream the response " + "content more than once." + "\n\n" + "You can fix this by manually accumulating the response content while streaming " + "or by calling `.read()` before starting to stream." + ) + super().__init__(message) + + +class ResponseContextManager(Generic[_APIResponseT]): + """Context manager for ensuring that a request is not made + until it is entered and that the response will always be closed + when the context manager exits + """ + + def __init__(self, request_func: Callable[[], _APIResponseT]) -> None: + self._request_func = request_func + self.__response: _APIResponseT | None = None + + def __enter__(self) -> _APIResponseT: + self.__response = self._request_func() + return self.__response + + def __exit__( + self, + exc_type: type[BaseException] | None, + exc: BaseException | None, + exc_tb: TracebackType | None, + ) -> None: + if self.__response is not None: + self.__response.close() + + +class AsyncResponseContextManager(Generic[_AsyncAPIResponseT]): + """Context manager for ensuring that a request is not made + until it is entered and that the response will always be closed + when the context manager exits + """ + + def __init__(self, api_request: Awaitable[_AsyncAPIResponseT]) -> None: + self._api_request = api_request + self.__response: _AsyncAPIResponseT | None = None + + async def __aenter__(self) -> _AsyncAPIResponseT: + self.__response = await self._api_request + return self.__response + + async def __aexit__( + self, + exc_type: type[BaseException] | None, + exc: BaseException | None, + exc_tb: TracebackType | None, + ) -> None: + if self.__response is not None: + await self.__response.close() + + +def to_streamed_response_wrapper(func: Callable[P, R]) -> Callable[P, ResponseContextManager[APIResponse[R]]]: + """Higher order function that takes one of our bound API methods and wraps it + to support streaming and returning the raw `APIResponse` object directly. + """ + + @functools.wraps(func) + def wrapped(*args: P.args, **kwargs: P.kwargs) -> ResponseContextManager[APIResponse[R]]: + extra_headers: dict[str, str] = {**(cast(Any, kwargs.get("extra_headers")) or {})} + extra_headers[RAW_RESPONSE_HEADER] = "stream" + + kwargs["extra_headers"] = extra_headers + + make_request = functools.partial(func, *args, **kwargs) + + return ResponseContextManager(cast(Callable[[], APIResponse[R]], make_request)) + + return wrapped + + +def async_to_streamed_response_wrapper( + func: Callable[P, Awaitable[R]], +) -> Callable[P, AsyncResponseContextManager[AsyncAPIResponse[R]]]: + """Higher order function that takes one of our bound API methods and wraps it + to support streaming and returning the raw `APIResponse` object directly. + """ + + @functools.wraps(func) + def wrapped(*args: P.args, **kwargs: P.kwargs) -> AsyncResponseContextManager[AsyncAPIResponse[R]]: + extra_headers: dict[str, str] = {**(cast(Any, kwargs.get("extra_headers")) or {})} + extra_headers[RAW_RESPONSE_HEADER] = "stream" + + kwargs["extra_headers"] = extra_headers + + make_request = func(*args, **kwargs) + + return AsyncResponseContextManager(cast(Awaitable[AsyncAPIResponse[R]], make_request)) + + return wrapped + + +def to_custom_streamed_response_wrapper( + func: Callable[P, object], + response_cls: type[_APIResponseT], +) -> Callable[P, ResponseContextManager[_APIResponseT]]: + """Higher order function that takes one of our bound API methods and an `APIResponse` class + and wraps the method to support streaming and returning the given response class directly. + + Note: the given `response_cls` *must* be concrete, e.g. `class BinaryAPIResponse(APIResponse[bytes])` + """ + + @functools.wraps(func) + def wrapped(*args: P.args, **kwargs: P.kwargs) -> ResponseContextManager[_APIResponseT]: + extra_headers: dict[str, Any] = {**(cast(Any, kwargs.get("extra_headers")) or {})} + extra_headers[RAW_RESPONSE_HEADER] = "stream" + extra_headers[OVERRIDE_CAST_TO_HEADER] = response_cls + + kwargs["extra_headers"] = extra_headers + + make_request = functools.partial(func, *args, **kwargs) + + return ResponseContextManager(cast(Callable[[], _APIResponseT], make_request)) + + return wrapped + + +def async_to_custom_streamed_response_wrapper( + func: Callable[P, Awaitable[object]], + response_cls: type[_AsyncAPIResponseT], +) -> Callable[P, AsyncResponseContextManager[_AsyncAPIResponseT]]: + """Higher order function that takes one of our bound API methods and an `APIResponse` class + and wraps the method to support streaming and returning the given response class directly. + + Note: the given `response_cls` *must* be concrete, e.g. `class BinaryAPIResponse(APIResponse[bytes])` + """ + + @functools.wraps(func) + def wrapped(*args: P.args, **kwargs: P.kwargs) -> AsyncResponseContextManager[_AsyncAPIResponseT]: + extra_headers: dict[str, Any] = {**(cast(Any, kwargs.get("extra_headers")) or {})} + extra_headers[RAW_RESPONSE_HEADER] = "stream" + extra_headers[OVERRIDE_CAST_TO_HEADER] = response_cls + + kwargs["extra_headers"] = extra_headers + + make_request = func(*args, **kwargs) + + return AsyncResponseContextManager(cast(Awaitable[_AsyncAPIResponseT], make_request)) + + return wrapped + + +def to_raw_response_wrapper(func: Callable[P, R]) -> Callable[P, APIResponse[R]]: + """Higher order function that takes one of our bound API methods and wraps it + to support returning the raw `APIResponse` object directly. + """ + + @functools.wraps(func) + def wrapped(*args: P.args, **kwargs: P.kwargs) -> APIResponse[R]: + extra_headers: dict[str, str] = {**(cast(Any, kwargs.get("extra_headers")) or {})} + extra_headers[RAW_RESPONSE_HEADER] = "raw" + + kwargs["extra_headers"] = extra_headers + + return cast(APIResponse[R], func(*args, **kwargs)) + + return wrapped + + +def async_to_raw_response_wrapper(func: Callable[P, Awaitable[R]]) -> Callable[P, Awaitable[AsyncAPIResponse[R]]]: + """Higher order function that takes one of our bound API methods and wraps it + to support returning the raw `APIResponse` object directly. + """ + + @functools.wraps(func) + async def wrapped(*args: P.args, **kwargs: P.kwargs) -> AsyncAPIResponse[R]: + extra_headers: dict[str, str] = {**(cast(Any, kwargs.get("extra_headers")) or {})} + extra_headers[RAW_RESPONSE_HEADER] = "raw" + + kwargs["extra_headers"] = extra_headers + + return cast(AsyncAPIResponse[R], await func(*args, **kwargs)) + + return wrapped + + +def to_custom_raw_response_wrapper( + func: Callable[P, object], + response_cls: type[_APIResponseT], +) -> Callable[P, _APIResponseT]: + """Higher order function that takes one of our bound API methods and an `APIResponse` class + and wraps the method to support returning the given response class directly. + + Note: the given `response_cls` *must* be concrete, e.g. `class BinaryAPIResponse(APIResponse[bytes])` + """ + + @functools.wraps(func) + def wrapped(*args: P.args, **kwargs: P.kwargs) -> _APIResponseT: + extra_headers: dict[str, Any] = {**(cast(Any, kwargs.get("extra_headers")) or {})} + extra_headers[RAW_RESPONSE_HEADER] = "raw" + extra_headers[OVERRIDE_CAST_TO_HEADER] = response_cls + + kwargs["extra_headers"] = extra_headers + + return cast(_APIResponseT, func(*args, **kwargs)) + + return wrapped + + +def async_to_custom_raw_response_wrapper( + func: Callable[P, Awaitable[object]], + response_cls: type[_AsyncAPIResponseT], +) -> Callable[P, Awaitable[_AsyncAPIResponseT]]: + """Higher order function that takes one of our bound API methods and an `APIResponse` class + and wraps the method to support returning the given response class directly. + + Note: the given `response_cls` *must* be concrete, e.g. `class BinaryAPIResponse(APIResponse[bytes])` + """ + + @functools.wraps(func) + def wrapped(*args: P.args, **kwargs: P.kwargs) -> Awaitable[_AsyncAPIResponseT]: + extra_headers: dict[str, Any] = {**(cast(Any, kwargs.get("extra_headers")) or {})} + extra_headers[RAW_RESPONSE_HEADER] = "raw" + extra_headers[OVERRIDE_CAST_TO_HEADER] = response_cls + + kwargs["extra_headers"] = extra_headers + + return cast(Awaitable[_AsyncAPIResponseT], func(*args, **kwargs)) + + return wrapped + + +def extract_response_type(typ: type[BaseAPIResponse[Any]]) -> type: + """Given a type like `APIResponse[T]`, returns the generic type variable `T`. + + This also handles the case where a concrete subclass is given, e.g. + ```py + class MyResponse(APIResponse[bytes]): + ... + + extract_response_type(MyResponse) -> bytes + ``` + """ + return extract_type_var_from_base( + typ, + generic_bases=cast("tuple[type, ...]", (BaseAPIResponse, APIResponse, AsyncAPIResponse)), + index=0, + ) diff --git a/src/opencode_ai/_streaming.py b/src/opencode_ai/_streaming.py new file mode 100644 index 0000000..34499b5 --- /dev/null +++ b/src/opencode_ai/_streaming.py @@ -0,0 +1,333 @@ +# Note: initially copied from https://github.com/florimondmanca/httpx-sse/blob/master/src/httpx_sse/_decoders.py +from __future__ import annotations + +import json +import inspect +from types import TracebackType +from typing import TYPE_CHECKING, Any, Generic, TypeVar, Iterator, AsyncIterator, cast +from typing_extensions import Self, Protocol, TypeGuard, override, get_origin, runtime_checkable + +import httpx + +from ._utils import extract_type_var_from_base + +if TYPE_CHECKING: + from ._client import Opencode, AsyncOpencode + + +_T = TypeVar("_T") + + +class Stream(Generic[_T]): + """Provides the core interface to iterate over a synchronous stream response.""" + + response: httpx.Response + + _decoder: SSEBytesDecoder + + def __init__( + self, + *, + cast_to: type[_T], + response: httpx.Response, + client: Opencode, + ) -> None: + self.response = response + self._cast_to = cast_to + self._client = client + self._decoder = client._make_sse_decoder() + self._iterator = self.__stream__() + + def __next__(self) -> _T: + return self._iterator.__next__() + + def __iter__(self) -> Iterator[_T]: + for item in self._iterator: + yield item + + def _iter_events(self) -> Iterator[ServerSentEvent]: + yield from self._decoder.iter_bytes(self.response.iter_bytes()) + + def __stream__(self) -> Iterator[_T]: + cast_to = cast(Any, self._cast_to) + response = self.response + process_data = self._client._process_response_data + iterator = self._iter_events() + + for sse in iterator: + yield process_data(data=sse.json(), cast_to=cast_to, response=response) + + # Ensure the entire stream is consumed + for _sse in iterator: + ... + + def __enter__(self) -> Self: + return self + + def __exit__( + self, + exc_type: type[BaseException] | None, + exc: BaseException | None, + exc_tb: TracebackType | None, + ) -> None: + self.close() + + def close(self) -> None: + """ + Close the response and release the connection. + + Automatically called if the response body is read to completion. + """ + self.response.close() + + +class AsyncStream(Generic[_T]): + """Provides the core interface to iterate over an asynchronous stream response.""" + + response: httpx.Response + + _decoder: SSEDecoder | SSEBytesDecoder + + def __init__( + self, + *, + cast_to: type[_T], + response: httpx.Response, + client: AsyncOpencode, + ) -> None: + self.response = response + self._cast_to = cast_to + self._client = client + self._decoder = client._make_sse_decoder() + self._iterator = self.__stream__() + + async def __anext__(self) -> _T: + return await self._iterator.__anext__() + + async def __aiter__(self) -> AsyncIterator[_T]: + async for item in self._iterator: + yield item + + async def _iter_events(self) -> AsyncIterator[ServerSentEvent]: + async for sse in self._decoder.aiter_bytes(self.response.aiter_bytes()): + yield sse + + async def __stream__(self) -> AsyncIterator[_T]: + cast_to = cast(Any, self._cast_to) + response = self.response + process_data = self._client._process_response_data + iterator = self._iter_events() + + async for sse in iterator: + yield process_data(data=sse.json(), cast_to=cast_to, response=response) + + # Ensure the entire stream is consumed + async for _sse in iterator: + ... + + async def __aenter__(self) -> Self: + return self + + async def __aexit__( + self, + exc_type: type[BaseException] | None, + exc: BaseException | None, + exc_tb: TracebackType | None, + ) -> None: + await self.close() + + async def close(self) -> None: + """ + Close the response and release the connection. + + Automatically called if the response body is read to completion. + """ + await self.response.aclose() + + +class ServerSentEvent: + def __init__( + self, + *, + event: str | None = None, + data: str | None = None, + id: str | None = None, + retry: int | None = None, + ) -> None: + if data is None: + data = "" + + self._id = id + self._data = data + self._event = event or None + self._retry = retry + + @property + def event(self) -> str | None: + return self._event + + @property + def id(self) -> str | None: + return self._id + + @property + def retry(self) -> int | None: + return self._retry + + @property + def data(self) -> str: + return self._data + + def json(self) -> Any: + return json.loads(self.data) + + @override + def __repr__(self) -> str: + return f"ServerSentEvent(event={self.event}, data={self.data}, id={self.id}, retry={self.retry})" + + +class SSEDecoder: + _data: list[str] + _event: str | None + _retry: int | None + _last_event_id: str | None + + def __init__(self) -> None: + self._event = None + self._data = [] + self._last_event_id = None + self._retry = None + + def iter_bytes(self, iterator: Iterator[bytes]) -> Iterator[ServerSentEvent]: + """Given an iterator that yields raw binary data, iterate over it & yield every event encountered""" + for chunk in self._iter_chunks(iterator): + # Split before decoding so splitlines() only uses \r and \n + for raw_line in chunk.splitlines(): + line = raw_line.decode("utf-8") + sse = self.decode(line) + if sse: + yield sse + + def _iter_chunks(self, iterator: Iterator[bytes]) -> Iterator[bytes]: + """Given an iterator that yields raw binary data, iterate over it and yield individual SSE chunks""" + data = b"" + for chunk in iterator: + for line in chunk.splitlines(keepends=True): + data += line + if data.endswith((b"\r\r", b"\n\n", b"\r\n\r\n")): + yield data + data = b"" + if data: + yield data + + async def aiter_bytes(self, iterator: AsyncIterator[bytes]) -> AsyncIterator[ServerSentEvent]: + """Given an iterator that yields raw binary data, iterate over it & yield every event encountered""" + async for chunk in self._aiter_chunks(iterator): + # Split before decoding so splitlines() only uses \r and \n + for raw_line in chunk.splitlines(): + line = raw_line.decode("utf-8") + sse = self.decode(line) + if sse: + yield sse + + async def _aiter_chunks(self, iterator: AsyncIterator[bytes]) -> AsyncIterator[bytes]: + """Given an iterator that yields raw binary data, iterate over it and yield individual SSE chunks""" + data = b"" + async for chunk in iterator: + for line in chunk.splitlines(keepends=True): + data += line + if data.endswith((b"\r\r", b"\n\n", b"\r\n\r\n")): + yield data + data = b"" + if data: + yield data + + def decode(self, line: str) -> ServerSentEvent | None: + # See: https://html.spec.whatwg.org/multipage/server-sent-events.html#event-stream-interpretation # noqa: E501 + + if not line: + if not self._event and not self._data and not self._last_event_id and self._retry is None: + return None + + sse = ServerSentEvent( + event=self._event, + data="\n".join(self._data), + id=self._last_event_id, + retry=self._retry, + ) + + # NOTE: as per the SSE spec, do not reset last_event_id. + self._event = None + self._data = [] + self._retry = None + + return sse + + if line.startswith(":"): + return None + + fieldname, _, value = line.partition(":") + + if value.startswith(" "): + value = value[1:] + + if fieldname == "event": + self._event = value + elif fieldname == "data": + self._data.append(value) + elif fieldname == "id": + if "\0" in value: + pass + else: + self._last_event_id = value + elif fieldname == "retry": + try: + self._retry = int(value) + except (TypeError, ValueError): + pass + else: + pass # Field is ignored. + + return None + + +@runtime_checkable +class SSEBytesDecoder(Protocol): + def iter_bytes(self, iterator: Iterator[bytes]) -> Iterator[ServerSentEvent]: + """Given an iterator that yields raw binary data, iterate over it & yield every event encountered""" + ... + + def aiter_bytes(self, iterator: AsyncIterator[bytes]) -> AsyncIterator[ServerSentEvent]: + """Given an async iterator that yields raw binary data, iterate over it & yield every event encountered""" + ... + + +def is_stream_class_type(typ: type) -> TypeGuard[type[Stream[object]] | type[AsyncStream[object]]]: + """TypeGuard for determining whether or not the given type is a subclass of `Stream` / `AsyncStream`""" + origin = get_origin(typ) or typ + return inspect.isclass(origin) and issubclass(origin, (Stream, AsyncStream)) + + +def extract_stream_chunk_type( + stream_cls: type, + *, + failure_message: str | None = None, +) -> type: + """Given a type like `Stream[T]`, returns the generic type variable `T`. + + This also handles the case where a concrete subclass is given, e.g. + ```py + class MyStream(Stream[bytes]): + ... + + extract_stream_chunk_type(MyStream) -> bytes + ``` + """ + from ._base_client import Stream, AsyncStream + + return extract_type_var_from_base( + stream_cls, + index=0, + generic_bases=cast("tuple[type, ...]", (Stream, AsyncStream)), + failure_message=failure_message, + ) diff --git a/src/opencode_ai/_types.py b/src/opencode_ai/_types.py new file mode 100644 index 0000000..5dfb606 --- /dev/null +++ b/src/opencode_ai/_types.py @@ -0,0 +1,253 @@ +from __future__ import annotations + +from os import PathLike +from typing import ( + IO, + TYPE_CHECKING, + Any, + Dict, + List, + Type, + Tuple, + Union, + Mapping, + TypeVar, + Callable, + Iterator, + Optional, + Sequence, +) +from typing_extensions import ( + Set, + Literal, + Protocol, + TypeAlias, + TypedDict, + SupportsIndex, + overload, + override, + runtime_checkable, +) + +import httpx +import pydantic +from httpx import URL, Proxy, Timeout, Response, BaseTransport, AsyncBaseTransport + +if TYPE_CHECKING: + from ._models import BaseModel + from ._response import APIResponse, AsyncAPIResponse + +Transport = BaseTransport +AsyncTransport = AsyncBaseTransport +Query = Mapping[str, object] +Body = object +AnyMapping = Mapping[str, object] +ModelT = TypeVar("ModelT", bound=pydantic.BaseModel) +_T = TypeVar("_T") + + +# Approximates httpx internal ProxiesTypes and RequestFiles types +# while adding support for `PathLike` instances +ProxiesDict = Dict["str | URL", Union[None, str, URL, Proxy]] +ProxiesTypes = Union[str, Proxy, ProxiesDict] +if TYPE_CHECKING: + Base64FileInput = Union[IO[bytes], PathLike[str]] + FileContent = Union[IO[bytes], bytes, PathLike[str]] +else: + Base64FileInput = Union[IO[bytes], PathLike] + FileContent = Union[IO[bytes], bytes, PathLike] # PathLike is not subscriptable in Python 3.8. +FileTypes = Union[ + # file (or bytes) + FileContent, + # (filename, file (or bytes)) + Tuple[Optional[str], FileContent], + # (filename, file (or bytes), content_type) + Tuple[Optional[str], FileContent, Optional[str]], + # (filename, file (or bytes), content_type, headers) + Tuple[Optional[str], FileContent, Optional[str], Mapping[str, str]], +] +RequestFiles = Union[Mapping[str, FileTypes], Sequence[Tuple[str, FileTypes]]] + +# duplicate of the above but without our custom file support +HttpxFileContent = Union[IO[bytes], bytes] +HttpxFileTypes = Union[ + # file (or bytes) + HttpxFileContent, + # (filename, file (or bytes)) + Tuple[Optional[str], HttpxFileContent], + # (filename, file (or bytes), content_type) + Tuple[Optional[str], HttpxFileContent, Optional[str]], + # (filename, file (or bytes), content_type, headers) + Tuple[Optional[str], HttpxFileContent, Optional[str], Mapping[str, str]], +] +HttpxRequestFiles = Union[Mapping[str, HttpxFileTypes], Sequence[Tuple[str, HttpxFileTypes]]] + +# Workaround to support (cast_to: Type[ResponseT]) -> ResponseT +# where ResponseT includes `None`. In order to support directly +# passing `None`, overloads would have to be defined for every +# method that uses `ResponseT` which would lead to an unacceptable +# amount of code duplication and make it unreadable. See _base_client.py +# for example usage. +# +# This unfortunately means that you will either have +# to import this type and pass it explicitly: +# +# from opencode_ai import NoneType +# client.get('/foo', cast_to=NoneType) +# +# or build it yourself: +# +# client.get('/foo', cast_to=type(None)) +if TYPE_CHECKING: + NoneType: Type[None] +else: + NoneType = type(None) + + +class RequestOptions(TypedDict, total=False): + headers: Headers + max_retries: int + timeout: float | Timeout | None + params: Query + extra_json: AnyMapping + idempotency_key: str + follow_redirects: bool + + +# Sentinel class used until PEP 0661 is accepted +class NotGiven: + """ + A sentinel singleton class used to distinguish omitted keyword arguments + from those passed in with the value None (which may have different behavior). + + For example: + + ```py + def get(timeout: Union[int, NotGiven, None] = NotGiven()) -> Response: ... + + + get(timeout=1) # 1s timeout + get(timeout=None) # No timeout + get() # Default timeout behavior, which may not be statically known at the method definition. + ``` + """ + + def __bool__(self) -> Literal[False]: + return False + + @override + def __repr__(self) -> str: + return "NOT_GIVEN" + + +NotGivenOr = Union[_T, NotGiven] +NOT_GIVEN = NotGiven() + + +class Omit: + """In certain situations you need to be able to represent a case where a default value has + to be explicitly removed and `None` is not an appropriate substitute, for example: + + ```py + # as the default `Content-Type` header is `application/json` that will be sent + client.post("/upload/files", files={"file": b"my raw file content"}) + + # you can't explicitly override the header as it has to be dynamically generated + # to look something like: 'multipart/form-data; boundary=0d8382fcf5f8c3be01ca2e11002d2983' + client.post(..., headers={"Content-Type": "multipart/form-data"}) + + # instead you can remove the default `application/json` header by passing Omit + client.post(..., headers={"Content-Type": Omit()}) + ``` + """ + + def __bool__(self) -> Literal[False]: + return False + + +@runtime_checkable +class ModelBuilderProtocol(Protocol): + @classmethod + def build( + cls: type[_T], + *, + response: Response, + data: object, + ) -> _T: ... + + +Headers = Mapping[str, Union[str, Omit]] + + +class HeadersLikeProtocol(Protocol): + def get(self, __key: str) -> str | None: ... + + +HeadersLike = Union[Headers, HeadersLikeProtocol] + +ResponseT = TypeVar( + "ResponseT", + bound=Union[ + object, + str, + None, + "BaseModel", + List[Any], + Dict[str, Any], + Response, + ModelBuilderProtocol, + "APIResponse[Any]", + "AsyncAPIResponse[Any]", + ], +) + +StrBytesIntFloat = Union[str, bytes, int, float] + +# Note: copied from Pydantic +# https://github.com/pydantic/pydantic/blob/6f31f8f68ef011f84357330186f603ff295312fd/pydantic/main.py#L79 +IncEx: TypeAlias = Union[Set[int], Set[str], Mapping[int, Union["IncEx", bool]], Mapping[str, Union["IncEx", bool]]] + +PostParser = Callable[[Any], Any] + + +@runtime_checkable +class InheritsGeneric(Protocol): + """Represents a type that has inherited from `Generic` + + The `__orig_bases__` property can be used to determine the resolved + type variable for a given base class. + """ + + __orig_bases__: tuple[_GenericAlias] + + +class _GenericAlias(Protocol): + __origin__: type[object] + + +class HttpxSendArgs(TypedDict, total=False): + auth: httpx.Auth + follow_redirects: bool + + +_T_co = TypeVar("_T_co", covariant=True) + + +if TYPE_CHECKING: + # This works because str.__contains__ does not accept object (either in typeshed or at runtime) + # https://github.com/hauntsaninja/useful_types/blob/5e9710f3875107d068e7679fd7fec9cfab0eff3b/useful_types/__init__.py#L285 + class SequenceNotStr(Protocol[_T_co]): + @overload + def __getitem__(self, index: SupportsIndex, /) -> _T_co: ... + @overload + def __getitem__(self, index: slice, /) -> Sequence[_T_co]: ... + def __contains__(self, value: object, /) -> bool: ... + def __len__(self) -> int: ... + def __iter__(self) -> Iterator[_T_co]: ... + def index(self, value: Any, start: int = 0, stop: int = ..., /) -> int: ... + def count(self, value: Any, /) -> int: ... + def __reversed__(self) -> Iterator[_T_co]: ... +else: + # just point this to a normal `Sequence` at runtime to avoid having to special case + # deserializing our custom sequence type + SequenceNotStr = Sequence diff --git a/src/opencode_ai/_utils/__init__.py b/src/opencode_ai/_utils/__init__.py new file mode 100644 index 0000000..ca547ce --- /dev/null +++ b/src/opencode_ai/_utils/__init__.py @@ -0,0 +1,58 @@ +from ._sync import asyncify as asyncify +from ._proxy import LazyProxy as LazyProxy +from ._utils import ( + flatten as flatten, + is_dict as is_dict, + is_list as is_list, + is_given as is_given, + is_tuple as is_tuple, + json_safe as json_safe, + lru_cache as lru_cache, + is_mapping as is_mapping, + is_tuple_t as is_tuple_t, + parse_date as parse_date, + is_iterable as is_iterable, + is_sequence as is_sequence, + coerce_float as coerce_float, + is_mapping_t as is_mapping_t, + removeprefix as removeprefix, + removesuffix as removesuffix, + extract_files as extract_files, + is_sequence_t as is_sequence_t, + required_args as required_args, + coerce_boolean as coerce_boolean, + coerce_integer as coerce_integer, + file_from_path as file_from_path, + parse_datetime as parse_datetime, + strip_not_given as strip_not_given, + deepcopy_minimal as deepcopy_minimal, + get_async_library as get_async_library, + maybe_coerce_float as maybe_coerce_float, + get_required_header as get_required_header, + maybe_coerce_boolean as maybe_coerce_boolean, + maybe_coerce_integer as maybe_coerce_integer, +) +from ._typing import ( + is_list_type as is_list_type, + is_union_type as is_union_type, + extract_type_arg as extract_type_arg, + is_iterable_type as is_iterable_type, + is_required_type as is_required_type, + is_sequence_type as is_sequence_type, + is_annotated_type as is_annotated_type, + is_type_alias_type as is_type_alias_type, + strip_annotated_type as strip_annotated_type, + extract_type_var_from_base as extract_type_var_from_base, +) +from ._streams import consume_sync_iterator as consume_sync_iterator, consume_async_iterator as consume_async_iterator +from ._transform import ( + PropertyInfo as PropertyInfo, + transform as transform, + async_transform as async_transform, + maybe_transform as maybe_transform, + async_maybe_transform as async_maybe_transform, +) +from ._reflection import ( + function_has_argument as function_has_argument, + assert_signatures_in_sync as assert_signatures_in_sync, +) diff --git a/src/opencode_ai/_utils/_logs.py b/src/opencode_ai/_utils/_logs.py new file mode 100644 index 0000000..4cbc451 --- /dev/null +++ b/src/opencode_ai/_utils/_logs.py @@ -0,0 +1,25 @@ +import os +import logging + +logger: logging.Logger = logging.getLogger("opencode_ai") +httpx_logger: logging.Logger = logging.getLogger("httpx") + + +def _basic_config() -> None: + # e.g. [2023-10-05 14:12:26 - opencode_ai._base_client:818 - DEBUG] HTTP Request: POST http://127.0.0.1:4010/foo/bar "200 OK" + logging.basicConfig( + format="[%(asctime)s - %(name)s:%(lineno)d - %(levelname)s] %(message)s", + datefmt="%Y-%m-%d %H:%M:%S", + ) + + +def setup_logging() -> None: + env = os.environ.get("OPENCODE_LOG") + if env == "debug": + _basic_config() + logger.setLevel(logging.DEBUG) + httpx_logger.setLevel(logging.DEBUG) + elif env == "info": + _basic_config() + logger.setLevel(logging.INFO) + httpx_logger.setLevel(logging.INFO) diff --git a/src/opencode_ai/_utils/_proxy.py b/src/opencode_ai/_utils/_proxy.py new file mode 100644 index 0000000..0f239a3 --- /dev/null +++ b/src/opencode_ai/_utils/_proxy.py @@ -0,0 +1,65 @@ +from __future__ import annotations + +from abc import ABC, abstractmethod +from typing import Generic, TypeVar, Iterable, cast +from typing_extensions import override + +T = TypeVar("T") + + +class LazyProxy(Generic[T], ABC): + """Implements data methods to pretend that an instance is another instance. + + This includes forwarding attribute access and other methods. + """ + + # Note: we have to special case proxies that themselves return proxies + # to support using a proxy as a catch-all for any random access, e.g. `proxy.foo.bar.baz` + + def __getattr__(self, attr: str) -> object: + proxied = self.__get_proxied__() + if isinstance(proxied, LazyProxy): + return proxied # pyright: ignore + return getattr(proxied, attr) + + @override + def __repr__(self) -> str: + proxied = self.__get_proxied__() + if isinstance(proxied, LazyProxy): + return proxied.__class__.__name__ + return repr(self.__get_proxied__()) + + @override + def __str__(self) -> str: + proxied = self.__get_proxied__() + if isinstance(proxied, LazyProxy): + return proxied.__class__.__name__ + return str(proxied) + + @override + def __dir__(self) -> Iterable[str]: + proxied = self.__get_proxied__() + if isinstance(proxied, LazyProxy): + return [] + return proxied.__dir__() + + @property # type: ignore + @override + def __class__(self) -> type: # pyright: ignore + try: + proxied = self.__get_proxied__() + except Exception: + return type(self) + if issubclass(type(proxied), LazyProxy): + return type(proxied) + return proxied.__class__ + + def __get_proxied__(self) -> T: + return self.__load__() + + def __as_proxied__(self) -> T: + """Helper method that returns the current proxy, typed as the loaded object""" + return cast(T, self) + + @abstractmethod + def __load__(self) -> T: ... diff --git a/src/opencode_ai/_utils/_reflection.py b/src/opencode_ai/_utils/_reflection.py new file mode 100644 index 0000000..89aa712 --- /dev/null +++ b/src/opencode_ai/_utils/_reflection.py @@ -0,0 +1,42 @@ +from __future__ import annotations + +import inspect +from typing import Any, Callable + + +def function_has_argument(func: Callable[..., Any], arg_name: str) -> bool: + """Returns whether or not the given function has a specific parameter""" + sig = inspect.signature(func) + return arg_name in sig.parameters + + +def assert_signatures_in_sync( + source_func: Callable[..., Any], + check_func: Callable[..., Any], + *, + exclude_params: set[str] = set(), +) -> None: + """Ensure that the signature of the second function matches the first.""" + + check_sig = inspect.signature(check_func) + source_sig = inspect.signature(source_func) + + errors: list[str] = [] + + for name, source_param in source_sig.parameters.items(): + if name in exclude_params: + continue + + custom_param = check_sig.parameters.get(name) + if not custom_param: + errors.append(f"the `{name}` param is missing") + continue + + if custom_param.annotation != source_param.annotation: + errors.append( + f"types for the `{name}` param are do not match; source={repr(source_param.annotation)} checking={repr(custom_param.annotation)}" + ) + continue + + if errors: + raise AssertionError(f"{len(errors)} errors encountered when comparing signatures:\n\n" + "\n\n".join(errors)) diff --git a/src/opencode_ai/_utils/_resources_proxy.py b/src/opencode_ai/_utils/_resources_proxy.py new file mode 100644 index 0000000..0e17312 --- /dev/null +++ b/src/opencode_ai/_utils/_resources_proxy.py @@ -0,0 +1,24 @@ +from __future__ import annotations + +from typing import Any +from typing_extensions import override + +from ._proxy import LazyProxy + + +class ResourcesProxy(LazyProxy[Any]): + """A proxy for the `opencode_ai.resources` module. + + This is used so that we can lazily import `opencode_ai.resources` only when + needed *and* so that users can just import `opencode_ai` and reference `opencode_ai.resources` + """ + + @override + def __load__(self) -> Any: + import importlib + + mod = importlib.import_module("opencode_ai.resources") + return mod + + +resources = ResourcesProxy().__as_proxied__() diff --git a/src/opencode_ai/_utils/_streams.py b/src/opencode_ai/_utils/_streams.py new file mode 100644 index 0000000..f4a0208 --- /dev/null +++ b/src/opencode_ai/_utils/_streams.py @@ -0,0 +1,12 @@ +from typing import Any +from typing_extensions import Iterator, AsyncIterator + + +def consume_sync_iterator(iterator: Iterator[Any]) -> None: + for _ in iterator: + ... + + +async def consume_async_iterator(iterator: AsyncIterator[Any]) -> None: + async for _ in iterator: + ... diff --git a/src/opencode_ai/_utils/_sync.py b/src/opencode_ai/_utils/_sync.py new file mode 100644 index 0000000..ad7ec71 --- /dev/null +++ b/src/opencode_ai/_utils/_sync.py @@ -0,0 +1,86 @@ +from __future__ import annotations + +import sys +import asyncio +import functools +import contextvars +from typing import Any, TypeVar, Callable, Awaitable +from typing_extensions import ParamSpec + +import anyio +import sniffio +import anyio.to_thread + +T_Retval = TypeVar("T_Retval") +T_ParamSpec = ParamSpec("T_ParamSpec") + + +if sys.version_info >= (3, 9): + _asyncio_to_thread = asyncio.to_thread +else: + # backport of https://docs.python.org/3/library/asyncio-task.html#asyncio.to_thread + # for Python 3.8 support + async def _asyncio_to_thread( + func: Callable[T_ParamSpec, T_Retval], /, *args: T_ParamSpec.args, **kwargs: T_ParamSpec.kwargs + ) -> Any: + """Asynchronously run function *func* in a separate thread. + + Any *args and **kwargs supplied for this function are directly passed + to *func*. Also, the current :class:`contextvars.Context` is propagated, + allowing context variables from the main thread to be accessed in the + separate thread. + + Returns a coroutine that can be awaited to get the eventual result of *func*. + """ + loop = asyncio.events.get_running_loop() + ctx = contextvars.copy_context() + func_call = functools.partial(ctx.run, func, *args, **kwargs) + return await loop.run_in_executor(None, func_call) + + +async def to_thread( + func: Callable[T_ParamSpec, T_Retval], /, *args: T_ParamSpec.args, **kwargs: T_ParamSpec.kwargs +) -> T_Retval: + if sniffio.current_async_library() == "asyncio": + return await _asyncio_to_thread(func, *args, **kwargs) + + return await anyio.to_thread.run_sync( + functools.partial(func, *args, **kwargs), + ) + + +# inspired by `asyncer`, https://github.com/tiangolo/asyncer +def asyncify(function: Callable[T_ParamSpec, T_Retval]) -> Callable[T_ParamSpec, Awaitable[T_Retval]]: + """ + Take a blocking function and create an async one that receives the same + positional and keyword arguments. For python version 3.9 and above, it uses + asyncio.to_thread to run the function in a separate thread. For python version + 3.8, it uses locally defined copy of the asyncio.to_thread function which was + introduced in python 3.9. + + Usage: + + ```python + def blocking_func(arg1, arg2, kwarg1=None): + # blocking code + return result + + + result = asyncify(blocking_function)(arg1, arg2, kwarg1=value1) + ``` + + ## Arguments + + `function`: a blocking regular callable (e.g. a function) + + ## Return + + An async function that takes the same positional and keyword arguments as the + original one, that when called runs the same original function in a thread worker + and returns the result. + """ + + async def wrapper(*args: T_ParamSpec.args, **kwargs: T_ParamSpec.kwargs) -> T_Retval: + return await to_thread(function, *args, **kwargs) + + return wrapper diff --git a/src/opencode_ai/_utils/_transform.py b/src/opencode_ai/_utils/_transform.py new file mode 100644 index 0000000..f0bcefd --- /dev/null +++ b/src/opencode_ai/_utils/_transform.py @@ -0,0 +1,453 @@ +from __future__ import annotations + +import io +import base64 +import pathlib +from typing import Any, Mapping, TypeVar, cast +from datetime import date, datetime +from typing_extensions import Literal, get_args, override, get_type_hints as _get_type_hints + +import anyio +import pydantic + +from ._utils import ( + is_list, + is_given, + lru_cache, + is_mapping, + is_iterable, + is_sequence, +) +from .._files import is_base64_file_input +from ._typing import ( + is_list_type, + is_union_type, + extract_type_arg, + is_iterable_type, + is_required_type, + is_sequence_type, + is_annotated_type, + strip_annotated_type, +) +from .._compat import get_origin, model_dump, is_typeddict + +_T = TypeVar("_T") + + +# TODO: support for drilling globals() and locals() +# TODO: ensure works correctly with forward references in all cases + + +PropertyFormat = Literal["iso8601", "base64", "custom"] + + +class PropertyInfo: + """Metadata class to be used in Annotated types to provide information about a given type. + + For example: + + class MyParams(TypedDict): + account_holder_name: Annotated[str, PropertyInfo(alias='accountHolderName')] + + This means that {'account_holder_name': 'Robert'} will be transformed to {'accountHolderName': 'Robert'} before being sent to the API. + """ + + alias: str | None + format: PropertyFormat | None + format_template: str | None + discriminator: str | None + + def __init__( + self, + *, + alias: str | None = None, + format: PropertyFormat | None = None, + format_template: str | None = None, + discriminator: str | None = None, + ) -> None: + self.alias = alias + self.format = format + self.format_template = format_template + self.discriminator = discriminator + + @override + def __repr__(self) -> str: + return f"{self.__class__.__name__}(alias='{self.alias}', format={self.format}, format_template='{self.format_template}', discriminator='{self.discriminator}')" + + +def maybe_transform( + data: object, + expected_type: object, +) -> Any | None: + """Wrapper over `transform()` that allows `None` to be passed. + + See `transform()` for more details. + """ + if data is None: + return None + return transform(data, expected_type) + + +# Wrapper over _transform_recursive providing fake types +def transform( + data: _T, + expected_type: object, +) -> _T: + """Transform dictionaries based off of type information from the given type, for example: + + ```py + class Params(TypedDict, total=False): + card_id: Required[Annotated[str, PropertyInfo(alias="cardID")]] + + + transformed = transform({"card_id": ""}, Params) + # {'cardID': ''} + ``` + + Any keys / data that does not have type information given will be included as is. + + It should be noted that the transformations that this function does are not represented in the type system. + """ + transformed = _transform_recursive(data, annotation=cast(type, expected_type)) + return cast(_T, transformed) + + +@lru_cache(maxsize=8096) +def _get_annotated_type(type_: type) -> type | None: + """If the given type is an `Annotated` type then it is returned, if not `None` is returned. + + This also unwraps the type when applicable, e.g. `Required[Annotated[T, ...]]` + """ + if is_required_type(type_): + # Unwrap `Required[Annotated[T, ...]]` to `Annotated[T, ...]` + type_ = get_args(type_)[0] + + if is_annotated_type(type_): + return type_ + + return None + + +def _maybe_transform_key(key: str, type_: type) -> str: + """Transform the given `data` based on the annotations provided in `type_`. + + Note: this function only looks at `Annotated` types that contain `PropertyInfo` metadata. + """ + annotated_type = _get_annotated_type(type_) + if annotated_type is None: + # no `Annotated` definition for this type, no transformation needed + return key + + # ignore the first argument as it is the actual type + annotations = get_args(annotated_type)[1:] + for annotation in annotations: + if isinstance(annotation, PropertyInfo) and annotation.alias is not None: + return annotation.alias + + return key + + +def _no_transform_needed(annotation: type) -> bool: + return annotation == float or annotation == int + + +def _transform_recursive( + data: object, + *, + annotation: type, + inner_type: type | None = None, +) -> object: + """Transform the given data against the expected type. + + Args: + annotation: The direct type annotation given to the particular piece of data. + This may or may not be wrapped in metadata types, e.g. `Required[T]`, `Annotated[T, ...]` etc + + inner_type: If applicable, this is the "inside" type. This is useful in certain cases where the outside type + is a container type such as `List[T]`. In that case `inner_type` should be set to `T` so that each entry in + the list can be transformed using the metadata from the container type. + + Defaults to the same value as the `annotation` argument. + """ + if inner_type is None: + inner_type = annotation + + stripped_type = strip_annotated_type(inner_type) + origin = get_origin(stripped_type) or stripped_type + if is_typeddict(stripped_type) and is_mapping(data): + return _transform_typeddict(data, stripped_type) + + if origin == dict and is_mapping(data): + items_type = get_args(stripped_type)[1] + return {key: _transform_recursive(value, annotation=items_type) for key, value in data.items()} + + if ( + # List[T] + (is_list_type(stripped_type) and is_list(data)) + # Iterable[T] + or (is_iterable_type(stripped_type) and is_iterable(data) and not isinstance(data, str)) + # Sequence[T] + or (is_sequence_type(stripped_type) and is_sequence(data) and not isinstance(data, str)) + ): + # dicts are technically iterable, but it is an iterable on the keys of the dict and is not usually + # intended as an iterable, so we don't transform it. + if isinstance(data, dict): + return cast(object, data) + + inner_type = extract_type_arg(stripped_type, 0) + if _no_transform_needed(inner_type): + # for some types there is no need to transform anything, so we can get a small + # perf boost from skipping that work. + # + # but we still need to convert to a list to ensure the data is json-serializable + if is_list(data): + return data + return list(data) + + return [_transform_recursive(d, annotation=annotation, inner_type=inner_type) for d in data] + + if is_union_type(stripped_type): + # For union types we run the transformation against all subtypes to ensure that everything is transformed. + # + # TODO: there may be edge cases where the same normalized field name will transform to two different names + # in different subtypes. + for subtype in get_args(stripped_type): + data = _transform_recursive(data, annotation=annotation, inner_type=subtype) + return data + + if isinstance(data, pydantic.BaseModel): + return model_dump(data, exclude_unset=True, mode="json") + + annotated_type = _get_annotated_type(annotation) + if annotated_type is None: + return data + + # ignore the first argument as it is the actual type + annotations = get_args(annotated_type)[1:] + for annotation in annotations: + if isinstance(annotation, PropertyInfo) and annotation.format is not None: + return _format_data(data, annotation.format, annotation.format_template) + + return data + + +def _format_data(data: object, format_: PropertyFormat, format_template: str | None) -> object: + if isinstance(data, (date, datetime)): + if format_ == "iso8601": + return data.isoformat() + + if format_ == "custom" and format_template is not None: + return data.strftime(format_template) + + if format_ == "base64" and is_base64_file_input(data): + binary: str | bytes | None = None + + if isinstance(data, pathlib.Path): + binary = data.read_bytes() + elif isinstance(data, io.IOBase): + binary = data.read() + + if isinstance(binary, str): # type: ignore[unreachable] + binary = binary.encode() + + if not isinstance(binary, bytes): + raise RuntimeError(f"Could not read bytes from {data}; Received {type(binary)}") + + return base64.b64encode(binary).decode("ascii") + + return data + + +def _transform_typeddict( + data: Mapping[str, object], + expected_type: type, +) -> Mapping[str, object]: + result: dict[str, object] = {} + annotations = get_type_hints(expected_type, include_extras=True) + for key, value in data.items(): + if not is_given(value): + # we don't need to include `NotGiven` values here as they'll + # be stripped out before the request is sent anyway + continue + + type_ = annotations.get(key) + if type_ is None: + # we do not have a type annotation for this field, leave it as is + result[key] = value + else: + result[_maybe_transform_key(key, type_)] = _transform_recursive(value, annotation=type_) + return result + + +async def async_maybe_transform( + data: object, + expected_type: object, +) -> Any | None: + """Wrapper over `async_transform()` that allows `None` to be passed. + + See `async_transform()` for more details. + """ + if data is None: + return None + return await async_transform(data, expected_type) + + +async def async_transform( + data: _T, + expected_type: object, +) -> _T: + """Transform dictionaries based off of type information from the given type, for example: + + ```py + class Params(TypedDict, total=False): + card_id: Required[Annotated[str, PropertyInfo(alias="cardID")]] + + + transformed = transform({"card_id": ""}, Params) + # {'cardID': ''} + ``` + + Any keys / data that does not have type information given will be included as is. + + It should be noted that the transformations that this function does are not represented in the type system. + """ + transformed = await _async_transform_recursive(data, annotation=cast(type, expected_type)) + return cast(_T, transformed) + + +async def _async_transform_recursive( + data: object, + *, + annotation: type, + inner_type: type | None = None, +) -> object: + """Transform the given data against the expected type. + + Args: + annotation: The direct type annotation given to the particular piece of data. + This may or may not be wrapped in metadata types, e.g. `Required[T]`, `Annotated[T, ...]` etc + + inner_type: If applicable, this is the "inside" type. This is useful in certain cases where the outside type + is a container type such as `List[T]`. In that case `inner_type` should be set to `T` so that each entry in + the list can be transformed using the metadata from the container type. + + Defaults to the same value as the `annotation` argument. + """ + if inner_type is None: + inner_type = annotation + + stripped_type = strip_annotated_type(inner_type) + origin = get_origin(stripped_type) or stripped_type + if is_typeddict(stripped_type) and is_mapping(data): + return await _async_transform_typeddict(data, stripped_type) + + if origin == dict and is_mapping(data): + items_type = get_args(stripped_type)[1] + return {key: _transform_recursive(value, annotation=items_type) for key, value in data.items()} + + if ( + # List[T] + (is_list_type(stripped_type) and is_list(data)) + # Iterable[T] + or (is_iterable_type(stripped_type) and is_iterable(data) and not isinstance(data, str)) + # Sequence[T] + or (is_sequence_type(stripped_type) and is_sequence(data) and not isinstance(data, str)) + ): + # dicts are technically iterable, but it is an iterable on the keys of the dict and is not usually + # intended as an iterable, so we don't transform it. + if isinstance(data, dict): + return cast(object, data) + + inner_type = extract_type_arg(stripped_type, 0) + if _no_transform_needed(inner_type): + # for some types there is no need to transform anything, so we can get a small + # perf boost from skipping that work. + # + # but we still need to convert to a list to ensure the data is json-serializable + if is_list(data): + return data + return list(data) + + return [await _async_transform_recursive(d, annotation=annotation, inner_type=inner_type) for d in data] + + if is_union_type(stripped_type): + # For union types we run the transformation against all subtypes to ensure that everything is transformed. + # + # TODO: there may be edge cases where the same normalized field name will transform to two different names + # in different subtypes. + for subtype in get_args(stripped_type): + data = await _async_transform_recursive(data, annotation=annotation, inner_type=subtype) + return data + + if isinstance(data, pydantic.BaseModel): + return model_dump(data, exclude_unset=True, mode="json") + + annotated_type = _get_annotated_type(annotation) + if annotated_type is None: + return data + + # ignore the first argument as it is the actual type + annotations = get_args(annotated_type)[1:] + for annotation in annotations: + if isinstance(annotation, PropertyInfo) and annotation.format is not None: + return await _async_format_data(data, annotation.format, annotation.format_template) + + return data + + +async def _async_format_data(data: object, format_: PropertyFormat, format_template: str | None) -> object: + if isinstance(data, (date, datetime)): + if format_ == "iso8601": + return data.isoformat() + + if format_ == "custom" and format_template is not None: + return data.strftime(format_template) + + if format_ == "base64" and is_base64_file_input(data): + binary: str | bytes | None = None + + if isinstance(data, pathlib.Path): + binary = await anyio.Path(data).read_bytes() + elif isinstance(data, io.IOBase): + binary = data.read() + + if isinstance(binary, str): # type: ignore[unreachable] + binary = binary.encode() + + if not isinstance(binary, bytes): + raise RuntimeError(f"Could not read bytes from {data}; Received {type(binary)}") + + return base64.b64encode(binary).decode("ascii") + + return data + + +async def _async_transform_typeddict( + data: Mapping[str, object], + expected_type: type, +) -> Mapping[str, object]: + result: dict[str, object] = {} + annotations = get_type_hints(expected_type, include_extras=True) + for key, value in data.items(): + if not is_given(value): + # we don't need to include `NotGiven` values here as they'll + # be stripped out before the request is sent anyway + continue + + type_ = annotations.get(key) + if type_ is None: + # we do not have a type annotation for this field, leave it as is + result[key] = value + else: + result[_maybe_transform_key(key, type_)] = await _async_transform_recursive(value, annotation=type_) + return result + + +@lru_cache(maxsize=8096) +def get_type_hints( + obj: Any, + globalns: dict[str, Any] | None = None, + localns: Mapping[str, Any] | None = None, + include_extras: bool = False, +) -> dict[str, Any]: + return _get_type_hints(obj, globalns=globalns, localns=localns, include_extras=include_extras) diff --git a/src/opencode_ai/_utils/_typing.py b/src/opencode_ai/_utils/_typing.py new file mode 100644 index 0000000..845cd6b --- /dev/null +++ b/src/opencode_ai/_utils/_typing.py @@ -0,0 +1,156 @@ +from __future__ import annotations + +import sys +import typing +import typing_extensions +from typing import Any, TypeVar, Iterable, cast +from collections import abc as _c_abc +from typing_extensions import ( + TypeIs, + Required, + Annotated, + get_args, + get_origin, +) + +from ._utils import lru_cache +from .._types import InheritsGeneric +from .._compat import is_union as _is_union + + +def is_annotated_type(typ: type) -> bool: + return get_origin(typ) == Annotated + + +def is_list_type(typ: type) -> bool: + return (get_origin(typ) or typ) == list + + +def is_sequence_type(typ: type) -> bool: + origin = get_origin(typ) or typ + return origin == typing_extensions.Sequence or origin == typing.Sequence or origin == _c_abc.Sequence + + +def is_iterable_type(typ: type) -> bool: + """If the given type is `typing.Iterable[T]`""" + origin = get_origin(typ) or typ + return origin == Iterable or origin == _c_abc.Iterable + + +def is_union_type(typ: type) -> bool: + return _is_union(get_origin(typ)) + + +def is_required_type(typ: type) -> bool: + return get_origin(typ) == Required + + +def is_typevar(typ: type) -> bool: + # type ignore is required because type checkers + # think this expression will always return False + return type(typ) == TypeVar # type: ignore + + +_TYPE_ALIAS_TYPES: tuple[type[typing_extensions.TypeAliasType], ...] = (typing_extensions.TypeAliasType,) +if sys.version_info >= (3, 12): + _TYPE_ALIAS_TYPES = (*_TYPE_ALIAS_TYPES, typing.TypeAliasType) + + +def is_type_alias_type(tp: Any, /) -> TypeIs[typing_extensions.TypeAliasType]: + """Return whether the provided argument is an instance of `TypeAliasType`. + + ```python + type Int = int + is_type_alias_type(Int) + # > True + Str = TypeAliasType("Str", str) + is_type_alias_type(Str) + # > True + ``` + """ + return isinstance(tp, _TYPE_ALIAS_TYPES) + + +# Extracts T from Annotated[T, ...] or from Required[Annotated[T, ...]] +@lru_cache(maxsize=8096) +def strip_annotated_type(typ: type) -> type: + if is_required_type(typ) or is_annotated_type(typ): + return strip_annotated_type(cast(type, get_args(typ)[0])) + + return typ + + +def extract_type_arg(typ: type, index: int) -> type: + args = get_args(typ) + try: + return cast(type, args[index]) + except IndexError as err: + raise RuntimeError(f"Expected type {typ} to have a type argument at index {index} but it did not") from err + + +def extract_type_var_from_base( + typ: type, + *, + generic_bases: tuple[type, ...], + index: int, + failure_message: str | None = None, +) -> type: + """Given a type like `Foo[T]`, returns the generic type variable `T`. + + This also handles the case where a concrete subclass is given, e.g. + ```py + class MyResponse(Foo[bytes]): + ... + + extract_type_var(MyResponse, bases=(Foo,), index=0) -> bytes + ``` + + And where a generic subclass is given: + ```py + _T = TypeVar('_T') + class MyResponse(Foo[_T]): + ... + + extract_type_var(MyResponse[bytes], bases=(Foo,), index=0) -> bytes + ``` + """ + cls = cast(object, get_origin(typ) or typ) + if cls in generic_bases: # pyright: ignore[reportUnnecessaryContains] + # we're given the class directly + return extract_type_arg(typ, index) + + # if a subclass is given + # --- + # this is needed as __orig_bases__ is not present in the typeshed stubs + # because it is intended to be for internal use only, however there does + # not seem to be a way to resolve generic TypeVars for inherited subclasses + # without using it. + if isinstance(cls, InheritsGeneric): + target_base_class: Any | None = None + for base in cls.__orig_bases__: + if base.__origin__ in generic_bases: + target_base_class = base + break + + if target_base_class is None: + raise RuntimeError( + "Could not find the generic base class;\n" + "This should never happen;\n" + f"Does {cls} inherit from one of {generic_bases} ?" + ) + + extracted = extract_type_arg(target_base_class, index) + if is_typevar(extracted): + # If the extracted type argument is itself a type variable + # then that means the subclass itself is generic, so we have + # to resolve the type argument from the class itself, not + # the base class. + # + # Note: if there is more than 1 type argument, the subclass could + # change the ordering of the type arguments, this is not currently + # supported. + return extract_type_arg(typ, index) + + return extracted + + raise RuntimeError(failure_message or f"Could not resolve inner type variable at index {index} for {typ}") diff --git a/src/opencode_ai/_utils/_utils.py b/src/opencode_ai/_utils/_utils.py new file mode 100644 index 0000000..ea3cf3f --- /dev/null +++ b/src/opencode_ai/_utils/_utils.py @@ -0,0 +1,422 @@ +from __future__ import annotations + +import os +import re +import inspect +import functools +from typing import ( + Any, + Tuple, + Mapping, + TypeVar, + Callable, + Iterable, + Sequence, + cast, + overload, +) +from pathlib import Path +from datetime import date, datetime +from typing_extensions import TypeGuard + +import sniffio + +from .._types import NotGiven, FileTypes, NotGivenOr, HeadersLike +from .._compat import parse_date as parse_date, parse_datetime as parse_datetime + +_T = TypeVar("_T") +_TupleT = TypeVar("_TupleT", bound=Tuple[object, ...]) +_MappingT = TypeVar("_MappingT", bound=Mapping[str, object]) +_SequenceT = TypeVar("_SequenceT", bound=Sequence[object]) +CallableT = TypeVar("CallableT", bound=Callable[..., Any]) + + +def flatten(t: Iterable[Iterable[_T]]) -> list[_T]: + return [item for sublist in t for item in sublist] + + +def extract_files( + # TODO: this needs to take Dict but variance issues..... + # create protocol type ? + query: Mapping[str, object], + *, + paths: Sequence[Sequence[str]], +) -> list[tuple[str, FileTypes]]: + """Recursively extract files from the given dictionary based on specified paths. + + A path may look like this ['foo', 'files', '', 'data']. + + Note: this mutates the given dictionary. + """ + files: list[tuple[str, FileTypes]] = [] + for path in paths: + files.extend(_extract_items(query, path, index=0, flattened_key=None)) + return files + + +def _extract_items( + obj: object, + path: Sequence[str], + *, + index: int, + flattened_key: str | None, +) -> list[tuple[str, FileTypes]]: + try: + key = path[index] + except IndexError: + if isinstance(obj, NotGiven): + # no value was provided - we can safely ignore + return [] + + # cyclical import + from .._files import assert_is_file_content + + # We have exhausted the path, return the entry we found. + assert flattened_key is not None + + if is_list(obj): + files: list[tuple[str, FileTypes]] = [] + for entry in obj: + assert_is_file_content(entry, key=flattened_key + "[]" if flattened_key else "") + files.append((flattened_key + "[]", cast(FileTypes, entry))) + return files + + assert_is_file_content(obj, key=flattened_key) + return [(flattened_key, cast(FileTypes, obj))] + + index += 1 + if is_dict(obj): + try: + # We are at the last entry in the path so we must remove the field + if (len(path)) == index: + item = obj.pop(key) + else: + item = obj[key] + except KeyError: + # Key was not present in the dictionary, this is not indicative of an error + # as the given path may not point to a required field. We also do not want + # to enforce required fields as the API may differ from the spec in some cases. + return [] + if flattened_key is None: + flattened_key = key + else: + flattened_key += f"[{key}]" + return _extract_items( + item, + path, + index=index, + flattened_key=flattened_key, + ) + elif is_list(obj): + if key != "": + return [] + + return flatten( + [ + _extract_items( + item, + path, + index=index, + flattened_key=flattened_key + "[]" if flattened_key is not None else "[]", + ) + for item in obj + ] + ) + + # Something unexpected was passed, just ignore it. + return [] + + +def is_given(obj: NotGivenOr[_T]) -> TypeGuard[_T]: + return not isinstance(obj, NotGiven) + + +# Type safe methods for narrowing types with TypeVars. +# The default narrowing for isinstance(obj, dict) is dict[unknown, unknown], +# however this cause Pyright to rightfully report errors. As we know we don't +# care about the contained types we can safely use `object` in it's place. +# +# There are two separate functions defined, `is_*` and `is_*_t` for different use cases. +# `is_*` is for when you're dealing with an unknown input +# `is_*_t` is for when you're narrowing a known union type to a specific subset + + +def is_tuple(obj: object) -> TypeGuard[tuple[object, ...]]: + return isinstance(obj, tuple) + + +def is_tuple_t(obj: _TupleT | object) -> TypeGuard[_TupleT]: + return isinstance(obj, tuple) + + +def is_sequence(obj: object) -> TypeGuard[Sequence[object]]: + return isinstance(obj, Sequence) + + +def is_sequence_t(obj: _SequenceT | object) -> TypeGuard[_SequenceT]: + return isinstance(obj, Sequence) + + +def is_mapping(obj: object) -> TypeGuard[Mapping[str, object]]: + return isinstance(obj, Mapping) + + +def is_mapping_t(obj: _MappingT | object) -> TypeGuard[_MappingT]: + return isinstance(obj, Mapping) + + +def is_dict(obj: object) -> TypeGuard[dict[object, object]]: + return isinstance(obj, dict) + + +def is_list(obj: object) -> TypeGuard[list[object]]: + return isinstance(obj, list) + + +def is_iterable(obj: object) -> TypeGuard[Iterable[object]]: + return isinstance(obj, Iterable) + + +def deepcopy_minimal(item: _T) -> _T: + """Minimal reimplementation of copy.deepcopy() that will only copy certain object types: + + - mappings, e.g. `dict` + - list + + This is done for performance reasons. + """ + if is_mapping(item): + return cast(_T, {k: deepcopy_minimal(v) for k, v in item.items()}) + if is_list(item): + return cast(_T, [deepcopy_minimal(entry) for entry in item]) + return item + + +# copied from https://github.com/Rapptz/RoboDanny +def human_join(seq: Sequence[str], *, delim: str = ", ", final: str = "or") -> str: + size = len(seq) + if size == 0: + return "" + + if size == 1: + return seq[0] + + if size == 2: + return f"{seq[0]} {final} {seq[1]}" + + return delim.join(seq[:-1]) + f" {final} {seq[-1]}" + + +def quote(string: str) -> str: + """Add single quotation marks around the given string. Does *not* do any escaping.""" + return f"'{string}'" + + +def required_args(*variants: Sequence[str]) -> Callable[[CallableT], CallableT]: + """Decorator to enforce a given set of arguments or variants of arguments are passed to the decorated function. + + Useful for enforcing runtime validation of overloaded functions. + + Example usage: + ```py + @overload + def foo(*, a: str) -> str: ... + + + @overload + def foo(*, b: bool) -> str: ... + + + # This enforces the same constraints that a static type checker would + # i.e. that either a or b must be passed to the function + @required_args(["a"], ["b"]) + def foo(*, a: str | None = None, b: bool | None = None) -> str: ... + ``` + """ + + def inner(func: CallableT) -> CallableT: + params = inspect.signature(func).parameters + positional = [ + name + for name, param in params.items() + if param.kind + in { + param.POSITIONAL_ONLY, + param.POSITIONAL_OR_KEYWORD, + } + ] + + @functools.wraps(func) + def wrapper(*args: object, **kwargs: object) -> object: + given_params: set[str] = set() + for i, _ in enumerate(args): + try: + given_params.add(positional[i]) + except IndexError: + raise TypeError( + f"{func.__name__}() takes {len(positional)} argument(s) but {len(args)} were given" + ) from None + + for key in kwargs.keys(): + given_params.add(key) + + for variant in variants: + matches = all((param in given_params for param in variant)) + if matches: + break + else: # no break + if len(variants) > 1: + variations = human_join( + ["(" + human_join([quote(arg) for arg in variant], final="and") + ")" for variant in variants] + ) + msg = f"Missing required arguments; Expected either {variations} arguments to be given" + else: + assert len(variants) > 0 + + # TODO: this error message is not deterministic + missing = list(set(variants[0]) - given_params) + if len(missing) > 1: + msg = f"Missing required arguments: {human_join([quote(arg) for arg in missing])}" + else: + msg = f"Missing required argument: {quote(missing[0])}" + raise TypeError(msg) + return func(*args, **kwargs) + + return wrapper # type: ignore + + return inner + + +_K = TypeVar("_K") +_V = TypeVar("_V") + + +@overload +def strip_not_given(obj: None) -> None: ... + + +@overload +def strip_not_given(obj: Mapping[_K, _V | NotGiven]) -> dict[_K, _V]: ... + + +@overload +def strip_not_given(obj: object) -> object: ... + + +def strip_not_given(obj: object | None) -> object: + """Remove all top-level keys where their values are instances of `NotGiven`""" + if obj is None: + return None + + if not is_mapping(obj): + return obj + + return {key: value for key, value in obj.items() if not isinstance(value, NotGiven)} + + +def coerce_integer(val: str) -> int: + return int(val, base=10) + + +def coerce_float(val: str) -> float: + return float(val) + + +def coerce_boolean(val: str) -> bool: + return val == "true" or val == "1" or val == "on" + + +def maybe_coerce_integer(val: str | None) -> int | None: + if val is None: + return None + return coerce_integer(val) + + +def maybe_coerce_float(val: str | None) -> float | None: + if val is None: + return None + return coerce_float(val) + + +def maybe_coerce_boolean(val: str | None) -> bool | None: + if val is None: + return None + return coerce_boolean(val) + + +def removeprefix(string: str, prefix: str) -> str: + """Remove a prefix from a string. + + Backport of `str.removeprefix` for Python < 3.9 + """ + if string.startswith(prefix): + return string[len(prefix) :] + return string + + +def removesuffix(string: str, suffix: str) -> str: + """Remove a suffix from a string. + + Backport of `str.removesuffix` for Python < 3.9 + """ + if string.endswith(suffix): + return string[: -len(suffix)] + return string + + +def file_from_path(path: str) -> FileTypes: + contents = Path(path).read_bytes() + file_name = os.path.basename(path) + return (file_name, contents) + + +def get_required_header(headers: HeadersLike, header: str) -> str: + lower_header = header.lower() + if is_mapping_t(headers): + # mypy doesn't understand the type narrowing here + for k, v in headers.items(): # type: ignore + if k.lower() == lower_header and isinstance(v, str): + return v + + # to deal with the case where the header looks like Stainless-Event-Id + intercaps_header = re.sub(r"([^\w])(\w)", lambda pat: pat.group(1) + pat.group(2).upper(), header.capitalize()) + + for normalized_header in [header, lower_header, header.upper(), intercaps_header]: + value = headers.get(normalized_header) + if value: + return value + + raise ValueError(f"Could not find {header} header") + + +def get_async_library() -> str: + try: + return sniffio.current_async_library() + except Exception: + return "false" + + +def lru_cache(*, maxsize: int | None = 128) -> Callable[[CallableT], CallableT]: + """A version of functools.lru_cache that retains the type signature + for the wrapped function arguments. + """ + wrapper = functools.lru_cache( # noqa: TID251 + maxsize=maxsize, + ) + return cast(Any, wrapper) # type: ignore[no-any-return] + + +def json_safe(data: object) -> object: + """Translates a mapping / sequence recursively in the same fashion + as `pydantic` v2's `model_dump(mode="json")`. + """ + if is_mapping(data): + return {json_safe(key): json_safe(value) for key, value in data.items()} + + if is_iterable(data) and not isinstance(data, (str, bytes, bytearray)): + return [json_safe(item) for item in data] + + if isinstance(data, (datetime, date)): + return data.isoformat() + + return data diff --git a/src/opencode_ai/_version.py b/src/opencode_ai/_version.py new file mode 100644 index 0000000..3e57475 --- /dev/null +++ b/src/opencode_ai/_version.py @@ -0,0 +1,4 @@ +# File generated from our OpenAPI spec by Stainless. See CONTRIBUTING.md for details. + +__title__ = "opencode_ai" +__version__ = "0.1.0-alpha.36" # x-release-please-version diff --git a/src/opencode_ai/lib/.keep b/src/opencode_ai/lib/.keep new file mode 100644 index 0000000..5e2c99f --- /dev/null +++ b/src/opencode_ai/lib/.keep @@ -0,0 +1,4 @@ +File generated from our OpenAPI spec by Stainless. + +This directory can be used to store custom files to expand the SDK. +It is ignored by Stainless code generation and its content (other than this keep file) won't be touched. \ No newline at end of file diff --git a/src/opencode_ai/py.typed b/src/opencode_ai/py.typed new file mode 100644 index 0000000..e69de29 diff --git a/src/opencode_ai/resources/__init__.py b/src/opencode_ai/resources/__init__.py new file mode 100644 index 0000000..c01ce0e --- /dev/null +++ b/src/opencode_ai/resources/__init__.py @@ -0,0 +1,159 @@ +# File generated from our OpenAPI spec by Stainless. See CONTRIBUTING.md for details. + +from .app import ( + AppResource, + AsyncAppResource, + AppResourceWithRawResponse, + AsyncAppResourceWithRawResponse, + AppResourceWithStreamingResponse, + AsyncAppResourceWithStreamingResponse, +) +from .tui import ( + TuiResource, + AsyncTuiResource, + TuiResourceWithRawResponse, + AsyncTuiResourceWithRawResponse, + TuiResourceWithStreamingResponse, + AsyncTuiResourceWithStreamingResponse, +) +from .file import ( + FileResource, + AsyncFileResource, + FileResourceWithRawResponse, + AsyncFileResourceWithRawResponse, + FileResourceWithStreamingResponse, + AsyncFileResourceWithStreamingResponse, +) +from .find import ( + FindResource, + AsyncFindResource, + FindResourceWithRawResponse, + AsyncFindResourceWithRawResponse, + FindResourceWithStreamingResponse, + AsyncFindResourceWithStreamingResponse, +) +from .path import ( + PathResource, + AsyncPathResource, + PathResourceWithRawResponse, + AsyncPathResourceWithRawResponse, + PathResourceWithStreamingResponse, + AsyncPathResourceWithStreamingResponse, +) +from .agent import ( + AgentResource, + AsyncAgentResource, + AgentResourceWithRawResponse, + AsyncAgentResourceWithRawResponse, + AgentResourceWithStreamingResponse, + AsyncAgentResourceWithStreamingResponse, +) +from .event import ( + EventResource, + AsyncEventResource, + EventResourceWithRawResponse, + AsyncEventResourceWithRawResponse, + EventResourceWithStreamingResponse, + AsyncEventResourceWithStreamingResponse, +) +from .config import ( + ConfigResource, + AsyncConfigResource, + ConfigResourceWithRawResponse, + AsyncConfigResourceWithRawResponse, + ConfigResourceWithStreamingResponse, + AsyncConfigResourceWithStreamingResponse, +) +from .command import ( + CommandResource, + AsyncCommandResource, + CommandResourceWithRawResponse, + AsyncCommandResourceWithRawResponse, + CommandResourceWithStreamingResponse, + AsyncCommandResourceWithStreamingResponse, +) +from .project import ( + ProjectResource, + AsyncProjectResource, + ProjectResourceWithRawResponse, + AsyncProjectResourceWithRawResponse, + ProjectResourceWithStreamingResponse, + AsyncProjectResourceWithStreamingResponse, +) +from .session import ( + SessionResource, + AsyncSessionResource, + SessionResourceWithRawResponse, + AsyncSessionResourceWithRawResponse, + SessionResourceWithStreamingResponse, + AsyncSessionResourceWithStreamingResponse, +) + +__all__ = [ + "EventResource", + "AsyncEventResource", + "EventResourceWithRawResponse", + "AsyncEventResourceWithRawResponse", + "EventResourceWithStreamingResponse", + "AsyncEventResourceWithStreamingResponse", + "PathResource", + "AsyncPathResource", + "PathResourceWithRawResponse", + "AsyncPathResourceWithRawResponse", + "PathResourceWithStreamingResponse", + "AsyncPathResourceWithStreamingResponse", + "AppResource", + "AsyncAppResource", + "AppResourceWithRawResponse", + "AsyncAppResourceWithRawResponse", + "AppResourceWithStreamingResponse", + "AsyncAppResourceWithStreamingResponse", + "AgentResource", + "AsyncAgentResource", + "AgentResourceWithRawResponse", + "AsyncAgentResourceWithRawResponse", + "AgentResourceWithStreamingResponse", + "AsyncAgentResourceWithStreamingResponse", + "FindResource", + "AsyncFindResource", + "FindResourceWithRawResponse", + "AsyncFindResourceWithRawResponse", + "FindResourceWithStreamingResponse", + "AsyncFindResourceWithStreamingResponse", + "FileResource", + "AsyncFileResource", + "FileResourceWithRawResponse", + "AsyncFileResourceWithRawResponse", + "FileResourceWithStreamingResponse", + "AsyncFileResourceWithStreamingResponse", + "ConfigResource", + "AsyncConfigResource", + "ConfigResourceWithRawResponse", + "AsyncConfigResourceWithRawResponse", + "ConfigResourceWithStreamingResponse", + "AsyncConfigResourceWithStreamingResponse", + "CommandResource", + "AsyncCommandResource", + "CommandResourceWithRawResponse", + "AsyncCommandResourceWithRawResponse", + "CommandResourceWithStreamingResponse", + "AsyncCommandResourceWithStreamingResponse", + "ProjectResource", + "AsyncProjectResource", + "ProjectResourceWithRawResponse", + "AsyncProjectResourceWithRawResponse", + "ProjectResourceWithStreamingResponse", + "AsyncProjectResourceWithStreamingResponse", + "SessionResource", + "AsyncSessionResource", + "SessionResourceWithRawResponse", + "AsyncSessionResourceWithRawResponse", + "SessionResourceWithStreamingResponse", + "AsyncSessionResourceWithStreamingResponse", + "TuiResource", + "AsyncTuiResource", + "TuiResourceWithRawResponse", + "AsyncTuiResourceWithRawResponse", + "TuiResourceWithStreamingResponse", + "AsyncTuiResourceWithStreamingResponse", +] diff --git a/src/opencode_ai/resources/agent.py b/src/opencode_ai/resources/agent.py new file mode 100644 index 0000000..084f87e --- /dev/null +++ b/src/opencode_ai/resources/agent.py @@ -0,0 +1,169 @@ +# File generated from our OpenAPI spec by Stainless. See CONTRIBUTING.md for details. + +from __future__ import annotations + +import httpx + +from ..types import agent_list_params +from .._types import NOT_GIVEN, Body, Query, Headers, NotGiven +from .._utils import maybe_transform, async_maybe_transform +from .._compat import cached_property +from .._resource import SyncAPIResource, AsyncAPIResource +from .._response import ( + to_raw_response_wrapper, + to_streamed_response_wrapper, + async_to_raw_response_wrapper, + async_to_streamed_response_wrapper, +) +from .._base_client import make_request_options +from ..types.agent_list_response import AgentListResponse + +__all__ = ["AgentResource", "AsyncAgentResource"] + + +class AgentResource(SyncAPIResource): + @cached_property + def with_raw_response(self) -> AgentResourceWithRawResponse: + """ + This property can be used as a prefix for any HTTP method call to return + the raw response object instead of the parsed content. + + For more information, see https://www.github.com/sst/opencode-sdk-python#accessing-raw-response-data-eg-headers + """ + return AgentResourceWithRawResponse(self) + + @cached_property + def with_streaming_response(self) -> AgentResourceWithStreamingResponse: + """ + An alternative to `.with_raw_response` that doesn't eagerly read the response body. + + For more information, see https://www.github.com/sst/opencode-sdk-python#with_streaming_response + """ + return AgentResourceWithStreamingResponse(self) + + def list( + self, + *, + directory: str | NotGiven = NOT_GIVEN, + # Use the following arguments if you need to pass additional parameters to the API that aren't available via kwargs. + # The extra values given here take precedence over values defined on the client or passed to this method. + extra_headers: Headers | None = None, + extra_query: Query | None = None, + extra_body: Body | None = None, + timeout: float | httpx.Timeout | None | NotGiven = NOT_GIVEN, + ) -> AgentListResponse: + """ + List all agents + + Args: + extra_headers: Send extra headers + + extra_query: Add additional query parameters to the request + + extra_body: Add additional JSON properties to the request + + timeout: Override the client-level default timeout for this request, in seconds + """ + return self._get( + "/agent", + options=make_request_options( + extra_headers=extra_headers, + extra_query=extra_query, + extra_body=extra_body, + timeout=timeout, + query=maybe_transform({"directory": directory}, agent_list_params.AgentListParams), + ), + cast_to=AgentListResponse, + ) + + +class AsyncAgentResource(AsyncAPIResource): + @cached_property + def with_raw_response(self) -> AsyncAgentResourceWithRawResponse: + """ + This property can be used as a prefix for any HTTP method call to return + the raw response object instead of the parsed content. + + For more information, see https://www.github.com/sst/opencode-sdk-python#accessing-raw-response-data-eg-headers + """ + return AsyncAgentResourceWithRawResponse(self) + + @cached_property + def with_streaming_response(self) -> AsyncAgentResourceWithStreamingResponse: + """ + An alternative to `.with_raw_response` that doesn't eagerly read the response body. + + For more information, see https://www.github.com/sst/opencode-sdk-python#with_streaming_response + """ + return AsyncAgentResourceWithStreamingResponse(self) + + async def list( + self, + *, + directory: str | NotGiven = NOT_GIVEN, + # Use the following arguments if you need to pass additional parameters to the API that aren't available via kwargs. + # The extra values given here take precedence over values defined on the client or passed to this method. + extra_headers: Headers | None = None, + extra_query: Query | None = None, + extra_body: Body | None = None, + timeout: float | httpx.Timeout | None | NotGiven = NOT_GIVEN, + ) -> AgentListResponse: + """ + List all agents + + Args: + extra_headers: Send extra headers + + extra_query: Add additional query parameters to the request + + extra_body: Add additional JSON properties to the request + + timeout: Override the client-level default timeout for this request, in seconds + """ + return await self._get( + "/agent", + options=make_request_options( + extra_headers=extra_headers, + extra_query=extra_query, + extra_body=extra_body, + timeout=timeout, + query=await async_maybe_transform({"directory": directory}, agent_list_params.AgentListParams), + ), + cast_to=AgentListResponse, + ) + + +class AgentResourceWithRawResponse: + def __init__(self, agent: AgentResource) -> None: + self._agent = agent + + self.list = to_raw_response_wrapper( + agent.list, + ) + + +class AsyncAgentResourceWithRawResponse: + def __init__(self, agent: AsyncAgentResource) -> None: + self._agent = agent + + self.list = async_to_raw_response_wrapper( + agent.list, + ) + + +class AgentResourceWithStreamingResponse: + def __init__(self, agent: AgentResource) -> None: + self._agent = agent + + self.list = to_streamed_response_wrapper( + agent.list, + ) + + +class AsyncAgentResourceWithStreamingResponse: + def __init__(self, agent: AsyncAgentResource) -> None: + self._agent = agent + + self.list = async_to_streamed_response_wrapper( + agent.list, + ) diff --git a/src/opencode_ai/resources/app.py b/src/opencode_ai/resources/app.py new file mode 100644 index 0000000..60ff06e --- /dev/null +++ b/src/opencode_ai/resources/app.py @@ -0,0 +1,297 @@ +# File generated from our OpenAPI spec by Stainless. See CONTRIBUTING.md for details. + +from __future__ import annotations + +from typing import Dict +from typing_extensions import Literal + +import httpx + +from ..types import app_log_params, app_providers_params +from .._types import NOT_GIVEN, Body, Query, Headers, NotGiven +from .._utils import maybe_transform, async_maybe_transform +from .._compat import cached_property +from .._resource import SyncAPIResource, AsyncAPIResource +from .._response import ( + to_raw_response_wrapper, + to_streamed_response_wrapper, + async_to_raw_response_wrapper, + async_to_streamed_response_wrapper, +) +from .._base_client import make_request_options +from ..types.app_log_response import AppLogResponse +from ..types.app_providers_response import AppProvidersResponse + +__all__ = ["AppResource", "AsyncAppResource"] + + +class AppResource(SyncAPIResource): + @cached_property + def with_raw_response(self) -> AppResourceWithRawResponse: + """ + This property can be used as a prefix for any HTTP method call to return + the raw response object instead of the parsed content. + + For more information, see https://www.github.com/sst/opencode-sdk-python#accessing-raw-response-data-eg-headers + """ + return AppResourceWithRawResponse(self) + + @cached_property + def with_streaming_response(self) -> AppResourceWithStreamingResponse: + """ + An alternative to `.with_raw_response` that doesn't eagerly read the response body. + + For more information, see https://www.github.com/sst/opencode-sdk-python#with_streaming_response + """ + return AppResourceWithStreamingResponse(self) + + def log( + self, + *, + level: Literal["debug", "info", "error", "warn"], + message: str, + service: str, + directory: str | NotGiven = NOT_GIVEN, + extra: Dict[str, object] | NotGiven = NOT_GIVEN, + # Use the following arguments if you need to pass additional parameters to the API that aren't available via kwargs. + # The extra values given here take precedence over values defined on the client or passed to this method. + extra_headers: Headers | None = None, + extra_query: Query | None = None, + extra_body: Body | None = None, + timeout: float | httpx.Timeout | None | NotGiven = NOT_GIVEN, + ) -> AppLogResponse: + """ + Write a log entry to the server logs + + Args: + level: Log level + + message: Log message + + service: Service name for the log entry + + extra: Additional metadata for the log entry + + extra_headers: Send extra headers + + extra_query: Add additional query parameters to the request + + extra_body: Add additional JSON properties to the request + + timeout: Override the client-level default timeout for this request, in seconds + """ + return self._post( + "/log", + body=maybe_transform( + { + "level": level, + "message": message, + "service": service, + "extra": extra, + }, + app_log_params.AppLogParams, + ), + options=make_request_options( + extra_headers=extra_headers, + extra_query=extra_query, + extra_body=extra_body, + timeout=timeout, + query=maybe_transform({"directory": directory}, app_log_params.AppLogParams), + ), + cast_to=AppLogResponse, + ) + + def providers( + self, + *, + directory: str | NotGiven = NOT_GIVEN, + # Use the following arguments if you need to pass additional parameters to the API that aren't available via kwargs. + # The extra values given here take precedence over values defined on the client or passed to this method. + extra_headers: Headers | None = None, + extra_query: Query | None = None, + extra_body: Body | None = None, + timeout: float | httpx.Timeout | None | NotGiven = NOT_GIVEN, + ) -> AppProvidersResponse: + """ + List all providers + + Args: + extra_headers: Send extra headers + + extra_query: Add additional query parameters to the request + + extra_body: Add additional JSON properties to the request + + timeout: Override the client-level default timeout for this request, in seconds + """ + return self._get( + "/config/providers", + options=make_request_options( + extra_headers=extra_headers, + extra_query=extra_query, + extra_body=extra_body, + timeout=timeout, + query=maybe_transform({"directory": directory}, app_providers_params.AppProvidersParams), + ), + cast_to=AppProvidersResponse, + ) + + +class AsyncAppResource(AsyncAPIResource): + @cached_property + def with_raw_response(self) -> AsyncAppResourceWithRawResponse: + """ + This property can be used as a prefix for any HTTP method call to return + the raw response object instead of the parsed content. + + For more information, see https://www.github.com/sst/opencode-sdk-python#accessing-raw-response-data-eg-headers + """ + return AsyncAppResourceWithRawResponse(self) + + @cached_property + def with_streaming_response(self) -> AsyncAppResourceWithStreamingResponse: + """ + An alternative to `.with_raw_response` that doesn't eagerly read the response body. + + For more information, see https://www.github.com/sst/opencode-sdk-python#with_streaming_response + """ + return AsyncAppResourceWithStreamingResponse(self) + + async def log( + self, + *, + level: Literal["debug", "info", "error", "warn"], + message: str, + service: str, + directory: str | NotGiven = NOT_GIVEN, + extra: Dict[str, object] | NotGiven = NOT_GIVEN, + # Use the following arguments if you need to pass additional parameters to the API that aren't available via kwargs. + # The extra values given here take precedence over values defined on the client or passed to this method. + extra_headers: Headers | None = None, + extra_query: Query | None = None, + extra_body: Body | None = None, + timeout: float | httpx.Timeout | None | NotGiven = NOT_GIVEN, + ) -> AppLogResponse: + """ + Write a log entry to the server logs + + Args: + level: Log level + + message: Log message + + service: Service name for the log entry + + extra: Additional metadata for the log entry + + extra_headers: Send extra headers + + extra_query: Add additional query parameters to the request + + extra_body: Add additional JSON properties to the request + + timeout: Override the client-level default timeout for this request, in seconds + """ + return await self._post( + "/log", + body=await async_maybe_transform( + { + "level": level, + "message": message, + "service": service, + "extra": extra, + }, + app_log_params.AppLogParams, + ), + options=make_request_options( + extra_headers=extra_headers, + extra_query=extra_query, + extra_body=extra_body, + timeout=timeout, + query=await async_maybe_transform({"directory": directory}, app_log_params.AppLogParams), + ), + cast_to=AppLogResponse, + ) + + async def providers( + self, + *, + directory: str | NotGiven = NOT_GIVEN, + # Use the following arguments if you need to pass additional parameters to the API that aren't available via kwargs. + # The extra values given here take precedence over values defined on the client or passed to this method. + extra_headers: Headers | None = None, + extra_query: Query | None = None, + extra_body: Body | None = None, + timeout: float | httpx.Timeout | None | NotGiven = NOT_GIVEN, + ) -> AppProvidersResponse: + """ + List all providers + + Args: + extra_headers: Send extra headers + + extra_query: Add additional query parameters to the request + + extra_body: Add additional JSON properties to the request + + timeout: Override the client-level default timeout for this request, in seconds + """ + return await self._get( + "/config/providers", + options=make_request_options( + extra_headers=extra_headers, + extra_query=extra_query, + extra_body=extra_body, + timeout=timeout, + query=await async_maybe_transform({"directory": directory}, app_providers_params.AppProvidersParams), + ), + cast_to=AppProvidersResponse, + ) + + +class AppResourceWithRawResponse: + def __init__(self, app: AppResource) -> None: + self._app = app + + self.log = to_raw_response_wrapper( + app.log, + ) + self.providers = to_raw_response_wrapper( + app.providers, + ) + + +class AsyncAppResourceWithRawResponse: + def __init__(self, app: AsyncAppResource) -> None: + self._app = app + + self.log = async_to_raw_response_wrapper( + app.log, + ) + self.providers = async_to_raw_response_wrapper( + app.providers, + ) + + +class AppResourceWithStreamingResponse: + def __init__(self, app: AppResource) -> None: + self._app = app + + self.log = to_streamed_response_wrapper( + app.log, + ) + self.providers = to_streamed_response_wrapper( + app.providers, + ) + + +class AsyncAppResourceWithStreamingResponse: + def __init__(self, app: AsyncAppResource) -> None: + self._app = app + + self.log = async_to_streamed_response_wrapper( + app.log, + ) + self.providers = async_to_streamed_response_wrapper( + app.providers, + ) diff --git a/src/opencode_ai/resources/command.py b/src/opencode_ai/resources/command.py new file mode 100644 index 0000000..57cfbc1 --- /dev/null +++ b/src/opencode_ai/resources/command.py @@ -0,0 +1,169 @@ +# File generated from our OpenAPI spec by Stainless. See CONTRIBUTING.md for details. + +from __future__ import annotations + +import httpx + +from ..types import command_list_params +from .._types import NOT_GIVEN, Body, Query, Headers, NotGiven +from .._utils import maybe_transform, async_maybe_transform +from .._compat import cached_property +from .._resource import SyncAPIResource, AsyncAPIResource +from .._response import ( + to_raw_response_wrapper, + to_streamed_response_wrapper, + async_to_raw_response_wrapper, + async_to_streamed_response_wrapper, +) +from .._base_client import make_request_options +from ..types.command_list_response import CommandListResponse + +__all__ = ["CommandResource", "AsyncCommandResource"] + + +class CommandResource(SyncAPIResource): + @cached_property + def with_raw_response(self) -> CommandResourceWithRawResponse: + """ + This property can be used as a prefix for any HTTP method call to return + the raw response object instead of the parsed content. + + For more information, see https://www.github.com/sst/opencode-sdk-python#accessing-raw-response-data-eg-headers + """ + return CommandResourceWithRawResponse(self) + + @cached_property + def with_streaming_response(self) -> CommandResourceWithStreamingResponse: + """ + An alternative to `.with_raw_response` that doesn't eagerly read the response body. + + For more information, see https://www.github.com/sst/opencode-sdk-python#with_streaming_response + """ + return CommandResourceWithStreamingResponse(self) + + def list( + self, + *, + directory: str | NotGiven = NOT_GIVEN, + # Use the following arguments if you need to pass additional parameters to the API that aren't available via kwargs. + # The extra values given here take precedence over values defined on the client or passed to this method. + extra_headers: Headers | None = None, + extra_query: Query | None = None, + extra_body: Body | None = None, + timeout: float | httpx.Timeout | None | NotGiven = NOT_GIVEN, + ) -> CommandListResponse: + """ + List all commands + + Args: + extra_headers: Send extra headers + + extra_query: Add additional query parameters to the request + + extra_body: Add additional JSON properties to the request + + timeout: Override the client-level default timeout for this request, in seconds + """ + return self._get( + "/command", + options=make_request_options( + extra_headers=extra_headers, + extra_query=extra_query, + extra_body=extra_body, + timeout=timeout, + query=maybe_transform({"directory": directory}, command_list_params.CommandListParams), + ), + cast_to=CommandListResponse, + ) + + +class AsyncCommandResource(AsyncAPIResource): + @cached_property + def with_raw_response(self) -> AsyncCommandResourceWithRawResponse: + """ + This property can be used as a prefix for any HTTP method call to return + the raw response object instead of the parsed content. + + For more information, see https://www.github.com/sst/opencode-sdk-python#accessing-raw-response-data-eg-headers + """ + return AsyncCommandResourceWithRawResponse(self) + + @cached_property + def with_streaming_response(self) -> AsyncCommandResourceWithStreamingResponse: + """ + An alternative to `.with_raw_response` that doesn't eagerly read the response body. + + For more information, see https://www.github.com/sst/opencode-sdk-python#with_streaming_response + """ + return AsyncCommandResourceWithStreamingResponse(self) + + async def list( + self, + *, + directory: str | NotGiven = NOT_GIVEN, + # Use the following arguments if you need to pass additional parameters to the API that aren't available via kwargs. + # The extra values given here take precedence over values defined on the client or passed to this method. + extra_headers: Headers | None = None, + extra_query: Query | None = None, + extra_body: Body | None = None, + timeout: float | httpx.Timeout | None | NotGiven = NOT_GIVEN, + ) -> CommandListResponse: + """ + List all commands + + Args: + extra_headers: Send extra headers + + extra_query: Add additional query parameters to the request + + extra_body: Add additional JSON properties to the request + + timeout: Override the client-level default timeout for this request, in seconds + """ + return await self._get( + "/command", + options=make_request_options( + extra_headers=extra_headers, + extra_query=extra_query, + extra_body=extra_body, + timeout=timeout, + query=await async_maybe_transform({"directory": directory}, command_list_params.CommandListParams), + ), + cast_to=CommandListResponse, + ) + + +class CommandResourceWithRawResponse: + def __init__(self, command: CommandResource) -> None: + self._command = command + + self.list = to_raw_response_wrapper( + command.list, + ) + + +class AsyncCommandResourceWithRawResponse: + def __init__(self, command: AsyncCommandResource) -> None: + self._command = command + + self.list = async_to_raw_response_wrapper( + command.list, + ) + + +class CommandResourceWithStreamingResponse: + def __init__(self, command: CommandResource) -> None: + self._command = command + + self.list = to_streamed_response_wrapper( + command.list, + ) + + +class AsyncCommandResourceWithStreamingResponse: + def __init__(self, command: AsyncCommandResource) -> None: + self._command = command + + self.list = async_to_streamed_response_wrapper( + command.list, + ) diff --git a/src/opencode_ai/resources/config.py b/src/opencode_ai/resources/config.py new file mode 100644 index 0000000..f460338 --- /dev/null +++ b/src/opencode_ai/resources/config.py @@ -0,0 +1,169 @@ +# File generated from our OpenAPI spec by Stainless. See CONTRIBUTING.md for details. + +from __future__ import annotations + +import httpx + +from ..types import config_get_params +from .._types import NOT_GIVEN, Body, Query, Headers, NotGiven +from .._utils import maybe_transform, async_maybe_transform +from .._compat import cached_property +from .._resource import SyncAPIResource, AsyncAPIResource +from .._response import ( + to_raw_response_wrapper, + to_streamed_response_wrapper, + async_to_raw_response_wrapper, + async_to_streamed_response_wrapper, +) +from .._base_client import make_request_options +from ..types.config import Config + +__all__ = ["ConfigResource", "AsyncConfigResource"] + + +class ConfigResource(SyncAPIResource): + @cached_property + def with_raw_response(self) -> ConfigResourceWithRawResponse: + """ + This property can be used as a prefix for any HTTP method call to return + the raw response object instead of the parsed content. + + For more information, see https://www.github.com/sst/opencode-sdk-python#accessing-raw-response-data-eg-headers + """ + return ConfigResourceWithRawResponse(self) + + @cached_property + def with_streaming_response(self) -> ConfigResourceWithStreamingResponse: + """ + An alternative to `.with_raw_response` that doesn't eagerly read the response body. + + For more information, see https://www.github.com/sst/opencode-sdk-python#with_streaming_response + """ + return ConfigResourceWithStreamingResponse(self) + + def get( + self, + *, + directory: str | NotGiven = NOT_GIVEN, + # Use the following arguments if you need to pass additional parameters to the API that aren't available via kwargs. + # The extra values given here take precedence over values defined on the client or passed to this method. + extra_headers: Headers | None = None, + extra_query: Query | None = None, + extra_body: Body | None = None, + timeout: float | httpx.Timeout | None | NotGiven = NOT_GIVEN, + ) -> Config: + """ + Get config info + + Args: + extra_headers: Send extra headers + + extra_query: Add additional query parameters to the request + + extra_body: Add additional JSON properties to the request + + timeout: Override the client-level default timeout for this request, in seconds + """ + return self._get( + "/config", + options=make_request_options( + extra_headers=extra_headers, + extra_query=extra_query, + extra_body=extra_body, + timeout=timeout, + query=maybe_transform({"directory": directory}, config_get_params.ConfigGetParams), + ), + cast_to=Config, + ) + + +class AsyncConfigResource(AsyncAPIResource): + @cached_property + def with_raw_response(self) -> AsyncConfigResourceWithRawResponse: + """ + This property can be used as a prefix for any HTTP method call to return + the raw response object instead of the parsed content. + + For more information, see https://www.github.com/sst/opencode-sdk-python#accessing-raw-response-data-eg-headers + """ + return AsyncConfigResourceWithRawResponse(self) + + @cached_property + def with_streaming_response(self) -> AsyncConfigResourceWithStreamingResponse: + """ + An alternative to `.with_raw_response` that doesn't eagerly read the response body. + + For more information, see https://www.github.com/sst/opencode-sdk-python#with_streaming_response + """ + return AsyncConfigResourceWithStreamingResponse(self) + + async def get( + self, + *, + directory: str | NotGiven = NOT_GIVEN, + # Use the following arguments if you need to pass additional parameters to the API that aren't available via kwargs. + # The extra values given here take precedence over values defined on the client or passed to this method. + extra_headers: Headers | None = None, + extra_query: Query | None = None, + extra_body: Body | None = None, + timeout: float | httpx.Timeout | None | NotGiven = NOT_GIVEN, + ) -> Config: + """ + Get config info + + Args: + extra_headers: Send extra headers + + extra_query: Add additional query parameters to the request + + extra_body: Add additional JSON properties to the request + + timeout: Override the client-level default timeout for this request, in seconds + """ + return await self._get( + "/config", + options=make_request_options( + extra_headers=extra_headers, + extra_query=extra_query, + extra_body=extra_body, + timeout=timeout, + query=await async_maybe_transform({"directory": directory}, config_get_params.ConfigGetParams), + ), + cast_to=Config, + ) + + +class ConfigResourceWithRawResponse: + def __init__(self, config: ConfigResource) -> None: + self._config = config + + self.get = to_raw_response_wrapper( + config.get, + ) + + +class AsyncConfigResourceWithRawResponse: + def __init__(self, config: AsyncConfigResource) -> None: + self._config = config + + self.get = async_to_raw_response_wrapper( + config.get, + ) + + +class ConfigResourceWithStreamingResponse: + def __init__(self, config: ConfigResource) -> None: + self._config = config + + self.get = to_streamed_response_wrapper( + config.get, + ) + + +class AsyncConfigResourceWithStreamingResponse: + def __init__(self, config: AsyncConfigResource) -> None: + self._config = config + + self.get = async_to_streamed_response_wrapper( + config.get, + ) diff --git a/src/opencode_ai/resources/event.py b/src/opencode_ai/resources/event.py new file mode 100644 index 0000000..118c557 --- /dev/null +++ b/src/opencode_ai/resources/event.py @@ -0,0 +1,178 @@ +# File generated from our OpenAPI spec by Stainless. See CONTRIBUTING.md for details. + +from __future__ import annotations + +from typing import Any, cast + +import httpx + +from ..types import event_list_params +from .._types import NOT_GIVEN, Body, Query, Headers, NotGiven +from .._utils import maybe_transform, async_maybe_transform +from .._compat import cached_property +from .._resource import SyncAPIResource, AsyncAPIResource +from .._response import ( + to_raw_response_wrapper, + to_streamed_response_wrapper, + async_to_raw_response_wrapper, + async_to_streamed_response_wrapper, +) +from .._streaming import Stream, AsyncStream +from .._base_client import make_request_options +from ..types.event_list_response import EventListResponse + +__all__ = ["EventResource", "AsyncEventResource"] + + +class EventResource(SyncAPIResource): + @cached_property + def with_raw_response(self) -> EventResourceWithRawResponse: + """ + This property can be used as a prefix for any HTTP method call to return + the raw response object instead of the parsed content. + + For more information, see https://www.github.com/sst/opencode-sdk-python#accessing-raw-response-data-eg-headers + """ + return EventResourceWithRawResponse(self) + + @cached_property + def with_streaming_response(self) -> EventResourceWithStreamingResponse: + """ + An alternative to `.with_raw_response` that doesn't eagerly read the response body. + + For more information, see https://www.github.com/sst/opencode-sdk-python#with_streaming_response + """ + return EventResourceWithStreamingResponse(self) + + def list( + self, + *, + directory: str | NotGiven = NOT_GIVEN, + # Use the following arguments if you need to pass additional parameters to the API that aren't available via kwargs. + # The extra values given here take precedence over values defined on the client or passed to this method. + extra_headers: Headers | None = None, + extra_query: Query | None = None, + extra_body: Body | None = None, + timeout: float | httpx.Timeout | None | NotGiven = NOT_GIVEN, + ) -> Stream[EventListResponse]: + """ + Get events + + Args: + extra_headers: Send extra headers + + extra_query: Add additional query parameters to the request + + extra_body: Add additional JSON properties to the request + + timeout: Override the client-level default timeout for this request, in seconds + """ + extra_headers = {"Accept": "text/event-stream", **(extra_headers or {})} + return self._get( + "/event", + options=make_request_options( + extra_headers=extra_headers, + extra_query=extra_query, + extra_body=extra_body, + timeout=timeout, + query=maybe_transform({"directory": directory}, event_list_params.EventListParams), + ), + cast_to=cast(Any, EventListResponse), # Union types cannot be passed in as arguments in the type system + stream=True, + stream_cls=Stream[EventListResponse], + ) + + +class AsyncEventResource(AsyncAPIResource): + @cached_property + def with_raw_response(self) -> AsyncEventResourceWithRawResponse: + """ + This property can be used as a prefix for any HTTP method call to return + the raw response object instead of the parsed content. + + For more information, see https://www.github.com/sst/opencode-sdk-python#accessing-raw-response-data-eg-headers + """ + return AsyncEventResourceWithRawResponse(self) + + @cached_property + def with_streaming_response(self) -> AsyncEventResourceWithStreamingResponse: + """ + An alternative to `.with_raw_response` that doesn't eagerly read the response body. + + For more information, see https://www.github.com/sst/opencode-sdk-python#with_streaming_response + """ + return AsyncEventResourceWithStreamingResponse(self) + + async def list( + self, + *, + directory: str | NotGiven = NOT_GIVEN, + # Use the following arguments if you need to pass additional parameters to the API that aren't available via kwargs. + # The extra values given here take precedence over values defined on the client or passed to this method. + extra_headers: Headers | None = None, + extra_query: Query | None = None, + extra_body: Body | None = None, + timeout: float | httpx.Timeout | None | NotGiven = NOT_GIVEN, + ) -> AsyncStream[EventListResponse]: + """ + Get events + + Args: + extra_headers: Send extra headers + + extra_query: Add additional query parameters to the request + + extra_body: Add additional JSON properties to the request + + timeout: Override the client-level default timeout for this request, in seconds + """ + extra_headers = {"Accept": "text/event-stream", **(extra_headers or {})} + return await self._get( + "/event", + options=make_request_options( + extra_headers=extra_headers, + extra_query=extra_query, + extra_body=extra_body, + timeout=timeout, + query=await async_maybe_transform({"directory": directory}, event_list_params.EventListParams), + ), + cast_to=cast(Any, EventListResponse), # Union types cannot be passed in as arguments in the type system + stream=True, + stream_cls=AsyncStream[EventListResponse], + ) + + +class EventResourceWithRawResponse: + def __init__(self, event: EventResource) -> None: + self._event = event + + self.list = to_raw_response_wrapper( + event.list, + ) + + +class AsyncEventResourceWithRawResponse: + def __init__(self, event: AsyncEventResource) -> None: + self._event = event + + self.list = async_to_raw_response_wrapper( + event.list, + ) + + +class EventResourceWithStreamingResponse: + def __init__(self, event: EventResource) -> None: + self._event = event + + self.list = to_streamed_response_wrapper( + event.list, + ) + + +class AsyncEventResourceWithStreamingResponse: + def __init__(self, event: AsyncEventResource) -> None: + self._event = event + + self.list = async_to_streamed_response_wrapper( + event.list, + ) diff --git a/src/opencode_ai/resources/file.py b/src/opencode_ai/resources/file.py new file mode 100644 index 0000000..2e10468 --- /dev/null +++ b/src/opencode_ai/resources/file.py @@ -0,0 +1,363 @@ +# File generated from our OpenAPI spec by Stainless. See CONTRIBUTING.md for details. + +from __future__ import annotations + +import httpx + +from ..types import file_list_params, file_read_params, file_status_params +from .._types import NOT_GIVEN, Body, Query, Headers, NotGiven +from .._utils import maybe_transform, async_maybe_transform +from .._compat import cached_property +from .._resource import SyncAPIResource, AsyncAPIResource +from .._response import ( + to_raw_response_wrapper, + to_streamed_response_wrapper, + async_to_raw_response_wrapper, + async_to_streamed_response_wrapper, +) +from .._base_client import make_request_options +from ..types.file_list_response import FileListResponse +from ..types.file_read_response import FileReadResponse +from ..types.file_status_response import FileStatusResponse + +__all__ = ["FileResource", "AsyncFileResource"] + + +class FileResource(SyncAPIResource): + @cached_property + def with_raw_response(self) -> FileResourceWithRawResponse: + """ + This property can be used as a prefix for any HTTP method call to return + the raw response object instead of the parsed content. + + For more information, see https://www.github.com/sst/opencode-sdk-python#accessing-raw-response-data-eg-headers + """ + return FileResourceWithRawResponse(self) + + @cached_property + def with_streaming_response(self) -> FileResourceWithStreamingResponse: + """ + An alternative to `.with_raw_response` that doesn't eagerly read the response body. + + For more information, see https://www.github.com/sst/opencode-sdk-python#with_streaming_response + """ + return FileResourceWithStreamingResponse(self) + + def list( + self, + *, + path: str, + directory: str | NotGiven = NOT_GIVEN, + # Use the following arguments if you need to pass additional parameters to the API that aren't available via kwargs. + # The extra values given here take precedence over values defined on the client or passed to this method. + extra_headers: Headers | None = None, + extra_query: Query | None = None, + extra_body: Body | None = None, + timeout: float | httpx.Timeout | None | NotGiven = NOT_GIVEN, + ) -> FileListResponse: + """ + List files and directories + + Args: + extra_headers: Send extra headers + + extra_query: Add additional query parameters to the request + + extra_body: Add additional JSON properties to the request + + timeout: Override the client-level default timeout for this request, in seconds + """ + return self._get( + "/file", + options=make_request_options( + extra_headers=extra_headers, + extra_query=extra_query, + extra_body=extra_body, + timeout=timeout, + query=maybe_transform( + { + "path": path, + "directory": directory, + }, + file_list_params.FileListParams, + ), + ), + cast_to=FileListResponse, + ) + + def read( + self, + *, + path: str, + directory: str | NotGiven = NOT_GIVEN, + # Use the following arguments if you need to pass additional parameters to the API that aren't available via kwargs. + # The extra values given here take precedence over values defined on the client or passed to this method. + extra_headers: Headers | None = None, + extra_query: Query | None = None, + extra_body: Body | None = None, + timeout: float | httpx.Timeout | None | NotGiven = NOT_GIVEN, + ) -> FileReadResponse: + """ + Read a file + + Args: + extra_headers: Send extra headers + + extra_query: Add additional query parameters to the request + + extra_body: Add additional JSON properties to the request + + timeout: Override the client-level default timeout for this request, in seconds + """ + return self._get( + "/file/content", + options=make_request_options( + extra_headers=extra_headers, + extra_query=extra_query, + extra_body=extra_body, + timeout=timeout, + query=maybe_transform( + { + "path": path, + "directory": directory, + }, + file_read_params.FileReadParams, + ), + ), + cast_to=FileReadResponse, + ) + + def status( + self, + *, + directory: str | NotGiven = NOT_GIVEN, + # Use the following arguments if you need to pass additional parameters to the API that aren't available via kwargs. + # The extra values given here take precedence over values defined on the client or passed to this method. + extra_headers: Headers | None = None, + extra_query: Query | None = None, + extra_body: Body | None = None, + timeout: float | httpx.Timeout | None | NotGiven = NOT_GIVEN, + ) -> FileStatusResponse: + """ + Get file status + + Args: + extra_headers: Send extra headers + + extra_query: Add additional query parameters to the request + + extra_body: Add additional JSON properties to the request + + timeout: Override the client-level default timeout for this request, in seconds + """ + return self._get( + "/file/status", + options=make_request_options( + extra_headers=extra_headers, + extra_query=extra_query, + extra_body=extra_body, + timeout=timeout, + query=maybe_transform({"directory": directory}, file_status_params.FileStatusParams), + ), + cast_to=FileStatusResponse, + ) + + +class AsyncFileResource(AsyncAPIResource): + @cached_property + def with_raw_response(self) -> AsyncFileResourceWithRawResponse: + """ + This property can be used as a prefix for any HTTP method call to return + the raw response object instead of the parsed content. + + For more information, see https://www.github.com/sst/opencode-sdk-python#accessing-raw-response-data-eg-headers + """ + return AsyncFileResourceWithRawResponse(self) + + @cached_property + def with_streaming_response(self) -> AsyncFileResourceWithStreamingResponse: + """ + An alternative to `.with_raw_response` that doesn't eagerly read the response body. + + For more information, see https://www.github.com/sst/opencode-sdk-python#with_streaming_response + """ + return AsyncFileResourceWithStreamingResponse(self) + + async def list( + self, + *, + path: str, + directory: str | NotGiven = NOT_GIVEN, + # Use the following arguments if you need to pass additional parameters to the API that aren't available via kwargs. + # The extra values given here take precedence over values defined on the client or passed to this method. + extra_headers: Headers | None = None, + extra_query: Query | None = None, + extra_body: Body | None = None, + timeout: float | httpx.Timeout | None | NotGiven = NOT_GIVEN, + ) -> FileListResponse: + """ + List files and directories + + Args: + extra_headers: Send extra headers + + extra_query: Add additional query parameters to the request + + extra_body: Add additional JSON properties to the request + + timeout: Override the client-level default timeout for this request, in seconds + """ + return await self._get( + "/file", + options=make_request_options( + extra_headers=extra_headers, + extra_query=extra_query, + extra_body=extra_body, + timeout=timeout, + query=await async_maybe_transform( + { + "path": path, + "directory": directory, + }, + file_list_params.FileListParams, + ), + ), + cast_to=FileListResponse, + ) + + async def read( + self, + *, + path: str, + directory: str | NotGiven = NOT_GIVEN, + # Use the following arguments if you need to pass additional parameters to the API that aren't available via kwargs. + # The extra values given here take precedence over values defined on the client or passed to this method. + extra_headers: Headers | None = None, + extra_query: Query | None = None, + extra_body: Body | None = None, + timeout: float | httpx.Timeout | None | NotGiven = NOT_GIVEN, + ) -> FileReadResponse: + """ + Read a file + + Args: + extra_headers: Send extra headers + + extra_query: Add additional query parameters to the request + + extra_body: Add additional JSON properties to the request + + timeout: Override the client-level default timeout for this request, in seconds + """ + return await self._get( + "/file/content", + options=make_request_options( + extra_headers=extra_headers, + extra_query=extra_query, + extra_body=extra_body, + timeout=timeout, + query=await async_maybe_transform( + { + "path": path, + "directory": directory, + }, + file_read_params.FileReadParams, + ), + ), + cast_to=FileReadResponse, + ) + + async def status( + self, + *, + directory: str | NotGiven = NOT_GIVEN, + # Use the following arguments if you need to pass additional parameters to the API that aren't available via kwargs. + # The extra values given here take precedence over values defined on the client or passed to this method. + extra_headers: Headers | None = None, + extra_query: Query | None = None, + extra_body: Body | None = None, + timeout: float | httpx.Timeout | None | NotGiven = NOT_GIVEN, + ) -> FileStatusResponse: + """ + Get file status + + Args: + extra_headers: Send extra headers + + extra_query: Add additional query parameters to the request + + extra_body: Add additional JSON properties to the request + + timeout: Override the client-level default timeout for this request, in seconds + """ + return await self._get( + "/file/status", + options=make_request_options( + extra_headers=extra_headers, + extra_query=extra_query, + extra_body=extra_body, + timeout=timeout, + query=await async_maybe_transform({"directory": directory}, file_status_params.FileStatusParams), + ), + cast_to=FileStatusResponse, + ) + + +class FileResourceWithRawResponse: + def __init__(self, file: FileResource) -> None: + self._file = file + + self.list = to_raw_response_wrapper( + file.list, + ) + self.read = to_raw_response_wrapper( + file.read, + ) + self.status = to_raw_response_wrapper( + file.status, + ) + + +class AsyncFileResourceWithRawResponse: + def __init__(self, file: AsyncFileResource) -> None: + self._file = file + + self.list = async_to_raw_response_wrapper( + file.list, + ) + self.read = async_to_raw_response_wrapper( + file.read, + ) + self.status = async_to_raw_response_wrapper( + file.status, + ) + + +class FileResourceWithStreamingResponse: + def __init__(self, file: FileResource) -> None: + self._file = file + + self.list = to_streamed_response_wrapper( + file.list, + ) + self.read = to_streamed_response_wrapper( + file.read, + ) + self.status = to_streamed_response_wrapper( + file.status, + ) + + +class AsyncFileResourceWithStreamingResponse: + def __init__(self, file: AsyncFileResource) -> None: + self._file = file + + self.list = async_to_streamed_response_wrapper( + file.list, + ) + self.read = async_to_streamed_response_wrapper( + file.read, + ) + self.status = async_to_streamed_response_wrapper( + file.status, + ) diff --git a/src/opencode_ai/resources/find.py b/src/opencode_ai/resources/find.py new file mode 100644 index 0000000..6d8476d --- /dev/null +++ b/src/opencode_ai/resources/find.py @@ -0,0 +1,377 @@ +# File generated from our OpenAPI spec by Stainless. See CONTRIBUTING.md for details. + +from __future__ import annotations + +import httpx + +from ..types import find_text_params, find_files_params, find_symbols_params +from .._types import NOT_GIVEN, Body, Query, Headers, NotGiven +from .._utils import maybe_transform, async_maybe_transform +from .._compat import cached_property +from .._resource import SyncAPIResource, AsyncAPIResource +from .._response import ( + to_raw_response_wrapper, + to_streamed_response_wrapper, + async_to_raw_response_wrapper, + async_to_streamed_response_wrapper, +) +from .._base_client import make_request_options +from ..types.find_text_response import FindTextResponse +from ..types.find_files_response import FindFilesResponse +from ..types.find_symbols_response import FindSymbolsResponse + +__all__ = ["FindResource", "AsyncFindResource"] + + +class FindResource(SyncAPIResource): + @cached_property + def with_raw_response(self) -> FindResourceWithRawResponse: + """ + This property can be used as a prefix for any HTTP method call to return + the raw response object instead of the parsed content. + + For more information, see https://www.github.com/sst/opencode-sdk-python#accessing-raw-response-data-eg-headers + """ + return FindResourceWithRawResponse(self) + + @cached_property + def with_streaming_response(self) -> FindResourceWithStreamingResponse: + """ + An alternative to `.with_raw_response` that doesn't eagerly read the response body. + + For more information, see https://www.github.com/sst/opencode-sdk-python#with_streaming_response + """ + return FindResourceWithStreamingResponse(self) + + def files( + self, + *, + query: str, + directory: str | NotGiven = NOT_GIVEN, + # Use the following arguments if you need to pass additional parameters to the API that aren't available via kwargs. + # The extra values given here take precedence over values defined on the client or passed to this method. + extra_headers: Headers | None = None, + extra_query: Query | None = None, + extra_body: Body | None = None, + timeout: float | httpx.Timeout | None | NotGiven = NOT_GIVEN, + ) -> FindFilesResponse: + """ + Find files + + Args: + extra_headers: Send extra headers + + extra_query: Add additional query parameters to the request + + extra_body: Add additional JSON properties to the request + + timeout: Override the client-level default timeout for this request, in seconds + """ + return self._get( + "/find/file", + options=make_request_options( + extra_headers=extra_headers, + extra_query=extra_query, + extra_body=extra_body, + timeout=timeout, + query=maybe_transform( + { + "query": query, + "directory": directory, + }, + find_files_params.FindFilesParams, + ), + ), + cast_to=FindFilesResponse, + ) + + def symbols( + self, + *, + query: str, + directory: str | NotGiven = NOT_GIVEN, + # Use the following arguments if you need to pass additional parameters to the API that aren't available via kwargs. + # The extra values given here take precedence over values defined on the client or passed to this method. + extra_headers: Headers | None = None, + extra_query: Query | None = None, + extra_body: Body | None = None, + timeout: float | httpx.Timeout | None | NotGiven = NOT_GIVEN, + ) -> FindSymbolsResponse: + """ + Find workspace symbols + + Args: + extra_headers: Send extra headers + + extra_query: Add additional query parameters to the request + + extra_body: Add additional JSON properties to the request + + timeout: Override the client-level default timeout for this request, in seconds + """ + return self._get( + "/find/symbol", + options=make_request_options( + extra_headers=extra_headers, + extra_query=extra_query, + extra_body=extra_body, + timeout=timeout, + query=maybe_transform( + { + "query": query, + "directory": directory, + }, + find_symbols_params.FindSymbolsParams, + ), + ), + cast_to=FindSymbolsResponse, + ) + + def text( + self, + *, + pattern: str, + directory: str | NotGiven = NOT_GIVEN, + # Use the following arguments if you need to pass additional parameters to the API that aren't available via kwargs. + # The extra values given here take precedence over values defined on the client or passed to this method. + extra_headers: Headers | None = None, + extra_query: Query | None = None, + extra_body: Body | None = None, + timeout: float | httpx.Timeout | None | NotGiven = NOT_GIVEN, + ) -> FindTextResponse: + """ + Find text in files + + Args: + extra_headers: Send extra headers + + extra_query: Add additional query parameters to the request + + extra_body: Add additional JSON properties to the request + + timeout: Override the client-level default timeout for this request, in seconds + """ + return self._get( + "/find", + options=make_request_options( + extra_headers=extra_headers, + extra_query=extra_query, + extra_body=extra_body, + timeout=timeout, + query=maybe_transform( + { + "pattern": pattern, + "directory": directory, + }, + find_text_params.FindTextParams, + ), + ), + cast_to=FindTextResponse, + ) + + +class AsyncFindResource(AsyncAPIResource): + @cached_property + def with_raw_response(self) -> AsyncFindResourceWithRawResponse: + """ + This property can be used as a prefix for any HTTP method call to return + the raw response object instead of the parsed content. + + For more information, see https://www.github.com/sst/opencode-sdk-python#accessing-raw-response-data-eg-headers + """ + return AsyncFindResourceWithRawResponse(self) + + @cached_property + def with_streaming_response(self) -> AsyncFindResourceWithStreamingResponse: + """ + An alternative to `.with_raw_response` that doesn't eagerly read the response body. + + For more information, see https://www.github.com/sst/opencode-sdk-python#with_streaming_response + """ + return AsyncFindResourceWithStreamingResponse(self) + + async def files( + self, + *, + query: str, + directory: str | NotGiven = NOT_GIVEN, + # Use the following arguments if you need to pass additional parameters to the API that aren't available via kwargs. + # The extra values given here take precedence over values defined on the client or passed to this method. + extra_headers: Headers | None = None, + extra_query: Query | None = None, + extra_body: Body | None = None, + timeout: float | httpx.Timeout | None | NotGiven = NOT_GIVEN, + ) -> FindFilesResponse: + """ + Find files + + Args: + extra_headers: Send extra headers + + extra_query: Add additional query parameters to the request + + extra_body: Add additional JSON properties to the request + + timeout: Override the client-level default timeout for this request, in seconds + """ + return await self._get( + "/find/file", + options=make_request_options( + extra_headers=extra_headers, + extra_query=extra_query, + extra_body=extra_body, + timeout=timeout, + query=await async_maybe_transform( + { + "query": query, + "directory": directory, + }, + find_files_params.FindFilesParams, + ), + ), + cast_to=FindFilesResponse, + ) + + async def symbols( + self, + *, + query: str, + directory: str | NotGiven = NOT_GIVEN, + # Use the following arguments if you need to pass additional parameters to the API that aren't available via kwargs. + # The extra values given here take precedence over values defined on the client or passed to this method. + extra_headers: Headers | None = None, + extra_query: Query | None = None, + extra_body: Body | None = None, + timeout: float | httpx.Timeout | None | NotGiven = NOT_GIVEN, + ) -> FindSymbolsResponse: + """ + Find workspace symbols + + Args: + extra_headers: Send extra headers + + extra_query: Add additional query parameters to the request + + extra_body: Add additional JSON properties to the request + + timeout: Override the client-level default timeout for this request, in seconds + """ + return await self._get( + "/find/symbol", + options=make_request_options( + extra_headers=extra_headers, + extra_query=extra_query, + extra_body=extra_body, + timeout=timeout, + query=await async_maybe_transform( + { + "query": query, + "directory": directory, + }, + find_symbols_params.FindSymbolsParams, + ), + ), + cast_to=FindSymbolsResponse, + ) + + async def text( + self, + *, + pattern: str, + directory: str | NotGiven = NOT_GIVEN, + # Use the following arguments if you need to pass additional parameters to the API that aren't available via kwargs. + # The extra values given here take precedence over values defined on the client or passed to this method. + extra_headers: Headers | None = None, + extra_query: Query | None = None, + extra_body: Body | None = None, + timeout: float | httpx.Timeout | None | NotGiven = NOT_GIVEN, + ) -> FindTextResponse: + """ + Find text in files + + Args: + extra_headers: Send extra headers + + extra_query: Add additional query parameters to the request + + extra_body: Add additional JSON properties to the request + + timeout: Override the client-level default timeout for this request, in seconds + """ + return await self._get( + "/find", + options=make_request_options( + extra_headers=extra_headers, + extra_query=extra_query, + extra_body=extra_body, + timeout=timeout, + query=await async_maybe_transform( + { + "pattern": pattern, + "directory": directory, + }, + find_text_params.FindTextParams, + ), + ), + cast_to=FindTextResponse, + ) + + +class FindResourceWithRawResponse: + def __init__(self, find: FindResource) -> None: + self._find = find + + self.files = to_raw_response_wrapper( + find.files, + ) + self.symbols = to_raw_response_wrapper( + find.symbols, + ) + self.text = to_raw_response_wrapper( + find.text, + ) + + +class AsyncFindResourceWithRawResponse: + def __init__(self, find: AsyncFindResource) -> None: + self._find = find + + self.files = async_to_raw_response_wrapper( + find.files, + ) + self.symbols = async_to_raw_response_wrapper( + find.symbols, + ) + self.text = async_to_raw_response_wrapper( + find.text, + ) + + +class FindResourceWithStreamingResponse: + def __init__(self, find: FindResource) -> None: + self._find = find + + self.files = to_streamed_response_wrapper( + find.files, + ) + self.symbols = to_streamed_response_wrapper( + find.symbols, + ) + self.text = to_streamed_response_wrapper( + find.text, + ) + + +class AsyncFindResourceWithStreamingResponse: + def __init__(self, find: AsyncFindResource) -> None: + self._find = find + + self.files = async_to_streamed_response_wrapper( + find.files, + ) + self.symbols = async_to_streamed_response_wrapper( + find.symbols, + ) + self.text = async_to_streamed_response_wrapper( + find.text, + ) diff --git a/src/opencode_ai/resources/path.py b/src/opencode_ai/resources/path.py new file mode 100644 index 0000000..7231a7e --- /dev/null +++ b/src/opencode_ai/resources/path.py @@ -0,0 +1,169 @@ +# File generated from our OpenAPI spec by Stainless. See CONTRIBUTING.md for details. + +from __future__ import annotations + +import httpx + +from ..types import path_get_params +from .._types import NOT_GIVEN, Body, Query, Headers, NotGiven +from .._utils import maybe_transform, async_maybe_transform +from .._compat import cached_property +from .._resource import SyncAPIResource, AsyncAPIResource +from .._response import ( + to_raw_response_wrapper, + to_streamed_response_wrapper, + async_to_raw_response_wrapper, + async_to_streamed_response_wrapper, +) +from ..types.path import Path +from .._base_client import make_request_options + +__all__ = ["PathResource", "AsyncPathResource"] + + +class PathResource(SyncAPIResource): + @cached_property + def with_raw_response(self) -> PathResourceWithRawResponse: + """ + This property can be used as a prefix for any HTTP method call to return + the raw response object instead of the parsed content. + + For more information, see https://www.github.com/sst/opencode-sdk-python#accessing-raw-response-data-eg-headers + """ + return PathResourceWithRawResponse(self) + + @cached_property + def with_streaming_response(self) -> PathResourceWithStreamingResponse: + """ + An alternative to `.with_raw_response` that doesn't eagerly read the response body. + + For more information, see https://www.github.com/sst/opencode-sdk-python#with_streaming_response + """ + return PathResourceWithStreamingResponse(self) + + def get( + self, + *, + directory: str | NotGiven = NOT_GIVEN, + # Use the following arguments if you need to pass additional parameters to the API that aren't available via kwargs. + # The extra values given here take precedence over values defined on the client or passed to this method. + extra_headers: Headers | None = None, + extra_query: Query | None = None, + extra_body: Body | None = None, + timeout: float | httpx.Timeout | None | NotGiven = NOT_GIVEN, + ) -> Path: + """ + Get the current path + + Args: + extra_headers: Send extra headers + + extra_query: Add additional query parameters to the request + + extra_body: Add additional JSON properties to the request + + timeout: Override the client-level default timeout for this request, in seconds + """ + return self._get( + "/path", + options=make_request_options( + extra_headers=extra_headers, + extra_query=extra_query, + extra_body=extra_body, + timeout=timeout, + query=maybe_transform({"directory": directory}, path_get_params.PathGetParams), + ), + cast_to=Path, + ) + + +class AsyncPathResource(AsyncAPIResource): + @cached_property + def with_raw_response(self) -> AsyncPathResourceWithRawResponse: + """ + This property can be used as a prefix for any HTTP method call to return + the raw response object instead of the parsed content. + + For more information, see https://www.github.com/sst/opencode-sdk-python#accessing-raw-response-data-eg-headers + """ + return AsyncPathResourceWithRawResponse(self) + + @cached_property + def with_streaming_response(self) -> AsyncPathResourceWithStreamingResponse: + """ + An alternative to `.with_raw_response` that doesn't eagerly read the response body. + + For more information, see https://www.github.com/sst/opencode-sdk-python#with_streaming_response + """ + return AsyncPathResourceWithStreamingResponse(self) + + async def get( + self, + *, + directory: str | NotGiven = NOT_GIVEN, + # Use the following arguments if you need to pass additional parameters to the API that aren't available via kwargs. + # The extra values given here take precedence over values defined on the client or passed to this method. + extra_headers: Headers | None = None, + extra_query: Query | None = None, + extra_body: Body | None = None, + timeout: float | httpx.Timeout | None | NotGiven = NOT_GIVEN, + ) -> Path: + """ + Get the current path + + Args: + extra_headers: Send extra headers + + extra_query: Add additional query parameters to the request + + extra_body: Add additional JSON properties to the request + + timeout: Override the client-level default timeout for this request, in seconds + """ + return await self._get( + "/path", + options=make_request_options( + extra_headers=extra_headers, + extra_query=extra_query, + extra_body=extra_body, + timeout=timeout, + query=await async_maybe_transform({"directory": directory}, path_get_params.PathGetParams), + ), + cast_to=Path, + ) + + +class PathResourceWithRawResponse: + def __init__(self, path: PathResource) -> None: + self._path = path + + self.get = to_raw_response_wrapper( + path.get, + ) + + +class AsyncPathResourceWithRawResponse: + def __init__(self, path: AsyncPathResource) -> None: + self._path = path + + self.get = async_to_raw_response_wrapper( + path.get, + ) + + +class PathResourceWithStreamingResponse: + def __init__(self, path: PathResource) -> None: + self._path = path + + self.get = to_streamed_response_wrapper( + path.get, + ) + + +class AsyncPathResourceWithStreamingResponse: + def __init__(self, path: AsyncPathResource) -> None: + self._path = path + + self.get = async_to_streamed_response_wrapper( + path.get, + ) diff --git a/src/opencode_ai/resources/project.py b/src/opencode_ai/resources/project.py new file mode 100644 index 0000000..72b214d --- /dev/null +++ b/src/opencode_ai/resources/project.py @@ -0,0 +1,254 @@ +# File generated from our OpenAPI spec by Stainless. See CONTRIBUTING.md for details. + +from __future__ import annotations + +import httpx + +from ..types import project_list_params, project_current_params +from .._types import NOT_GIVEN, Body, Query, Headers, NotGiven +from .._utils import maybe_transform, async_maybe_transform +from .._compat import cached_property +from .._resource import SyncAPIResource, AsyncAPIResource +from .._response import ( + to_raw_response_wrapper, + to_streamed_response_wrapper, + async_to_raw_response_wrapper, + async_to_streamed_response_wrapper, +) +from .._base_client import make_request_options +from ..types.project import Project +from ..types.project_list_response import ProjectListResponse + +__all__ = ["ProjectResource", "AsyncProjectResource"] + + +class ProjectResource(SyncAPIResource): + @cached_property + def with_raw_response(self) -> ProjectResourceWithRawResponse: + """ + This property can be used as a prefix for any HTTP method call to return + the raw response object instead of the parsed content. + + For more information, see https://www.github.com/sst/opencode-sdk-python#accessing-raw-response-data-eg-headers + """ + return ProjectResourceWithRawResponse(self) + + @cached_property + def with_streaming_response(self) -> ProjectResourceWithStreamingResponse: + """ + An alternative to `.with_raw_response` that doesn't eagerly read the response body. + + For more information, see https://www.github.com/sst/opencode-sdk-python#with_streaming_response + """ + return ProjectResourceWithStreamingResponse(self) + + def list( + self, + *, + directory: str | NotGiven = NOT_GIVEN, + # Use the following arguments if you need to pass additional parameters to the API that aren't available via kwargs. + # The extra values given here take precedence over values defined on the client or passed to this method. + extra_headers: Headers | None = None, + extra_query: Query | None = None, + extra_body: Body | None = None, + timeout: float | httpx.Timeout | None | NotGiven = NOT_GIVEN, + ) -> ProjectListResponse: + """ + List all projects + + Args: + extra_headers: Send extra headers + + extra_query: Add additional query parameters to the request + + extra_body: Add additional JSON properties to the request + + timeout: Override the client-level default timeout for this request, in seconds + """ + return self._get( + "/project", + options=make_request_options( + extra_headers=extra_headers, + extra_query=extra_query, + extra_body=extra_body, + timeout=timeout, + query=maybe_transform({"directory": directory}, project_list_params.ProjectListParams), + ), + cast_to=ProjectListResponse, + ) + + def current( + self, + *, + directory: str | NotGiven = NOT_GIVEN, + # Use the following arguments if you need to pass additional parameters to the API that aren't available via kwargs. + # The extra values given here take precedence over values defined on the client or passed to this method. + extra_headers: Headers | None = None, + extra_query: Query | None = None, + extra_body: Body | None = None, + timeout: float | httpx.Timeout | None | NotGiven = NOT_GIVEN, + ) -> Project: + """ + Get the current project + + Args: + extra_headers: Send extra headers + + extra_query: Add additional query parameters to the request + + extra_body: Add additional JSON properties to the request + + timeout: Override the client-level default timeout for this request, in seconds + """ + return self._get( + "/project/current", + options=make_request_options( + extra_headers=extra_headers, + extra_query=extra_query, + extra_body=extra_body, + timeout=timeout, + query=maybe_transform({"directory": directory}, project_current_params.ProjectCurrentParams), + ), + cast_to=Project, + ) + + +class AsyncProjectResource(AsyncAPIResource): + @cached_property + def with_raw_response(self) -> AsyncProjectResourceWithRawResponse: + """ + This property can be used as a prefix for any HTTP method call to return + the raw response object instead of the parsed content. + + For more information, see https://www.github.com/sst/opencode-sdk-python#accessing-raw-response-data-eg-headers + """ + return AsyncProjectResourceWithRawResponse(self) + + @cached_property + def with_streaming_response(self) -> AsyncProjectResourceWithStreamingResponse: + """ + An alternative to `.with_raw_response` that doesn't eagerly read the response body. + + For more information, see https://www.github.com/sst/opencode-sdk-python#with_streaming_response + """ + return AsyncProjectResourceWithStreamingResponse(self) + + async def list( + self, + *, + directory: str | NotGiven = NOT_GIVEN, + # Use the following arguments if you need to pass additional parameters to the API that aren't available via kwargs. + # The extra values given here take precedence over values defined on the client or passed to this method. + extra_headers: Headers | None = None, + extra_query: Query | None = None, + extra_body: Body | None = None, + timeout: float | httpx.Timeout | None | NotGiven = NOT_GIVEN, + ) -> ProjectListResponse: + """ + List all projects + + Args: + extra_headers: Send extra headers + + extra_query: Add additional query parameters to the request + + extra_body: Add additional JSON properties to the request + + timeout: Override the client-level default timeout for this request, in seconds + """ + return await self._get( + "/project", + options=make_request_options( + extra_headers=extra_headers, + extra_query=extra_query, + extra_body=extra_body, + timeout=timeout, + query=await async_maybe_transform({"directory": directory}, project_list_params.ProjectListParams), + ), + cast_to=ProjectListResponse, + ) + + async def current( + self, + *, + directory: str | NotGiven = NOT_GIVEN, + # Use the following arguments if you need to pass additional parameters to the API that aren't available via kwargs. + # The extra values given here take precedence over values defined on the client or passed to this method. + extra_headers: Headers | None = None, + extra_query: Query | None = None, + extra_body: Body | None = None, + timeout: float | httpx.Timeout | None | NotGiven = NOT_GIVEN, + ) -> Project: + """ + Get the current project + + Args: + extra_headers: Send extra headers + + extra_query: Add additional query parameters to the request + + extra_body: Add additional JSON properties to the request + + timeout: Override the client-level default timeout for this request, in seconds + """ + return await self._get( + "/project/current", + options=make_request_options( + extra_headers=extra_headers, + extra_query=extra_query, + extra_body=extra_body, + timeout=timeout, + query=await async_maybe_transform( + {"directory": directory}, project_current_params.ProjectCurrentParams + ), + ), + cast_to=Project, + ) + + +class ProjectResourceWithRawResponse: + def __init__(self, project: ProjectResource) -> None: + self._project = project + + self.list = to_raw_response_wrapper( + project.list, + ) + self.current = to_raw_response_wrapper( + project.current, + ) + + +class AsyncProjectResourceWithRawResponse: + def __init__(self, project: AsyncProjectResource) -> None: + self._project = project + + self.list = async_to_raw_response_wrapper( + project.list, + ) + self.current = async_to_raw_response_wrapper( + project.current, + ) + + +class ProjectResourceWithStreamingResponse: + def __init__(self, project: ProjectResource) -> None: + self._project = project + + self.list = to_streamed_response_wrapper( + project.list, + ) + self.current = to_streamed_response_wrapper( + project.current, + ) + + +class AsyncProjectResourceWithStreamingResponse: + def __init__(self, project: AsyncProjectResource) -> None: + self._project = project + + self.list = async_to_streamed_response_wrapper( + project.list, + ) + self.current = async_to_streamed_response_wrapper( + project.current, + ) diff --git a/src/opencode_ai/resources/session/__init__.py b/src/opencode_ai/resources/session/__init__.py new file mode 100644 index 0000000..60a023e --- /dev/null +++ b/src/opencode_ai/resources/session/__init__.py @@ -0,0 +1,33 @@ +# File generated from our OpenAPI spec by Stainless. See CONTRIBUTING.md for details. + +from .session import ( + SessionResource, + AsyncSessionResource, + SessionResourceWithRawResponse, + AsyncSessionResourceWithRawResponse, + SessionResourceWithStreamingResponse, + AsyncSessionResourceWithStreamingResponse, +) +from .permissions import ( + PermissionsResource, + AsyncPermissionsResource, + PermissionsResourceWithRawResponse, + AsyncPermissionsResourceWithRawResponse, + PermissionsResourceWithStreamingResponse, + AsyncPermissionsResourceWithStreamingResponse, +) + +__all__ = [ + "PermissionsResource", + "AsyncPermissionsResource", + "PermissionsResourceWithRawResponse", + "AsyncPermissionsResourceWithRawResponse", + "PermissionsResourceWithStreamingResponse", + "AsyncPermissionsResourceWithStreamingResponse", + "SessionResource", + "AsyncSessionResource", + "SessionResourceWithRawResponse", + "AsyncSessionResourceWithRawResponse", + "SessionResourceWithStreamingResponse", + "AsyncSessionResourceWithStreamingResponse", +] diff --git a/src/opencode_ai/resources/session/permissions.py b/src/opencode_ai/resources/session/permissions.py new file mode 100644 index 0000000..8b521bd --- /dev/null +++ b/src/opencode_ai/resources/session/permissions.py @@ -0,0 +1,189 @@ +# File generated from our OpenAPI spec by Stainless. See CONTRIBUTING.md for details. + +from __future__ import annotations + +from typing_extensions import Literal + +import httpx + +from ..._types import NOT_GIVEN, Body, Query, Headers, NotGiven +from ..._utils import maybe_transform, async_maybe_transform +from ..._compat import cached_property +from ..._resource import SyncAPIResource, AsyncAPIResource +from ..._response import ( + to_raw_response_wrapper, + to_streamed_response_wrapper, + async_to_raw_response_wrapper, + async_to_streamed_response_wrapper, +) +from ..._base_client import make_request_options +from ...types.session import permission_respond_params +from ...types.session.permission_respond_response import PermissionRespondResponse + +__all__ = ["PermissionsResource", "AsyncPermissionsResource"] + + +class PermissionsResource(SyncAPIResource): + @cached_property + def with_raw_response(self) -> PermissionsResourceWithRawResponse: + """ + This property can be used as a prefix for any HTTP method call to return + the raw response object instead of the parsed content. + + For more information, see https://www.github.com/sst/opencode-sdk-python#accessing-raw-response-data-eg-headers + """ + return PermissionsResourceWithRawResponse(self) + + @cached_property + def with_streaming_response(self) -> PermissionsResourceWithStreamingResponse: + """ + An alternative to `.with_raw_response` that doesn't eagerly read the response body. + + For more information, see https://www.github.com/sst/opencode-sdk-python#with_streaming_response + """ + return PermissionsResourceWithStreamingResponse(self) + + def respond( + self, + permission_id: str, + *, + id: str, + response: Literal["once", "always", "reject"], + directory: str | NotGiven = NOT_GIVEN, + # Use the following arguments if you need to pass additional parameters to the API that aren't available via kwargs. + # The extra values given here take precedence over values defined on the client or passed to this method. + extra_headers: Headers | None = None, + extra_query: Query | None = None, + extra_body: Body | None = None, + timeout: float | httpx.Timeout | None | NotGiven = NOT_GIVEN, + ) -> PermissionRespondResponse: + """ + Respond to a permission request + + Args: + extra_headers: Send extra headers + + extra_query: Add additional query parameters to the request + + extra_body: Add additional JSON properties to the request + + timeout: Override the client-level default timeout for this request, in seconds + """ + if not id: + raise ValueError(f"Expected a non-empty value for `id` but received {id!r}") + if not permission_id: + raise ValueError(f"Expected a non-empty value for `permission_id` but received {permission_id!r}") + return self._post( + f"/session/{id}/permissions/{permission_id}", + body=maybe_transform({"response": response}, permission_respond_params.PermissionRespondParams), + options=make_request_options( + extra_headers=extra_headers, + extra_query=extra_query, + extra_body=extra_body, + timeout=timeout, + query=maybe_transform({"directory": directory}, permission_respond_params.PermissionRespondParams), + ), + cast_to=PermissionRespondResponse, + ) + + +class AsyncPermissionsResource(AsyncAPIResource): + @cached_property + def with_raw_response(self) -> AsyncPermissionsResourceWithRawResponse: + """ + This property can be used as a prefix for any HTTP method call to return + the raw response object instead of the parsed content. + + For more information, see https://www.github.com/sst/opencode-sdk-python#accessing-raw-response-data-eg-headers + """ + return AsyncPermissionsResourceWithRawResponse(self) + + @cached_property + def with_streaming_response(self) -> AsyncPermissionsResourceWithStreamingResponse: + """ + An alternative to `.with_raw_response` that doesn't eagerly read the response body. + + For more information, see https://www.github.com/sst/opencode-sdk-python#with_streaming_response + """ + return AsyncPermissionsResourceWithStreamingResponse(self) + + async def respond( + self, + permission_id: str, + *, + id: str, + response: Literal["once", "always", "reject"], + directory: str | NotGiven = NOT_GIVEN, + # Use the following arguments if you need to pass additional parameters to the API that aren't available via kwargs. + # The extra values given here take precedence over values defined on the client or passed to this method. + extra_headers: Headers | None = None, + extra_query: Query | None = None, + extra_body: Body | None = None, + timeout: float | httpx.Timeout | None | NotGiven = NOT_GIVEN, + ) -> PermissionRespondResponse: + """ + Respond to a permission request + + Args: + extra_headers: Send extra headers + + extra_query: Add additional query parameters to the request + + extra_body: Add additional JSON properties to the request + + timeout: Override the client-level default timeout for this request, in seconds + """ + if not id: + raise ValueError(f"Expected a non-empty value for `id` but received {id!r}") + if not permission_id: + raise ValueError(f"Expected a non-empty value for `permission_id` but received {permission_id!r}") + return await self._post( + f"/session/{id}/permissions/{permission_id}", + body=await async_maybe_transform({"response": response}, permission_respond_params.PermissionRespondParams), + options=make_request_options( + extra_headers=extra_headers, + extra_query=extra_query, + extra_body=extra_body, + timeout=timeout, + query=await async_maybe_transform( + {"directory": directory}, permission_respond_params.PermissionRespondParams + ), + ), + cast_to=PermissionRespondResponse, + ) + + +class PermissionsResourceWithRawResponse: + def __init__(self, permissions: PermissionsResource) -> None: + self._permissions = permissions + + self.respond = to_raw_response_wrapper( + permissions.respond, + ) + + +class AsyncPermissionsResourceWithRawResponse: + def __init__(self, permissions: AsyncPermissionsResource) -> None: + self._permissions = permissions + + self.respond = async_to_raw_response_wrapper( + permissions.respond, + ) + + +class PermissionsResourceWithStreamingResponse: + def __init__(self, permissions: PermissionsResource) -> None: + self._permissions = permissions + + self.respond = to_streamed_response_wrapper( + permissions.respond, + ) + + +class AsyncPermissionsResourceWithStreamingResponse: + def __init__(self, permissions: AsyncPermissionsResource) -> None: + self._permissions = permissions + + self.respond = async_to_streamed_response_wrapper( + permissions.respond, + ) diff --git a/src/opencode_ai/resources/session/session.py b/src/opencode_ai/resources/session/session.py new file mode 100644 index 0000000..8e29247 --- /dev/null +++ b/src/opencode_ai/resources/session/session.py @@ -0,0 +1,1937 @@ +# File generated from our OpenAPI spec by Stainless. See CONTRIBUTING.md for details. + +from __future__ import annotations + +from typing import Dict, Iterable + +import httpx + +from ...types import ( + session_get_params, + session_init_params, + session_list_params, + session_abort_params, + session_share_params, + session_shell_params, + session_create_params, + session_delete_params, + session_prompt_params, + session_revert_params, + session_update_params, + session_command_params, + session_message_params, + session_unshare_params, + session_children_params, + session_messages_params, + session_unrevert_params, + session_summarize_params, +) +from ..._types import NOT_GIVEN, Body, Query, Headers, NotGiven +from ..._utils import maybe_transform, async_maybe_transform +from ..._compat import cached_property +from ..._resource import SyncAPIResource, AsyncAPIResource +from ..._response import ( + to_raw_response_wrapper, + to_streamed_response_wrapper, + async_to_raw_response_wrapper, + async_to_streamed_response_wrapper, +) +from .permissions import ( + PermissionsResource, + AsyncPermissionsResource, + PermissionsResourceWithRawResponse, + AsyncPermissionsResourceWithRawResponse, + PermissionsResourceWithStreamingResponse, + AsyncPermissionsResourceWithStreamingResponse, +) +from ..._base_client import make_request_options +from ...types.session.session import Session +from ...types.assistant_message import AssistantMessage +from ...types.session_init_response import SessionInitResponse +from ...types.session_list_response import SessionListResponse +from ...types.session_abort_response import SessionAbortResponse +from ...types.session_delete_response import SessionDeleteResponse +from ...types.session_prompt_response import SessionPromptResponse +from ...types.session_command_response import SessionCommandResponse +from ...types.session_message_response import SessionMessageResponse +from ...types.session_children_response import SessionChildrenResponse +from ...types.session_messages_response import SessionMessagesResponse +from ...types.session_summarize_response import SessionSummarizeResponse + +__all__ = ["SessionResource", "AsyncSessionResource"] + + +class SessionResource(SyncAPIResource): + @cached_property + def permissions(self) -> PermissionsResource: + return PermissionsResource(self._client) + + @cached_property + def with_raw_response(self) -> SessionResourceWithRawResponse: + """ + This property can be used as a prefix for any HTTP method call to return + the raw response object instead of the parsed content. + + For more information, see https://www.github.com/sst/opencode-sdk-python#accessing-raw-response-data-eg-headers + """ + return SessionResourceWithRawResponse(self) + + @cached_property + def with_streaming_response(self) -> SessionResourceWithStreamingResponse: + """ + An alternative to `.with_raw_response` that doesn't eagerly read the response body. + + For more information, see https://www.github.com/sst/opencode-sdk-python#with_streaming_response + """ + return SessionResourceWithStreamingResponse(self) + + def create( + self, + *, + directory: str | NotGiven = NOT_GIVEN, + parent_id: str | NotGiven = NOT_GIVEN, + title: str | NotGiven = NOT_GIVEN, + # Use the following arguments if you need to pass additional parameters to the API that aren't available via kwargs. + # The extra values given here take precedence over values defined on the client or passed to this method. + extra_headers: Headers | None = None, + extra_query: Query | None = None, + extra_body: Body | None = None, + timeout: float | httpx.Timeout | None | NotGiven = NOT_GIVEN, + ) -> Session: + """ + Create a new session + + Args: + extra_headers: Send extra headers + + extra_query: Add additional query parameters to the request + + extra_body: Add additional JSON properties to the request + + timeout: Override the client-level default timeout for this request, in seconds + """ + return self._post( + "/session", + body=maybe_transform( + { + "parent_id": parent_id, + "title": title, + }, + session_create_params.SessionCreateParams, + ), + options=make_request_options( + extra_headers=extra_headers, + extra_query=extra_query, + extra_body=extra_body, + timeout=timeout, + query=maybe_transform({"directory": directory}, session_create_params.SessionCreateParams), + ), + cast_to=Session, + ) + + def update( + self, + id: str, + *, + directory: str | NotGiven = NOT_GIVEN, + title: str | NotGiven = NOT_GIVEN, + # Use the following arguments if you need to pass additional parameters to the API that aren't available via kwargs. + # The extra values given here take precedence over values defined on the client or passed to this method. + extra_headers: Headers | None = None, + extra_query: Query | None = None, + extra_body: Body | None = None, + timeout: float | httpx.Timeout | None | NotGiven = NOT_GIVEN, + ) -> Session: + """ + Update session properties + + Args: + extra_headers: Send extra headers + + extra_query: Add additional query parameters to the request + + extra_body: Add additional JSON properties to the request + + timeout: Override the client-level default timeout for this request, in seconds + """ + if not id: + raise ValueError(f"Expected a non-empty value for `id` but received {id!r}") + return self._patch( + f"/session/{id}", + body=maybe_transform({"title": title}, session_update_params.SessionUpdateParams), + options=make_request_options( + extra_headers=extra_headers, + extra_query=extra_query, + extra_body=extra_body, + timeout=timeout, + query=maybe_transform({"directory": directory}, session_update_params.SessionUpdateParams), + ), + cast_to=Session, + ) + + def list( + self, + *, + directory: str | NotGiven = NOT_GIVEN, + # Use the following arguments if you need to pass additional parameters to the API that aren't available via kwargs. + # The extra values given here take precedence over values defined on the client or passed to this method. + extra_headers: Headers | None = None, + extra_query: Query | None = None, + extra_body: Body | None = None, + timeout: float | httpx.Timeout | None | NotGiven = NOT_GIVEN, + ) -> SessionListResponse: + """ + List all sessions + + Args: + extra_headers: Send extra headers + + extra_query: Add additional query parameters to the request + + extra_body: Add additional JSON properties to the request + + timeout: Override the client-level default timeout for this request, in seconds + """ + return self._get( + "/session", + options=make_request_options( + extra_headers=extra_headers, + extra_query=extra_query, + extra_body=extra_body, + timeout=timeout, + query=maybe_transform({"directory": directory}, session_list_params.SessionListParams), + ), + cast_to=SessionListResponse, + ) + + def delete( + self, + id: str, + *, + directory: str | NotGiven = NOT_GIVEN, + # Use the following arguments if you need to pass additional parameters to the API that aren't available via kwargs. + # The extra values given here take precedence over values defined on the client or passed to this method. + extra_headers: Headers | None = None, + extra_query: Query | None = None, + extra_body: Body | None = None, + timeout: float | httpx.Timeout | None | NotGiven = NOT_GIVEN, + ) -> SessionDeleteResponse: + """ + Delete a session and all its data + + Args: + extra_headers: Send extra headers + + extra_query: Add additional query parameters to the request + + extra_body: Add additional JSON properties to the request + + timeout: Override the client-level default timeout for this request, in seconds + """ + if not id: + raise ValueError(f"Expected a non-empty value for `id` but received {id!r}") + return self._delete( + f"/session/{id}", + options=make_request_options( + extra_headers=extra_headers, + extra_query=extra_query, + extra_body=extra_body, + timeout=timeout, + query=maybe_transform({"directory": directory}, session_delete_params.SessionDeleteParams), + ), + cast_to=SessionDeleteResponse, + ) + + def abort( + self, + id: str, + *, + directory: str | NotGiven = NOT_GIVEN, + # Use the following arguments if you need to pass additional parameters to the API that aren't available via kwargs. + # The extra values given here take precedence over values defined on the client or passed to this method. + extra_headers: Headers | None = None, + extra_query: Query | None = None, + extra_body: Body | None = None, + timeout: float | httpx.Timeout | None | NotGiven = NOT_GIVEN, + ) -> SessionAbortResponse: + """ + Abort a session + + Args: + extra_headers: Send extra headers + + extra_query: Add additional query parameters to the request + + extra_body: Add additional JSON properties to the request + + timeout: Override the client-level default timeout for this request, in seconds + """ + if not id: + raise ValueError(f"Expected a non-empty value for `id` but received {id!r}") + return self._post( + f"/session/{id}/abort", + options=make_request_options( + extra_headers=extra_headers, + extra_query=extra_query, + extra_body=extra_body, + timeout=timeout, + query=maybe_transform({"directory": directory}, session_abort_params.SessionAbortParams), + ), + cast_to=SessionAbortResponse, + ) + + def children( + self, + id: str, + *, + directory: str | NotGiven = NOT_GIVEN, + # Use the following arguments if you need to pass additional parameters to the API that aren't available via kwargs. + # The extra values given here take precedence over values defined on the client or passed to this method. + extra_headers: Headers | None = None, + extra_query: Query | None = None, + extra_body: Body | None = None, + timeout: float | httpx.Timeout | None | NotGiven = NOT_GIVEN, + ) -> SessionChildrenResponse: + """ + Get a session's children + + Args: + extra_headers: Send extra headers + + extra_query: Add additional query parameters to the request + + extra_body: Add additional JSON properties to the request + + timeout: Override the client-level default timeout for this request, in seconds + """ + if not id: + raise ValueError(f"Expected a non-empty value for `id` but received {id!r}") + return self._get( + f"/session/{id}/children", + options=make_request_options( + extra_headers=extra_headers, + extra_query=extra_query, + extra_body=extra_body, + timeout=timeout, + query=maybe_transform({"directory": directory}, session_children_params.SessionChildrenParams), + ), + cast_to=SessionChildrenResponse, + ) + + def command( + self, + id: str, + *, + arguments: str, + command: str, + directory: str | NotGiven = NOT_GIVEN, + agent: str | NotGiven = NOT_GIVEN, + message_id: str | NotGiven = NOT_GIVEN, + model: str | NotGiven = NOT_GIVEN, + # Use the following arguments if you need to pass additional parameters to the API that aren't available via kwargs. + # The extra values given here take precedence over values defined on the client or passed to this method. + extra_headers: Headers | None = None, + extra_query: Query | None = None, + extra_body: Body | None = None, + timeout: float | httpx.Timeout | None | NotGiven = NOT_GIVEN, + ) -> SessionCommandResponse: + """ + Send a new command to a session + + Args: + id: Session ID + + extra_headers: Send extra headers + + extra_query: Add additional query parameters to the request + + extra_body: Add additional JSON properties to the request + + timeout: Override the client-level default timeout for this request, in seconds + """ + if not id: + raise ValueError(f"Expected a non-empty value for `id` but received {id!r}") + return self._post( + f"/session/{id}/command", + body=maybe_transform( + { + "arguments": arguments, + "command": command, + "agent": agent, + "message_id": message_id, + "model": model, + }, + session_command_params.SessionCommandParams, + ), + options=make_request_options( + extra_headers=extra_headers, + extra_query=extra_query, + extra_body=extra_body, + timeout=timeout, + query=maybe_transform({"directory": directory}, session_command_params.SessionCommandParams), + ), + cast_to=SessionCommandResponse, + ) + + def get( + self, + id: str, + *, + directory: str | NotGiven = NOT_GIVEN, + # Use the following arguments if you need to pass additional parameters to the API that aren't available via kwargs. + # The extra values given here take precedence over values defined on the client or passed to this method. + extra_headers: Headers | None = None, + extra_query: Query | None = None, + extra_body: Body | None = None, + timeout: float | httpx.Timeout | None | NotGiven = NOT_GIVEN, + ) -> Session: + """ + Get session + + Args: + extra_headers: Send extra headers + + extra_query: Add additional query parameters to the request + + extra_body: Add additional JSON properties to the request + + timeout: Override the client-level default timeout for this request, in seconds + """ + if not id: + raise ValueError(f"Expected a non-empty value for `id` but received {id!r}") + return self._get( + f"/session/{id}", + options=make_request_options( + extra_headers=extra_headers, + extra_query=extra_query, + extra_body=extra_body, + timeout=timeout, + query=maybe_transform({"directory": directory}, session_get_params.SessionGetParams), + ), + cast_to=Session, + ) + + def init( + self, + id: str, + *, + message_id: str, + model_id: str, + provider_id: str, + directory: str | NotGiven = NOT_GIVEN, + # Use the following arguments if you need to pass additional parameters to the API that aren't available via kwargs. + # The extra values given here take precedence over values defined on the client or passed to this method. + extra_headers: Headers | None = None, + extra_query: Query | None = None, + extra_body: Body | None = None, + timeout: float | httpx.Timeout | None | NotGiven = NOT_GIVEN, + ) -> SessionInitResponse: + """ + Analyze the app and create an AGENTS.md file + + Args: + id: Session ID + + extra_headers: Send extra headers + + extra_query: Add additional query parameters to the request + + extra_body: Add additional JSON properties to the request + + timeout: Override the client-level default timeout for this request, in seconds + """ + if not id: + raise ValueError(f"Expected a non-empty value for `id` but received {id!r}") + return self._post( + f"/session/{id}/init", + body=maybe_transform( + { + "message_id": message_id, + "model_id": model_id, + "provider_id": provider_id, + }, + session_init_params.SessionInitParams, + ), + options=make_request_options( + extra_headers=extra_headers, + extra_query=extra_query, + extra_body=extra_body, + timeout=timeout, + query=maybe_transform({"directory": directory}, session_init_params.SessionInitParams), + ), + cast_to=SessionInitResponse, + ) + + def message( + self, + message_id: str, + *, + id: str, + directory: str | NotGiven = NOT_GIVEN, + # Use the following arguments if you need to pass additional parameters to the API that aren't available via kwargs. + # The extra values given here take precedence over values defined on the client or passed to this method. + extra_headers: Headers | None = None, + extra_query: Query | None = None, + extra_body: Body | None = None, + timeout: float | httpx.Timeout | None | NotGiven = NOT_GIVEN, + ) -> SessionMessageResponse: + """ + Get a message from a session + + Args: + id: Session ID + + message_id: Message ID + + extra_headers: Send extra headers + + extra_query: Add additional query parameters to the request + + extra_body: Add additional JSON properties to the request + + timeout: Override the client-level default timeout for this request, in seconds + """ + if not id: + raise ValueError(f"Expected a non-empty value for `id` but received {id!r}") + if not message_id: + raise ValueError(f"Expected a non-empty value for `message_id` but received {message_id!r}") + return self._get( + f"/session/{id}/message/{message_id}", + options=make_request_options( + extra_headers=extra_headers, + extra_query=extra_query, + extra_body=extra_body, + timeout=timeout, + query=maybe_transform({"directory": directory}, session_message_params.SessionMessageParams), + ), + cast_to=SessionMessageResponse, + ) + + def messages( + self, + id: str, + *, + directory: str | NotGiven = NOT_GIVEN, + # Use the following arguments if you need to pass additional parameters to the API that aren't available via kwargs. + # The extra values given here take precedence over values defined on the client or passed to this method. + extra_headers: Headers | None = None, + extra_query: Query | None = None, + extra_body: Body | None = None, + timeout: float | httpx.Timeout | None | NotGiven = NOT_GIVEN, + ) -> SessionMessagesResponse: + """ + List messages for a session + + Args: + id: Session ID + + extra_headers: Send extra headers + + extra_query: Add additional query parameters to the request + + extra_body: Add additional JSON properties to the request + + timeout: Override the client-level default timeout for this request, in seconds + """ + if not id: + raise ValueError(f"Expected a non-empty value for `id` but received {id!r}") + return self._get( + f"/session/{id}/message", + options=make_request_options( + extra_headers=extra_headers, + extra_query=extra_query, + extra_body=extra_body, + timeout=timeout, + query=maybe_transform({"directory": directory}, session_messages_params.SessionMessagesParams), + ), + cast_to=SessionMessagesResponse, + ) + + def prompt( + self, + id: str, + *, + parts: Iterable[session_prompt_params.Part], + directory: str | NotGiven = NOT_GIVEN, + agent: str | NotGiven = NOT_GIVEN, + message_id: str | NotGiven = NOT_GIVEN, + model: session_prompt_params.Model | NotGiven = NOT_GIVEN, + system: str | NotGiven = NOT_GIVEN, + tools: Dict[str, bool] | NotGiven = NOT_GIVEN, + # Use the following arguments if you need to pass additional parameters to the API that aren't available via kwargs. + # The extra values given here take precedence over values defined on the client or passed to this method. + extra_headers: Headers | None = None, + extra_query: Query | None = None, + extra_body: Body | None = None, + timeout: float | httpx.Timeout | None | NotGiven = NOT_GIVEN, + ) -> SessionPromptResponse: + """ + Create and send a new message to a session + + Args: + id: Session ID + + extra_headers: Send extra headers + + extra_query: Add additional query parameters to the request + + extra_body: Add additional JSON properties to the request + + timeout: Override the client-level default timeout for this request, in seconds + """ + if not id: + raise ValueError(f"Expected a non-empty value for `id` but received {id!r}") + return self._post( + f"/session/{id}/message", + body=maybe_transform( + { + "parts": parts, + "agent": agent, + "message_id": message_id, + "model": model, + "system": system, + "tools": tools, + }, + session_prompt_params.SessionPromptParams, + ), + options=make_request_options( + extra_headers=extra_headers, + extra_query=extra_query, + extra_body=extra_body, + timeout=timeout, + query=maybe_transform({"directory": directory}, session_prompt_params.SessionPromptParams), + ), + cast_to=SessionPromptResponse, + ) + + def revert( + self, + id: str, + *, + message_id: str, + directory: str | NotGiven = NOT_GIVEN, + part_id: str | NotGiven = NOT_GIVEN, + # Use the following arguments if you need to pass additional parameters to the API that aren't available via kwargs. + # The extra values given here take precedence over values defined on the client or passed to this method. + extra_headers: Headers | None = None, + extra_query: Query | None = None, + extra_body: Body | None = None, + timeout: float | httpx.Timeout | None | NotGiven = NOT_GIVEN, + ) -> Session: + """ + Revert a message + + Args: + extra_headers: Send extra headers + + extra_query: Add additional query parameters to the request + + extra_body: Add additional JSON properties to the request + + timeout: Override the client-level default timeout for this request, in seconds + """ + if not id: + raise ValueError(f"Expected a non-empty value for `id` but received {id!r}") + return self._post( + f"/session/{id}/revert", + body=maybe_transform( + { + "message_id": message_id, + "part_id": part_id, + }, + session_revert_params.SessionRevertParams, + ), + options=make_request_options( + extra_headers=extra_headers, + extra_query=extra_query, + extra_body=extra_body, + timeout=timeout, + query=maybe_transform({"directory": directory}, session_revert_params.SessionRevertParams), + ), + cast_to=Session, + ) + + def share( + self, + id: str, + *, + directory: str | NotGiven = NOT_GIVEN, + # Use the following arguments if you need to pass additional parameters to the API that aren't available via kwargs. + # The extra values given here take precedence over values defined on the client or passed to this method. + extra_headers: Headers | None = None, + extra_query: Query | None = None, + extra_body: Body | None = None, + timeout: float | httpx.Timeout | None | NotGiven = NOT_GIVEN, + ) -> Session: + """ + Share a session + + Args: + extra_headers: Send extra headers + + extra_query: Add additional query parameters to the request + + extra_body: Add additional JSON properties to the request + + timeout: Override the client-level default timeout for this request, in seconds + """ + if not id: + raise ValueError(f"Expected a non-empty value for `id` but received {id!r}") + return self._post( + f"/session/{id}/share", + options=make_request_options( + extra_headers=extra_headers, + extra_query=extra_query, + extra_body=extra_body, + timeout=timeout, + query=maybe_transform({"directory": directory}, session_share_params.SessionShareParams), + ), + cast_to=Session, + ) + + def shell( + self, + id: str, + *, + agent: str, + command: str, + directory: str | NotGiven = NOT_GIVEN, + # Use the following arguments if you need to pass additional parameters to the API that aren't available via kwargs. + # The extra values given here take precedence over values defined on the client or passed to this method. + extra_headers: Headers | None = None, + extra_query: Query | None = None, + extra_body: Body | None = None, + timeout: float | httpx.Timeout | None | NotGiven = NOT_GIVEN, + ) -> AssistantMessage: + """ + Run a shell command + + Args: + id: Session ID + + extra_headers: Send extra headers + + extra_query: Add additional query parameters to the request + + extra_body: Add additional JSON properties to the request + + timeout: Override the client-level default timeout for this request, in seconds + """ + if not id: + raise ValueError(f"Expected a non-empty value for `id` but received {id!r}") + return self._post( + f"/session/{id}/shell", + body=maybe_transform( + { + "agent": agent, + "command": command, + }, + session_shell_params.SessionShellParams, + ), + options=make_request_options( + extra_headers=extra_headers, + extra_query=extra_query, + extra_body=extra_body, + timeout=timeout, + query=maybe_transform({"directory": directory}, session_shell_params.SessionShellParams), + ), + cast_to=AssistantMessage, + ) + + def summarize( + self, + id: str, + *, + model_id: str, + provider_id: str, + directory: str | NotGiven = NOT_GIVEN, + # Use the following arguments if you need to pass additional parameters to the API that aren't available via kwargs. + # The extra values given here take precedence over values defined on the client or passed to this method. + extra_headers: Headers | None = None, + extra_query: Query | None = None, + extra_body: Body | None = None, + timeout: float | httpx.Timeout | None | NotGiven = NOT_GIVEN, + ) -> SessionSummarizeResponse: + """ + Summarize the session + + Args: + id: Session ID + + extra_headers: Send extra headers + + extra_query: Add additional query parameters to the request + + extra_body: Add additional JSON properties to the request + + timeout: Override the client-level default timeout for this request, in seconds + """ + if not id: + raise ValueError(f"Expected a non-empty value for `id` but received {id!r}") + return self._post( + f"/session/{id}/summarize", + body=maybe_transform( + { + "model_id": model_id, + "provider_id": provider_id, + }, + session_summarize_params.SessionSummarizeParams, + ), + options=make_request_options( + extra_headers=extra_headers, + extra_query=extra_query, + extra_body=extra_body, + timeout=timeout, + query=maybe_transform({"directory": directory}, session_summarize_params.SessionSummarizeParams), + ), + cast_to=SessionSummarizeResponse, + ) + + def unrevert( + self, + id: str, + *, + directory: str | NotGiven = NOT_GIVEN, + # Use the following arguments if you need to pass additional parameters to the API that aren't available via kwargs. + # The extra values given here take precedence over values defined on the client or passed to this method. + extra_headers: Headers | None = None, + extra_query: Query | None = None, + extra_body: Body | None = None, + timeout: float | httpx.Timeout | None | NotGiven = NOT_GIVEN, + ) -> Session: + """ + Restore all reverted messages + + Args: + extra_headers: Send extra headers + + extra_query: Add additional query parameters to the request + + extra_body: Add additional JSON properties to the request + + timeout: Override the client-level default timeout for this request, in seconds + """ + if not id: + raise ValueError(f"Expected a non-empty value for `id` but received {id!r}") + return self._post( + f"/session/{id}/unrevert", + options=make_request_options( + extra_headers=extra_headers, + extra_query=extra_query, + extra_body=extra_body, + timeout=timeout, + query=maybe_transform({"directory": directory}, session_unrevert_params.SessionUnrevertParams), + ), + cast_to=Session, + ) + + def unshare( + self, + id: str, + *, + directory: str | NotGiven = NOT_GIVEN, + # Use the following arguments if you need to pass additional parameters to the API that aren't available via kwargs. + # The extra values given here take precedence over values defined on the client or passed to this method. + extra_headers: Headers | None = None, + extra_query: Query | None = None, + extra_body: Body | None = None, + timeout: float | httpx.Timeout | None | NotGiven = NOT_GIVEN, + ) -> Session: + """ + Unshare the session + + Args: + extra_headers: Send extra headers + + extra_query: Add additional query parameters to the request + + extra_body: Add additional JSON properties to the request + + timeout: Override the client-level default timeout for this request, in seconds + """ + if not id: + raise ValueError(f"Expected a non-empty value for `id` but received {id!r}") + return self._delete( + f"/session/{id}/share", + options=make_request_options( + extra_headers=extra_headers, + extra_query=extra_query, + extra_body=extra_body, + timeout=timeout, + query=maybe_transform({"directory": directory}, session_unshare_params.SessionUnshareParams), + ), + cast_to=Session, + ) + + +class AsyncSessionResource(AsyncAPIResource): + @cached_property + def permissions(self) -> AsyncPermissionsResource: + return AsyncPermissionsResource(self._client) + + @cached_property + def with_raw_response(self) -> AsyncSessionResourceWithRawResponse: + """ + This property can be used as a prefix for any HTTP method call to return + the raw response object instead of the parsed content. + + For more information, see https://www.github.com/sst/opencode-sdk-python#accessing-raw-response-data-eg-headers + """ + return AsyncSessionResourceWithRawResponse(self) + + @cached_property + def with_streaming_response(self) -> AsyncSessionResourceWithStreamingResponse: + """ + An alternative to `.with_raw_response` that doesn't eagerly read the response body. + + For more information, see https://www.github.com/sst/opencode-sdk-python#with_streaming_response + """ + return AsyncSessionResourceWithStreamingResponse(self) + + async def create( + self, + *, + directory: str | NotGiven = NOT_GIVEN, + parent_id: str | NotGiven = NOT_GIVEN, + title: str | NotGiven = NOT_GIVEN, + # Use the following arguments if you need to pass additional parameters to the API that aren't available via kwargs. + # The extra values given here take precedence over values defined on the client or passed to this method. + extra_headers: Headers | None = None, + extra_query: Query | None = None, + extra_body: Body | None = None, + timeout: float | httpx.Timeout | None | NotGiven = NOT_GIVEN, + ) -> Session: + """ + Create a new session + + Args: + extra_headers: Send extra headers + + extra_query: Add additional query parameters to the request + + extra_body: Add additional JSON properties to the request + + timeout: Override the client-level default timeout for this request, in seconds + """ + return await self._post( + "/session", + body=await async_maybe_transform( + { + "parent_id": parent_id, + "title": title, + }, + session_create_params.SessionCreateParams, + ), + options=make_request_options( + extra_headers=extra_headers, + extra_query=extra_query, + extra_body=extra_body, + timeout=timeout, + query=await async_maybe_transform({"directory": directory}, session_create_params.SessionCreateParams), + ), + cast_to=Session, + ) + + async def update( + self, + id: str, + *, + directory: str | NotGiven = NOT_GIVEN, + title: str | NotGiven = NOT_GIVEN, + # Use the following arguments if you need to pass additional parameters to the API that aren't available via kwargs. + # The extra values given here take precedence over values defined on the client or passed to this method. + extra_headers: Headers | None = None, + extra_query: Query | None = None, + extra_body: Body | None = None, + timeout: float | httpx.Timeout | None | NotGiven = NOT_GIVEN, + ) -> Session: + """ + Update session properties + + Args: + extra_headers: Send extra headers + + extra_query: Add additional query parameters to the request + + extra_body: Add additional JSON properties to the request + + timeout: Override the client-level default timeout for this request, in seconds + """ + if not id: + raise ValueError(f"Expected a non-empty value for `id` but received {id!r}") + return await self._patch( + f"/session/{id}", + body=await async_maybe_transform({"title": title}, session_update_params.SessionUpdateParams), + options=make_request_options( + extra_headers=extra_headers, + extra_query=extra_query, + extra_body=extra_body, + timeout=timeout, + query=await async_maybe_transform({"directory": directory}, session_update_params.SessionUpdateParams), + ), + cast_to=Session, + ) + + async def list( + self, + *, + directory: str | NotGiven = NOT_GIVEN, + # Use the following arguments if you need to pass additional parameters to the API that aren't available via kwargs. + # The extra values given here take precedence over values defined on the client or passed to this method. + extra_headers: Headers | None = None, + extra_query: Query | None = None, + extra_body: Body | None = None, + timeout: float | httpx.Timeout | None | NotGiven = NOT_GIVEN, + ) -> SessionListResponse: + """ + List all sessions + + Args: + extra_headers: Send extra headers + + extra_query: Add additional query parameters to the request + + extra_body: Add additional JSON properties to the request + + timeout: Override the client-level default timeout for this request, in seconds + """ + return await self._get( + "/session", + options=make_request_options( + extra_headers=extra_headers, + extra_query=extra_query, + extra_body=extra_body, + timeout=timeout, + query=await async_maybe_transform({"directory": directory}, session_list_params.SessionListParams), + ), + cast_to=SessionListResponse, + ) + + async def delete( + self, + id: str, + *, + directory: str | NotGiven = NOT_GIVEN, + # Use the following arguments if you need to pass additional parameters to the API that aren't available via kwargs. + # The extra values given here take precedence over values defined on the client or passed to this method. + extra_headers: Headers | None = None, + extra_query: Query | None = None, + extra_body: Body | None = None, + timeout: float | httpx.Timeout | None | NotGiven = NOT_GIVEN, + ) -> SessionDeleteResponse: + """ + Delete a session and all its data + + Args: + extra_headers: Send extra headers + + extra_query: Add additional query parameters to the request + + extra_body: Add additional JSON properties to the request + + timeout: Override the client-level default timeout for this request, in seconds + """ + if not id: + raise ValueError(f"Expected a non-empty value for `id` but received {id!r}") + return await self._delete( + f"/session/{id}", + options=make_request_options( + extra_headers=extra_headers, + extra_query=extra_query, + extra_body=extra_body, + timeout=timeout, + query=await async_maybe_transform({"directory": directory}, session_delete_params.SessionDeleteParams), + ), + cast_to=SessionDeleteResponse, + ) + + async def abort( + self, + id: str, + *, + directory: str | NotGiven = NOT_GIVEN, + # Use the following arguments if you need to pass additional parameters to the API that aren't available via kwargs. + # The extra values given here take precedence over values defined on the client or passed to this method. + extra_headers: Headers | None = None, + extra_query: Query | None = None, + extra_body: Body | None = None, + timeout: float | httpx.Timeout | None | NotGiven = NOT_GIVEN, + ) -> SessionAbortResponse: + """ + Abort a session + + Args: + extra_headers: Send extra headers + + extra_query: Add additional query parameters to the request + + extra_body: Add additional JSON properties to the request + + timeout: Override the client-level default timeout for this request, in seconds + """ + if not id: + raise ValueError(f"Expected a non-empty value for `id` but received {id!r}") + return await self._post( + f"/session/{id}/abort", + options=make_request_options( + extra_headers=extra_headers, + extra_query=extra_query, + extra_body=extra_body, + timeout=timeout, + query=await async_maybe_transform({"directory": directory}, session_abort_params.SessionAbortParams), + ), + cast_to=SessionAbortResponse, + ) + + async def children( + self, + id: str, + *, + directory: str | NotGiven = NOT_GIVEN, + # Use the following arguments if you need to pass additional parameters to the API that aren't available via kwargs. + # The extra values given here take precedence over values defined on the client or passed to this method. + extra_headers: Headers | None = None, + extra_query: Query | None = None, + extra_body: Body | None = None, + timeout: float | httpx.Timeout | None | NotGiven = NOT_GIVEN, + ) -> SessionChildrenResponse: + """ + Get a session's children + + Args: + extra_headers: Send extra headers + + extra_query: Add additional query parameters to the request + + extra_body: Add additional JSON properties to the request + + timeout: Override the client-level default timeout for this request, in seconds + """ + if not id: + raise ValueError(f"Expected a non-empty value for `id` but received {id!r}") + return await self._get( + f"/session/{id}/children", + options=make_request_options( + extra_headers=extra_headers, + extra_query=extra_query, + extra_body=extra_body, + timeout=timeout, + query=await async_maybe_transform( + {"directory": directory}, session_children_params.SessionChildrenParams + ), + ), + cast_to=SessionChildrenResponse, + ) + + async def command( + self, + id: str, + *, + arguments: str, + command: str, + directory: str | NotGiven = NOT_GIVEN, + agent: str | NotGiven = NOT_GIVEN, + message_id: str | NotGiven = NOT_GIVEN, + model: str | NotGiven = NOT_GIVEN, + # Use the following arguments if you need to pass additional parameters to the API that aren't available via kwargs. + # The extra values given here take precedence over values defined on the client or passed to this method. + extra_headers: Headers | None = None, + extra_query: Query | None = None, + extra_body: Body | None = None, + timeout: float | httpx.Timeout | None | NotGiven = NOT_GIVEN, + ) -> SessionCommandResponse: + """ + Send a new command to a session + + Args: + id: Session ID + + extra_headers: Send extra headers + + extra_query: Add additional query parameters to the request + + extra_body: Add additional JSON properties to the request + + timeout: Override the client-level default timeout for this request, in seconds + """ + if not id: + raise ValueError(f"Expected a non-empty value for `id` but received {id!r}") + return await self._post( + f"/session/{id}/command", + body=await async_maybe_transform( + { + "arguments": arguments, + "command": command, + "agent": agent, + "message_id": message_id, + "model": model, + }, + session_command_params.SessionCommandParams, + ), + options=make_request_options( + extra_headers=extra_headers, + extra_query=extra_query, + extra_body=extra_body, + timeout=timeout, + query=await async_maybe_transform( + {"directory": directory}, session_command_params.SessionCommandParams + ), + ), + cast_to=SessionCommandResponse, + ) + + async def get( + self, + id: str, + *, + directory: str | NotGiven = NOT_GIVEN, + # Use the following arguments if you need to pass additional parameters to the API that aren't available via kwargs. + # The extra values given here take precedence over values defined on the client or passed to this method. + extra_headers: Headers | None = None, + extra_query: Query | None = None, + extra_body: Body | None = None, + timeout: float | httpx.Timeout | None | NotGiven = NOT_GIVEN, + ) -> Session: + """ + Get session + + Args: + extra_headers: Send extra headers + + extra_query: Add additional query parameters to the request + + extra_body: Add additional JSON properties to the request + + timeout: Override the client-level default timeout for this request, in seconds + """ + if not id: + raise ValueError(f"Expected a non-empty value for `id` but received {id!r}") + return await self._get( + f"/session/{id}", + options=make_request_options( + extra_headers=extra_headers, + extra_query=extra_query, + extra_body=extra_body, + timeout=timeout, + query=await async_maybe_transform({"directory": directory}, session_get_params.SessionGetParams), + ), + cast_to=Session, + ) + + async def init( + self, + id: str, + *, + message_id: str, + model_id: str, + provider_id: str, + directory: str | NotGiven = NOT_GIVEN, + # Use the following arguments if you need to pass additional parameters to the API that aren't available via kwargs. + # The extra values given here take precedence over values defined on the client or passed to this method. + extra_headers: Headers | None = None, + extra_query: Query | None = None, + extra_body: Body | None = None, + timeout: float | httpx.Timeout | None | NotGiven = NOT_GIVEN, + ) -> SessionInitResponse: + """ + Analyze the app and create an AGENTS.md file + + Args: + id: Session ID + + extra_headers: Send extra headers + + extra_query: Add additional query parameters to the request + + extra_body: Add additional JSON properties to the request + + timeout: Override the client-level default timeout for this request, in seconds + """ + if not id: + raise ValueError(f"Expected a non-empty value for `id` but received {id!r}") + return await self._post( + f"/session/{id}/init", + body=await async_maybe_transform( + { + "message_id": message_id, + "model_id": model_id, + "provider_id": provider_id, + }, + session_init_params.SessionInitParams, + ), + options=make_request_options( + extra_headers=extra_headers, + extra_query=extra_query, + extra_body=extra_body, + timeout=timeout, + query=await async_maybe_transform({"directory": directory}, session_init_params.SessionInitParams), + ), + cast_to=SessionInitResponse, + ) + + async def message( + self, + message_id: str, + *, + id: str, + directory: str | NotGiven = NOT_GIVEN, + # Use the following arguments if you need to pass additional parameters to the API that aren't available via kwargs. + # The extra values given here take precedence over values defined on the client or passed to this method. + extra_headers: Headers | None = None, + extra_query: Query | None = None, + extra_body: Body | None = None, + timeout: float | httpx.Timeout | None | NotGiven = NOT_GIVEN, + ) -> SessionMessageResponse: + """ + Get a message from a session + + Args: + id: Session ID + + message_id: Message ID + + extra_headers: Send extra headers + + extra_query: Add additional query parameters to the request + + extra_body: Add additional JSON properties to the request + + timeout: Override the client-level default timeout for this request, in seconds + """ + if not id: + raise ValueError(f"Expected a non-empty value for `id` but received {id!r}") + if not message_id: + raise ValueError(f"Expected a non-empty value for `message_id` but received {message_id!r}") + return await self._get( + f"/session/{id}/message/{message_id}", + options=make_request_options( + extra_headers=extra_headers, + extra_query=extra_query, + extra_body=extra_body, + timeout=timeout, + query=await async_maybe_transform( + {"directory": directory}, session_message_params.SessionMessageParams + ), + ), + cast_to=SessionMessageResponse, + ) + + async def messages( + self, + id: str, + *, + directory: str | NotGiven = NOT_GIVEN, + # Use the following arguments if you need to pass additional parameters to the API that aren't available via kwargs. + # The extra values given here take precedence over values defined on the client or passed to this method. + extra_headers: Headers | None = None, + extra_query: Query | None = None, + extra_body: Body | None = None, + timeout: float | httpx.Timeout | None | NotGiven = NOT_GIVEN, + ) -> SessionMessagesResponse: + """ + List messages for a session + + Args: + id: Session ID + + extra_headers: Send extra headers + + extra_query: Add additional query parameters to the request + + extra_body: Add additional JSON properties to the request + + timeout: Override the client-level default timeout for this request, in seconds + """ + if not id: + raise ValueError(f"Expected a non-empty value for `id` but received {id!r}") + return await self._get( + f"/session/{id}/message", + options=make_request_options( + extra_headers=extra_headers, + extra_query=extra_query, + extra_body=extra_body, + timeout=timeout, + query=await async_maybe_transform( + {"directory": directory}, session_messages_params.SessionMessagesParams + ), + ), + cast_to=SessionMessagesResponse, + ) + + async def prompt( + self, + id: str, + *, + parts: Iterable[session_prompt_params.Part], + directory: str | NotGiven = NOT_GIVEN, + agent: str | NotGiven = NOT_GIVEN, + message_id: str | NotGiven = NOT_GIVEN, + model: session_prompt_params.Model | NotGiven = NOT_GIVEN, + system: str | NotGiven = NOT_GIVEN, + tools: Dict[str, bool] | NotGiven = NOT_GIVEN, + # Use the following arguments if you need to pass additional parameters to the API that aren't available via kwargs. + # The extra values given here take precedence over values defined on the client or passed to this method. + extra_headers: Headers | None = None, + extra_query: Query | None = None, + extra_body: Body | None = None, + timeout: float | httpx.Timeout | None | NotGiven = NOT_GIVEN, + ) -> SessionPromptResponse: + """ + Create and send a new message to a session + + Args: + id: Session ID + + extra_headers: Send extra headers + + extra_query: Add additional query parameters to the request + + extra_body: Add additional JSON properties to the request + + timeout: Override the client-level default timeout for this request, in seconds + """ + if not id: + raise ValueError(f"Expected a non-empty value for `id` but received {id!r}") + return await self._post( + f"/session/{id}/message", + body=await async_maybe_transform( + { + "parts": parts, + "agent": agent, + "message_id": message_id, + "model": model, + "system": system, + "tools": tools, + }, + session_prompt_params.SessionPromptParams, + ), + options=make_request_options( + extra_headers=extra_headers, + extra_query=extra_query, + extra_body=extra_body, + timeout=timeout, + query=await async_maybe_transform({"directory": directory}, session_prompt_params.SessionPromptParams), + ), + cast_to=SessionPromptResponse, + ) + + async def revert( + self, + id: str, + *, + message_id: str, + directory: str | NotGiven = NOT_GIVEN, + part_id: str | NotGiven = NOT_GIVEN, + # Use the following arguments if you need to pass additional parameters to the API that aren't available via kwargs. + # The extra values given here take precedence over values defined on the client or passed to this method. + extra_headers: Headers | None = None, + extra_query: Query | None = None, + extra_body: Body | None = None, + timeout: float | httpx.Timeout | None | NotGiven = NOT_GIVEN, + ) -> Session: + """ + Revert a message + + Args: + extra_headers: Send extra headers + + extra_query: Add additional query parameters to the request + + extra_body: Add additional JSON properties to the request + + timeout: Override the client-level default timeout for this request, in seconds + """ + if not id: + raise ValueError(f"Expected a non-empty value for `id` but received {id!r}") + return await self._post( + f"/session/{id}/revert", + body=await async_maybe_transform( + { + "message_id": message_id, + "part_id": part_id, + }, + session_revert_params.SessionRevertParams, + ), + options=make_request_options( + extra_headers=extra_headers, + extra_query=extra_query, + extra_body=extra_body, + timeout=timeout, + query=await async_maybe_transform({"directory": directory}, session_revert_params.SessionRevertParams), + ), + cast_to=Session, + ) + + async def share( + self, + id: str, + *, + directory: str | NotGiven = NOT_GIVEN, + # Use the following arguments if you need to pass additional parameters to the API that aren't available via kwargs. + # The extra values given here take precedence over values defined on the client or passed to this method. + extra_headers: Headers | None = None, + extra_query: Query | None = None, + extra_body: Body | None = None, + timeout: float | httpx.Timeout | None | NotGiven = NOT_GIVEN, + ) -> Session: + """ + Share a session + + Args: + extra_headers: Send extra headers + + extra_query: Add additional query parameters to the request + + extra_body: Add additional JSON properties to the request + + timeout: Override the client-level default timeout for this request, in seconds + """ + if not id: + raise ValueError(f"Expected a non-empty value for `id` but received {id!r}") + return await self._post( + f"/session/{id}/share", + options=make_request_options( + extra_headers=extra_headers, + extra_query=extra_query, + extra_body=extra_body, + timeout=timeout, + query=await async_maybe_transform({"directory": directory}, session_share_params.SessionShareParams), + ), + cast_to=Session, + ) + + async def shell( + self, + id: str, + *, + agent: str, + command: str, + directory: str | NotGiven = NOT_GIVEN, + # Use the following arguments if you need to pass additional parameters to the API that aren't available via kwargs. + # The extra values given here take precedence over values defined on the client or passed to this method. + extra_headers: Headers | None = None, + extra_query: Query | None = None, + extra_body: Body | None = None, + timeout: float | httpx.Timeout | None | NotGiven = NOT_GIVEN, + ) -> AssistantMessage: + """ + Run a shell command + + Args: + id: Session ID + + extra_headers: Send extra headers + + extra_query: Add additional query parameters to the request + + extra_body: Add additional JSON properties to the request + + timeout: Override the client-level default timeout for this request, in seconds + """ + if not id: + raise ValueError(f"Expected a non-empty value for `id` but received {id!r}") + return await self._post( + f"/session/{id}/shell", + body=await async_maybe_transform( + { + "agent": agent, + "command": command, + }, + session_shell_params.SessionShellParams, + ), + options=make_request_options( + extra_headers=extra_headers, + extra_query=extra_query, + extra_body=extra_body, + timeout=timeout, + query=await async_maybe_transform({"directory": directory}, session_shell_params.SessionShellParams), + ), + cast_to=AssistantMessage, + ) + + async def summarize( + self, + id: str, + *, + model_id: str, + provider_id: str, + directory: str | NotGiven = NOT_GIVEN, + # Use the following arguments if you need to pass additional parameters to the API that aren't available via kwargs. + # The extra values given here take precedence over values defined on the client or passed to this method. + extra_headers: Headers | None = None, + extra_query: Query | None = None, + extra_body: Body | None = None, + timeout: float | httpx.Timeout | None | NotGiven = NOT_GIVEN, + ) -> SessionSummarizeResponse: + """ + Summarize the session + + Args: + id: Session ID + + extra_headers: Send extra headers + + extra_query: Add additional query parameters to the request + + extra_body: Add additional JSON properties to the request + + timeout: Override the client-level default timeout for this request, in seconds + """ + if not id: + raise ValueError(f"Expected a non-empty value for `id` but received {id!r}") + return await self._post( + f"/session/{id}/summarize", + body=await async_maybe_transform( + { + "model_id": model_id, + "provider_id": provider_id, + }, + session_summarize_params.SessionSummarizeParams, + ), + options=make_request_options( + extra_headers=extra_headers, + extra_query=extra_query, + extra_body=extra_body, + timeout=timeout, + query=await async_maybe_transform( + {"directory": directory}, session_summarize_params.SessionSummarizeParams + ), + ), + cast_to=SessionSummarizeResponse, + ) + + async def unrevert( + self, + id: str, + *, + directory: str | NotGiven = NOT_GIVEN, + # Use the following arguments if you need to pass additional parameters to the API that aren't available via kwargs. + # The extra values given here take precedence over values defined on the client or passed to this method. + extra_headers: Headers | None = None, + extra_query: Query | None = None, + extra_body: Body | None = None, + timeout: float | httpx.Timeout | None | NotGiven = NOT_GIVEN, + ) -> Session: + """ + Restore all reverted messages + + Args: + extra_headers: Send extra headers + + extra_query: Add additional query parameters to the request + + extra_body: Add additional JSON properties to the request + + timeout: Override the client-level default timeout for this request, in seconds + """ + if not id: + raise ValueError(f"Expected a non-empty value for `id` but received {id!r}") + return await self._post( + f"/session/{id}/unrevert", + options=make_request_options( + extra_headers=extra_headers, + extra_query=extra_query, + extra_body=extra_body, + timeout=timeout, + query=await async_maybe_transform( + {"directory": directory}, session_unrevert_params.SessionUnrevertParams + ), + ), + cast_to=Session, + ) + + async def unshare( + self, + id: str, + *, + directory: str | NotGiven = NOT_GIVEN, + # Use the following arguments if you need to pass additional parameters to the API that aren't available via kwargs. + # The extra values given here take precedence over values defined on the client or passed to this method. + extra_headers: Headers | None = None, + extra_query: Query | None = None, + extra_body: Body | None = None, + timeout: float | httpx.Timeout | None | NotGiven = NOT_GIVEN, + ) -> Session: + """ + Unshare the session + + Args: + extra_headers: Send extra headers + + extra_query: Add additional query parameters to the request + + extra_body: Add additional JSON properties to the request + + timeout: Override the client-level default timeout for this request, in seconds + """ + if not id: + raise ValueError(f"Expected a non-empty value for `id` but received {id!r}") + return await self._delete( + f"/session/{id}/share", + options=make_request_options( + extra_headers=extra_headers, + extra_query=extra_query, + extra_body=extra_body, + timeout=timeout, + query=await async_maybe_transform( + {"directory": directory}, session_unshare_params.SessionUnshareParams + ), + ), + cast_to=Session, + ) + + +class SessionResourceWithRawResponse: + def __init__(self, session: SessionResource) -> None: + self._session = session + + self.create = to_raw_response_wrapper( + session.create, + ) + self.update = to_raw_response_wrapper( + session.update, + ) + self.list = to_raw_response_wrapper( + session.list, + ) + self.delete = to_raw_response_wrapper( + session.delete, + ) + self.abort = to_raw_response_wrapper( + session.abort, + ) + self.children = to_raw_response_wrapper( + session.children, + ) + self.command = to_raw_response_wrapper( + session.command, + ) + self.get = to_raw_response_wrapper( + session.get, + ) + self.init = to_raw_response_wrapper( + session.init, + ) + self.message = to_raw_response_wrapper( + session.message, + ) + self.messages = to_raw_response_wrapper( + session.messages, + ) + self.prompt = to_raw_response_wrapper( + session.prompt, + ) + self.revert = to_raw_response_wrapper( + session.revert, + ) + self.share = to_raw_response_wrapper( + session.share, + ) + self.shell = to_raw_response_wrapper( + session.shell, + ) + self.summarize = to_raw_response_wrapper( + session.summarize, + ) + self.unrevert = to_raw_response_wrapper( + session.unrevert, + ) + self.unshare = to_raw_response_wrapper( + session.unshare, + ) + + @cached_property + def permissions(self) -> PermissionsResourceWithRawResponse: + return PermissionsResourceWithRawResponse(self._session.permissions) + + +class AsyncSessionResourceWithRawResponse: + def __init__(self, session: AsyncSessionResource) -> None: + self._session = session + + self.create = async_to_raw_response_wrapper( + session.create, + ) + self.update = async_to_raw_response_wrapper( + session.update, + ) + self.list = async_to_raw_response_wrapper( + session.list, + ) + self.delete = async_to_raw_response_wrapper( + session.delete, + ) + self.abort = async_to_raw_response_wrapper( + session.abort, + ) + self.children = async_to_raw_response_wrapper( + session.children, + ) + self.command = async_to_raw_response_wrapper( + session.command, + ) + self.get = async_to_raw_response_wrapper( + session.get, + ) + self.init = async_to_raw_response_wrapper( + session.init, + ) + self.message = async_to_raw_response_wrapper( + session.message, + ) + self.messages = async_to_raw_response_wrapper( + session.messages, + ) + self.prompt = async_to_raw_response_wrapper( + session.prompt, + ) + self.revert = async_to_raw_response_wrapper( + session.revert, + ) + self.share = async_to_raw_response_wrapper( + session.share, + ) + self.shell = async_to_raw_response_wrapper( + session.shell, + ) + self.summarize = async_to_raw_response_wrapper( + session.summarize, + ) + self.unrevert = async_to_raw_response_wrapper( + session.unrevert, + ) + self.unshare = async_to_raw_response_wrapper( + session.unshare, + ) + + @cached_property + def permissions(self) -> AsyncPermissionsResourceWithRawResponse: + return AsyncPermissionsResourceWithRawResponse(self._session.permissions) + + +class SessionResourceWithStreamingResponse: + def __init__(self, session: SessionResource) -> None: + self._session = session + + self.create = to_streamed_response_wrapper( + session.create, + ) + self.update = to_streamed_response_wrapper( + session.update, + ) + self.list = to_streamed_response_wrapper( + session.list, + ) + self.delete = to_streamed_response_wrapper( + session.delete, + ) + self.abort = to_streamed_response_wrapper( + session.abort, + ) + self.children = to_streamed_response_wrapper( + session.children, + ) + self.command = to_streamed_response_wrapper( + session.command, + ) + self.get = to_streamed_response_wrapper( + session.get, + ) + self.init = to_streamed_response_wrapper( + session.init, + ) + self.message = to_streamed_response_wrapper( + session.message, + ) + self.messages = to_streamed_response_wrapper( + session.messages, + ) + self.prompt = to_streamed_response_wrapper( + session.prompt, + ) + self.revert = to_streamed_response_wrapper( + session.revert, + ) + self.share = to_streamed_response_wrapper( + session.share, + ) + self.shell = to_streamed_response_wrapper( + session.shell, + ) + self.summarize = to_streamed_response_wrapper( + session.summarize, + ) + self.unrevert = to_streamed_response_wrapper( + session.unrevert, + ) + self.unshare = to_streamed_response_wrapper( + session.unshare, + ) + + @cached_property + def permissions(self) -> PermissionsResourceWithStreamingResponse: + return PermissionsResourceWithStreamingResponse(self._session.permissions) + + +class AsyncSessionResourceWithStreamingResponse: + def __init__(self, session: AsyncSessionResource) -> None: + self._session = session + + self.create = async_to_streamed_response_wrapper( + session.create, + ) + self.update = async_to_streamed_response_wrapper( + session.update, + ) + self.list = async_to_streamed_response_wrapper( + session.list, + ) + self.delete = async_to_streamed_response_wrapper( + session.delete, + ) + self.abort = async_to_streamed_response_wrapper( + session.abort, + ) + self.children = async_to_streamed_response_wrapper( + session.children, + ) + self.command = async_to_streamed_response_wrapper( + session.command, + ) + self.get = async_to_streamed_response_wrapper( + session.get, + ) + self.init = async_to_streamed_response_wrapper( + session.init, + ) + self.message = async_to_streamed_response_wrapper( + session.message, + ) + self.messages = async_to_streamed_response_wrapper( + session.messages, + ) + self.prompt = async_to_streamed_response_wrapper( + session.prompt, + ) + self.revert = async_to_streamed_response_wrapper( + session.revert, + ) + self.share = async_to_streamed_response_wrapper( + session.share, + ) + self.shell = async_to_streamed_response_wrapper( + session.shell, + ) + self.summarize = async_to_streamed_response_wrapper( + session.summarize, + ) + self.unrevert = async_to_streamed_response_wrapper( + session.unrevert, + ) + self.unshare = async_to_streamed_response_wrapper( + session.unshare, + ) + + @cached_property + def permissions(self) -> AsyncPermissionsResourceWithStreamingResponse: + return AsyncPermissionsResourceWithStreamingResponse(self._session.permissions) diff --git a/src/opencode_ai/resources/tui.py b/src/opencode_ai/resources/tui.py new file mode 100644 index 0000000..0a13597 --- /dev/null +++ b/src/opencode_ai/resources/tui.py @@ -0,0 +1,887 @@ +# File generated from our OpenAPI spec by Stainless. See CONTRIBUTING.md for details. + +from __future__ import annotations + +from typing_extensions import Literal + +import httpx + +from ..types import ( + tui_open_help_params, + tui_show_toast_params, + tui_open_models_params, + tui_open_themes_params, + tui_clear_prompt_params, + tui_append_prompt_params, + tui_open_sessions_params, + tui_submit_prompt_params, + tui_execute_command_params, +) +from .._types import NOT_GIVEN, Body, Query, Headers, NotGiven +from .._utils import maybe_transform, async_maybe_transform +from .._compat import cached_property +from .._resource import SyncAPIResource, AsyncAPIResource +from .._response import ( + to_raw_response_wrapper, + to_streamed_response_wrapper, + async_to_raw_response_wrapper, + async_to_streamed_response_wrapper, +) +from .._base_client import make_request_options +from ..types.tui_open_help_response import TuiOpenHelpResponse +from ..types.tui_show_toast_response import TuiShowToastResponse +from ..types.tui_open_models_response import TuiOpenModelsResponse +from ..types.tui_open_themes_response import TuiOpenThemesResponse +from ..types.tui_clear_prompt_response import TuiClearPromptResponse +from ..types.tui_append_prompt_response import TuiAppendPromptResponse +from ..types.tui_open_sessions_response import TuiOpenSessionsResponse +from ..types.tui_submit_prompt_response import TuiSubmitPromptResponse +from ..types.tui_execute_command_response import TuiExecuteCommandResponse + +__all__ = ["TuiResource", "AsyncTuiResource"] + + +class TuiResource(SyncAPIResource): + @cached_property + def with_raw_response(self) -> TuiResourceWithRawResponse: + """ + This property can be used as a prefix for any HTTP method call to return + the raw response object instead of the parsed content. + + For more information, see https://www.github.com/sst/opencode-sdk-python#accessing-raw-response-data-eg-headers + """ + return TuiResourceWithRawResponse(self) + + @cached_property + def with_streaming_response(self) -> TuiResourceWithStreamingResponse: + """ + An alternative to `.with_raw_response` that doesn't eagerly read the response body. + + For more information, see https://www.github.com/sst/opencode-sdk-python#with_streaming_response + """ + return TuiResourceWithStreamingResponse(self) + + def append_prompt( + self, + *, + text: str, + directory: str | NotGiven = NOT_GIVEN, + # Use the following arguments if you need to pass additional parameters to the API that aren't available via kwargs. + # The extra values given here take precedence over values defined on the client or passed to this method. + extra_headers: Headers | None = None, + extra_query: Query | None = None, + extra_body: Body | None = None, + timeout: float | httpx.Timeout | None | NotGiven = NOT_GIVEN, + ) -> TuiAppendPromptResponse: + """ + Append prompt to the TUI + + Args: + extra_headers: Send extra headers + + extra_query: Add additional query parameters to the request + + extra_body: Add additional JSON properties to the request + + timeout: Override the client-level default timeout for this request, in seconds + """ + return self._post( + "/tui/append-prompt", + body=maybe_transform({"text": text}, tui_append_prompt_params.TuiAppendPromptParams), + options=make_request_options( + extra_headers=extra_headers, + extra_query=extra_query, + extra_body=extra_body, + timeout=timeout, + query=maybe_transform({"directory": directory}, tui_append_prompt_params.TuiAppendPromptParams), + ), + cast_to=TuiAppendPromptResponse, + ) + + def clear_prompt( + self, + *, + directory: str | NotGiven = NOT_GIVEN, + # Use the following arguments if you need to pass additional parameters to the API that aren't available via kwargs. + # The extra values given here take precedence over values defined on the client or passed to this method. + extra_headers: Headers | None = None, + extra_query: Query | None = None, + extra_body: Body | None = None, + timeout: float | httpx.Timeout | None | NotGiven = NOT_GIVEN, + ) -> TuiClearPromptResponse: + """ + Clear the prompt + + Args: + extra_headers: Send extra headers + + extra_query: Add additional query parameters to the request + + extra_body: Add additional JSON properties to the request + + timeout: Override the client-level default timeout for this request, in seconds + """ + return self._post( + "/tui/clear-prompt", + options=make_request_options( + extra_headers=extra_headers, + extra_query=extra_query, + extra_body=extra_body, + timeout=timeout, + query=maybe_transform({"directory": directory}, tui_clear_prompt_params.TuiClearPromptParams), + ), + cast_to=TuiClearPromptResponse, + ) + + def execute_command( + self, + *, + command: str, + directory: str | NotGiven = NOT_GIVEN, + # Use the following arguments if you need to pass additional parameters to the API that aren't available via kwargs. + # The extra values given here take precedence over values defined on the client or passed to this method. + extra_headers: Headers | None = None, + extra_query: Query | None = None, + extra_body: Body | None = None, + timeout: float | httpx.Timeout | None | NotGiven = NOT_GIVEN, + ) -> TuiExecuteCommandResponse: + """Execute a TUI command (e.g. + + agent_cycle) + + Args: + extra_headers: Send extra headers + + extra_query: Add additional query parameters to the request + + extra_body: Add additional JSON properties to the request + + timeout: Override the client-level default timeout for this request, in seconds + """ + return self._post( + "/tui/execute-command", + body=maybe_transform({"command": command}, tui_execute_command_params.TuiExecuteCommandParams), + options=make_request_options( + extra_headers=extra_headers, + extra_query=extra_query, + extra_body=extra_body, + timeout=timeout, + query=maybe_transform({"directory": directory}, tui_execute_command_params.TuiExecuteCommandParams), + ), + cast_to=TuiExecuteCommandResponse, + ) + + def open_help( + self, + *, + directory: str | NotGiven = NOT_GIVEN, + # Use the following arguments if you need to pass additional parameters to the API that aren't available via kwargs. + # The extra values given here take precedence over values defined on the client or passed to this method. + extra_headers: Headers | None = None, + extra_query: Query | None = None, + extra_body: Body | None = None, + timeout: float | httpx.Timeout | None | NotGiven = NOT_GIVEN, + ) -> TuiOpenHelpResponse: + """ + Open the help dialog + + Args: + extra_headers: Send extra headers + + extra_query: Add additional query parameters to the request + + extra_body: Add additional JSON properties to the request + + timeout: Override the client-level default timeout for this request, in seconds + """ + return self._post( + "/tui/open-help", + options=make_request_options( + extra_headers=extra_headers, + extra_query=extra_query, + extra_body=extra_body, + timeout=timeout, + query=maybe_transform({"directory": directory}, tui_open_help_params.TuiOpenHelpParams), + ), + cast_to=TuiOpenHelpResponse, + ) + + def open_models( + self, + *, + directory: str | NotGiven = NOT_GIVEN, + # Use the following arguments if you need to pass additional parameters to the API that aren't available via kwargs. + # The extra values given here take precedence over values defined on the client or passed to this method. + extra_headers: Headers | None = None, + extra_query: Query | None = None, + extra_body: Body | None = None, + timeout: float | httpx.Timeout | None | NotGiven = NOT_GIVEN, + ) -> TuiOpenModelsResponse: + """ + Open the model dialog + + Args: + extra_headers: Send extra headers + + extra_query: Add additional query parameters to the request + + extra_body: Add additional JSON properties to the request + + timeout: Override the client-level default timeout for this request, in seconds + """ + return self._post( + "/tui/open-models", + options=make_request_options( + extra_headers=extra_headers, + extra_query=extra_query, + extra_body=extra_body, + timeout=timeout, + query=maybe_transform({"directory": directory}, tui_open_models_params.TuiOpenModelsParams), + ), + cast_to=TuiOpenModelsResponse, + ) + + def open_sessions( + self, + *, + directory: str | NotGiven = NOT_GIVEN, + # Use the following arguments if you need to pass additional parameters to the API that aren't available via kwargs. + # The extra values given here take precedence over values defined on the client or passed to this method. + extra_headers: Headers | None = None, + extra_query: Query | None = None, + extra_body: Body | None = None, + timeout: float | httpx.Timeout | None | NotGiven = NOT_GIVEN, + ) -> TuiOpenSessionsResponse: + """ + Open the session dialog + + Args: + extra_headers: Send extra headers + + extra_query: Add additional query parameters to the request + + extra_body: Add additional JSON properties to the request + + timeout: Override the client-level default timeout for this request, in seconds + """ + return self._post( + "/tui/open-sessions", + options=make_request_options( + extra_headers=extra_headers, + extra_query=extra_query, + extra_body=extra_body, + timeout=timeout, + query=maybe_transform({"directory": directory}, tui_open_sessions_params.TuiOpenSessionsParams), + ), + cast_to=TuiOpenSessionsResponse, + ) + + def open_themes( + self, + *, + directory: str | NotGiven = NOT_GIVEN, + # Use the following arguments if you need to pass additional parameters to the API that aren't available via kwargs. + # The extra values given here take precedence over values defined on the client or passed to this method. + extra_headers: Headers | None = None, + extra_query: Query | None = None, + extra_body: Body | None = None, + timeout: float | httpx.Timeout | None | NotGiven = NOT_GIVEN, + ) -> TuiOpenThemesResponse: + """ + Open the theme dialog + + Args: + extra_headers: Send extra headers + + extra_query: Add additional query parameters to the request + + extra_body: Add additional JSON properties to the request + + timeout: Override the client-level default timeout for this request, in seconds + """ + return self._post( + "/tui/open-themes", + options=make_request_options( + extra_headers=extra_headers, + extra_query=extra_query, + extra_body=extra_body, + timeout=timeout, + query=maybe_transform({"directory": directory}, tui_open_themes_params.TuiOpenThemesParams), + ), + cast_to=TuiOpenThemesResponse, + ) + + def show_toast( + self, + *, + message: str, + variant: Literal["info", "success", "warning", "error"], + directory: str | NotGiven = NOT_GIVEN, + title: str | NotGiven = NOT_GIVEN, + # Use the following arguments if you need to pass additional parameters to the API that aren't available via kwargs. + # The extra values given here take precedence over values defined on the client or passed to this method. + extra_headers: Headers | None = None, + extra_query: Query | None = None, + extra_body: Body | None = None, + timeout: float | httpx.Timeout | None | NotGiven = NOT_GIVEN, + ) -> TuiShowToastResponse: + """ + Show a toast notification in the TUI + + Args: + extra_headers: Send extra headers + + extra_query: Add additional query parameters to the request + + extra_body: Add additional JSON properties to the request + + timeout: Override the client-level default timeout for this request, in seconds + """ + return self._post( + "/tui/show-toast", + body=maybe_transform( + { + "message": message, + "variant": variant, + "title": title, + }, + tui_show_toast_params.TuiShowToastParams, + ), + options=make_request_options( + extra_headers=extra_headers, + extra_query=extra_query, + extra_body=extra_body, + timeout=timeout, + query=maybe_transform({"directory": directory}, tui_show_toast_params.TuiShowToastParams), + ), + cast_to=TuiShowToastResponse, + ) + + def submit_prompt( + self, + *, + directory: str | NotGiven = NOT_GIVEN, + # Use the following arguments if you need to pass additional parameters to the API that aren't available via kwargs. + # The extra values given here take precedence over values defined on the client or passed to this method. + extra_headers: Headers | None = None, + extra_query: Query | None = None, + extra_body: Body | None = None, + timeout: float | httpx.Timeout | None | NotGiven = NOT_GIVEN, + ) -> TuiSubmitPromptResponse: + """ + Submit the prompt + + Args: + extra_headers: Send extra headers + + extra_query: Add additional query parameters to the request + + extra_body: Add additional JSON properties to the request + + timeout: Override the client-level default timeout for this request, in seconds + """ + return self._post( + "/tui/submit-prompt", + options=make_request_options( + extra_headers=extra_headers, + extra_query=extra_query, + extra_body=extra_body, + timeout=timeout, + query=maybe_transform({"directory": directory}, tui_submit_prompt_params.TuiSubmitPromptParams), + ), + cast_to=TuiSubmitPromptResponse, + ) + + +class AsyncTuiResource(AsyncAPIResource): + @cached_property + def with_raw_response(self) -> AsyncTuiResourceWithRawResponse: + """ + This property can be used as a prefix for any HTTP method call to return + the raw response object instead of the parsed content. + + For more information, see https://www.github.com/sst/opencode-sdk-python#accessing-raw-response-data-eg-headers + """ + return AsyncTuiResourceWithRawResponse(self) + + @cached_property + def with_streaming_response(self) -> AsyncTuiResourceWithStreamingResponse: + """ + An alternative to `.with_raw_response` that doesn't eagerly read the response body. + + For more information, see https://www.github.com/sst/opencode-sdk-python#with_streaming_response + """ + return AsyncTuiResourceWithStreamingResponse(self) + + async def append_prompt( + self, + *, + text: str, + directory: str | NotGiven = NOT_GIVEN, + # Use the following arguments if you need to pass additional parameters to the API that aren't available via kwargs. + # The extra values given here take precedence over values defined on the client or passed to this method. + extra_headers: Headers | None = None, + extra_query: Query | None = None, + extra_body: Body | None = None, + timeout: float | httpx.Timeout | None | NotGiven = NOT_GIVEN, + ) -> TuiAppendPromptResponse: + """ + Append prompt to the TUI + + Args: + extra_headers: Send extra headers + + extra_query: Add additional query parameters to the request + + extra_body: Add additional JSON properties to the request + + timeout: Override the client-level default timeout for this request, in seconds + """ + return await self._post( + "/tui/append-prompt", + body=await async_maybe_transform({"text": text}, tui_append_prompt_params.TuiAppendPromptParams), + options=make_request_options( + extra_headers=extra_headers, + extra_query=extra_query, + extra_body=extra_body, + timeout=timeout, + query=await async_maybe_transform( + {"directory": directory}, tui_append_prompt_params.TuiAppendPromptParams + ), + ), + cast_to=TuiAppendPromptResponse, + ) + + async def clear_prompt( + self, + *, + directory: str | NotGiven = NOT_GIVEN, + # Use the following arguments if you need to pass additional parameters to the API that aren't available via kwargs. + # The extra values given here take precedence over values defined on the client or passed to this method. + extra_headers: Headers | None = None, + extra_query: Query | None = None, + extra_body: Body | None = None, + timeout: float | httpx.Timeout | None | NotGiven = NOT_GIVEN, + ) -> TuiClearPromptResponse: + """ + Clear the prompt + + Args: + extra_headers: Send extra headers + + extra_query: Add additional query parameters to the request + + extra_body: Add additional JSON properties to the request + + timeout: Override the client-level default timeout for this request, in seconds + """ + return await self._post( + "/tui/clear-prompt", + options=make_request_options( + extra_headers=extra_headers, + extra_query=extra_query, + extra_body=extra_body, + timeout=timeout, + query=await async_maybe_transform( + {"directory": directory}, tui_clear_prompt_params.TuiClearPromptParams + ), + ), + cast_to=TuiClearPromptResponse, + ) + + async def execute_command( + self, + *, + command: str, + directory: str | NotGiven = NOT_GIVEN, + # Use the following arguments if you need to pass additional parameters to the API that aren't available via kwargs. + # The extra values given here take precedence over values defined on the client or passed to this method. + extra_headers: Headers | None = None, + extra_query: Query | None = None, + extra_body: Body | None = None, + timeout: float | httpx.Timeout | None | NotGiven = NOT_GIVEN, + ) -> TuiExecuteCommandResponse: + """Execute a TUI command (e.g. + + agent_cycle) + + Args: + extra_headers: Send extra headers + + extra_query: Add additional query parameters to the request + + extra_body: Add additional JSON properties to the request + + timeout: Override the client-level default timeout for this request, in seconds + """ + return await self._post( + "/tui/execute-command", + body=await async_maybe_transform({"command": command}, tui_execute_command_params.TuiExecuteCommandParams), + options=make_request_options( + extra_headers=extra_headers, + extra_query=extra_query, + extra_body=extra_body, + timeout=timeout, + query=await async_maybe_transform( + {"directory": directory}, tui_execute_command_params.TuiExecuteCommandParams + ), + ), + cast_to=TuiExecuteCommandResponse, + ) + + async def open_help( + self, + *, + directory: str | NotGiven = NOT_GIVEN, + # Use the following arguments if you need to pass additional parameters to the API that aren't available via kwargs. + # The extra values given here take precedence over values defined on the client or passed to this method. + extra_headers: Headers | None = None, + extra_query: Query | None = None, + extra_body: Body | None = None, + timeout: float | httpx.Timeout | None | NotGiven = NOT_GIVEN, + ) -> TuiOpenHelpResponse: + """ + Open the help dialog + + Args: + extra_headers: Send extra headers + + extra_query: Add additional query parameters to the request + + extra_body: Add additional JSON properties to the request + + timeout: Override the client-level default timeout for this request, in seconds + """ + return await self._post( + "/tui/open-help", + options=make_request_options( + extra_headers=extra_headers, + extra_query=extra_query, + extra_body=extra_body, + timeout=timeout, + query=await async_maybe_transform({"directory": directory}, tui_open_help_params.TuiOpenHelpParams), + ), + cast_to=TuiOpenHelpResponse, + ) + + async def open_models( + self, + *, + directory: str | NotGiven = NOT_GIVEN, + # Use the following arguments if you need to pass additional parameters to the API that aren't available via kwargs. + # The extra values given here take precedence over values defined on the client or passed to this method. + extra_headers: Headers | None = None, + extra_query: Query | None = None, + extra_body: Body | None = None, + timeout: float | httpx.Timeout | None | NotGiven = NOT_GIVEN, + ) -> TuiOpenModelsResponse: + """ + Open the model dialog + + Args: + extra_headers: Send extra headers + + extra_query: Add additional query parameters to the request + + extra_body: Add additional JSON properties to the request + + timeout: Override the client-level default timeout for this request, in seconds + """ + return await self._post( + "/tui/open-models", + options=make_request_options( + extra_headers=extra_headers, + extra_query=extra_query, + extra_body=extra_body, + timeout=timeout, + query=await async_maybe_transform({"directory": directory}, tui_open_models_params.TuiOpenModelsParams), + ), + cast_to=TuiOpenModelsResponse, + ) + + async def open_sessions( + self, + *, + directory: str | NotGiven = NOT_GIVEN, + # Use the following arguments if you need to pass additional parameters to the API that aren't available via kwargs. + # The extra values given here take precedence over values defined on the client or passed to this method. + extra_headers: Headers | None = None, + extra_query: Query | None = None, + extra_body: Body | None = None, + timeout: float | httpx.Timeout | None | NotGiven = NOT_GIVEN, + ) -> TuiOpenSessionsResponse: + """ + Open the session dialog + + Args: + extra_headers: Send extra headers + + extra_query: Add additional query parameters to the request + + extra_body: Add additional JSON properties to the request + + timeout: Override the client-level default timeout for this request, in seconds + """ + return await self._post( + "/tui/open-sessions", + options=make_request_options( + extra_headers=extra_headers, + extra_query=extra_query, + extra_body=extra_body, + timeout=timeout, + query=await async_maybe_transform( + {"directory": directory}, tui_open_sessions_params.TuiOpenSessionsParams + ), + ), + cast_to=TuiOpenSessionsResponse, + ) + + async def open_themes( + self, + *, + directory: str | NotGiven = NOT_GIVEN, + # Use the following arguments if you need to pass additional parameters to the API that aren't available via kwargs. + # The extra values given here take precedence over values defined on the client or passed to this method. + extra_headers: Headers | None = None, + extra_query: Query | None = None, + extra_body: Body | None = None, + timeout: float | httpx.Timeout | None | NotGiven = NOT_GIVEN, + ) -> TuiOpenThemesResponse: + """ + Open the theme dialog + + Args: + extra_headers: Send extra headers + + extra_query: Add additional query parameters to the request + + extra_body: Add additional JSON properties to the request + + timeout: Override the client-level default timeout for this request, in seconds + """ + return await self._post( + "/tui/open-themes", + options=make_request_options( + extra_headers=extra_headers, + extra_query=extra_query, + extra_body=extra_body, + timeout=timeout, + query=await async_maybe_transform({"directory": directory}, tui_open_themes_params.TuiOpenThemesParams), + ), + cast_to=TuiOpenThemesResponse, + ) + + async def show_toast( + self, + *, + message: str, + variant: Literal["info", "success", "warning", "error"], + directory: str | NotGiven = NOT_GIVEN, + title: str | NotGiven = NOT_GIVEN, + # Use the following arguments if you need to pass additional parameters to the API that aren't available via kwargs. + # The extra values given here take precedence over values defined on the client or passed to this method. + extra_headers: Headers | None = None, + extra_query: Query | None = None, + extra_body: Body | None = None, + timeout: float | httpx.Timeout | None | NotGiven = NOT_GIVEN, + ) -> TuiShowToastResponse: + """ + Show a toast notification in the TUI + + Args: + extra_headers: Send extra headers + + extra_query: Add additional query parameters to the request + + extra_body: Add additional JSON properties to the request + + timeout: Override the client-level default timeout for this request, in seconds + """ + return await self._post( + "/tui/show-toast", + body=await async_maybe_transform( + { + "message": message, + "variant": variant, + "title": title, + }, + tui_show_toast_params.TuiShowToastParams, + ), + options=make_request_options( + extra_headers=extra_headers, + extra_query=extra_query, + extra_body=extra_body, + timeout=timeout, + query=await async_maybe_transform({"directory": directory}, tui_show_toast_params.TuiShowToastParams), + ), + cast_to=TuiShowToastResponse, + ) + + async def submit_prompt( + self, + *, + directory: str | NotGiven = NOT_GIVEN, + # Use the following arguments if you need to pass additional parameters to the API that aren't available via kwargs. + # The extra values given here take precedence over values defined on the client or passed to this method. + extra_headers: Headers | None = None, + extra_query: Query | None = None, + extra_body: Body | None = None, + timeout: float | httpx.Timeout | None | NotGiven = NOT_GIVEN, + ) -> TuiSubmitPromptResponse: + """ + Submit the prompt + + Args: + extra_headers: Send extra headers + + extra_query: Add additional query parameters to the request + + extra_body: Add additional JSON properties to the request + + timeout: Override the client-level default timeout for this request, in seconds + """ + return await self._post( + "/tui/submit-prompt", + options=make_request_options( + extra_headers=extra_headers, + extra_query=extra_query, + extra_body=extra_body, + timeout=timeout, + query=await async_maybe_transform( + {"directory": directory}, tui_submit_prompt_params.TuiSubmitPromptParams + ), + ), + cast_to=TuiSubmitPromptResponse, + ) + + +class TuiResourceWithRawResponse: + def __init__(self, tui: TuiResource) -> None: + self._tui = tui + + self.append_prompt = to_raw_response_wrapper( + tui.append_prompt, + ) + self.clear_prompt = to_raw_response_wrapper( + tui.clear_prompt, + ) + self.execute_command = to_raw_response_wrapper( + tui.execute_command, + ) + self.open_help = to_raw_response_wrapper( + tui.open_help, + ) + self.open_models = to_raw_response_wrapper( + tui.open_models, + ) + self.open_sessions = to_raw_response_wrapper( + tui.open_sessions, + ) + self.open_themes = to_raw_response_wrapper( + tui.open_themes, + ) + self.show_toast = to_raw_response_wrapper( + tui.show_toast, + ) + self.submit_prompt = to_raw_response_wrapper( + tui.submit_prompt, + ) + + +class AsyncTuiResourceWithRawResponse: + def __init__(self, tui: AsyncTuiResource) -> None: + self._tui = tui + + self.append_prompt = async_to_raw_response_wrapper( + tui.append_prompt, + ) + self.clear_prompt = async_to_raw_response_wrapper( + tui.clear_prompt, + ) + self.execute_command = async_to_raw_response_wrapper( + tui.execute_command, + ) + self.open_help = async_to_raw_response_wrapper( + tui.open_help, + ) + self.open_models = async_to_raw_response_wrapper( + tui.open_models, + ) + self.open_sessions = async_to_raw_response_wrapper( + tui.open_sessions, + ) + self.open_themes = async_to_raw_response_wrapper( + tui.open_themes, + ) + self.show_toast = async_to_raw_response_wrapper( + tui.show_toast, + ) + self.submit_prompt = async_to_raw_response_wrapper( + tui.submit_prompt, + ) + + +class TuiResourceWithStreamingResponse: + def __init__(self, tui: TuiResource) -> None: + self._tui = tui + + self.append_prompt = to_streamed_response_wrapper( + tui.append_prompt, + ) + self.clear_prompt = to_streamed_response_wrapper( + tui.clear_prompt, + ) + self.execute_command = to_streamed_response_wrapper( + tui.execute_command, + ) + self.open_help = to_streamed_response_wrapper( + tui.open_help, + ) + self.open_models = to_streamed_response_wrapper( + tui.open_models, + ) + self.open_sessions = to_streamed_response_wrapper( + tui.open_sessions, + ) + self.open_themes = to_streamed_response_wrapper( + tui.open_themes, + ) + self.show_toast = to_streamed_response_wrapper( + tui.show_toast, + ) + self.submit_prompt = to_streamed_response_wrapper( + tui.submit_prompt, + ) + + +class AsyncTuiResourceWithStreamingResponse: + def __init__(self, tui: AsyncTuiResource) -> None: + self._tui = tui + + self.append_prompt = async_to_streamed_response_wrapper( + tui.append_prompt, + ) + self.clear_prompt = async_to_streamed_response_wrapper( + tui.clear_prompt, + ) + self.execute_command = async_to_streamed_response_wrapper( + tui.execute_command, + ) + self.open_help = async_to_streamed_response_wrapper( + tui.open_help, + ) + self.open_models = async_to_streamed_response_wrapper( + tui.open_models, + ) + self.open_sessions = async_to_streamed_response_wrapper( + tui.open_sessions, + ) + self.open_themes = async_to_streamed_response_wrapper( + tui.open_themes, + ) + self.show_toast = async_to_streamed_response_wrapper( + tui.show_toast, + ) + self.submit_prompt = async_to_streamed_response_wrapper( + tui.submit_prompt, + ) diff --git a/src/opencode_ai/types/__init__.py b/src/opencode_ai/types/__init__.py new file mode 100644 index 0000000..83a7701 --- /dev/null +++ b/src/opencode_ai/types/__init__.py @@ -0,0 +1,123 @@ +# File generated from our OpenAPI spec by Stainless. See CONTRIBUTING.md for details. + +from __future__ import annotations + +from .file import File as File +from .part import Part as Part +from .path import Path as Path +from .agent import Agent as Agent +from .model import Model as Model +from .config import Config as Config +from .shared import ( + UnknownError as UnknownError, + ProviderAuthError as ProviderAuthError, + MessageAbortedError as MessageAbortedError, +) +from .symbol import Symbol as Symbol +from .command import Command as Command +from .message import Message as Message +from .project import Project as Project +from .session import Session as Session +from .provider import Provider as Provider +from .file_node import FileNode as FileNode +from .file_part import FilePart as FilePart +from .text_part import TextPart as TextPart +from .tool_part import ToolPart as ToolPart +from .agent_part import AgentPart as AgentPart +from .file_source import FileSource as FileSource +from .user_message import UserMessage as UserMessage +from .snapshot_part import SnapshotPart as SnapshotPart +from .symbol_source import SymbolSource as SymbolSource +from .app_log_params import AppLogParams as AppLogParams +from .reasoning_part import ReasoningPart as ReasoningPart +from .keybinds_config import KeybindsConfig as KeybindsConfig +from .path_get_params import PathGetParams as PathGetParams +from .step_start_part import StepStartPart as StepStartPart +from .app_log_response import AppLogResponse as AppLogResponse +from .file_list_params import FileListParams as FileListParams +from .file_part_source import FilePartSource as FilePartSource +from .file_read_params import FileReadParams as FileReadParams +from .find_text_params import FindTextParams as FindTextParams +from .mcp_local_config import McpLocalConfig as McpLocalConfig +from .step_finish_part import StepFinishPart as StepFinishPart +from .tool_state_error import ToolStateError as ToolStateError +from .agent_list_params import AgentListParams as AgentListParams +from .assistant_message import AssistantMessage as AssistantMessage +from .config_get_params import ConfigGetParams as ConfigGetParams +from .event_list_params import EventListParams as EventListParams +from .file_source_param import FileSourceParam as FileSourceParam +from .find_files_params import FindFilesParams as FindFilesParams +from .mcp_remote_config import McpRemoteConfig as McpRemoteConfig +from .file_list_response import FileListResponse as FileListResponse +from .file_read_response import FileReadResponse as FileReadResponse +from .file_status_params import FileStatusParams as FileStatusParams +from .find_text_response import FindTextResponse as FindTextResponse +from .session_get_params import SessionGetParams as SessionGetParams +from .tool_state_pending import ToolStatePending as ToolStatePending +from .tool_state_running import ToolStateRunning as ToolStateRunning +from .agent_list_response import AgentListResponse as AgentListResponse +from .command_list_params import CommandListParams as CommandListParams +from .event_list_response import EventListResponse as EventListResponse +from .find_files_response import FindFilesResponse as FindFilesResponse +from .find_symbols_params import FindSymbolsParams as FindSymbolsParams +from .project_list_params import ProjectListParams as ProjectListParams +from .session_init_params import SessionInitParams as SessionInitParams +from .session_list_params import SessionListParams as SessionListParams +from .symbol_source_param import SymbolSourceParam as SymbolSourceParam +from .app_providers_params import AppProvidersParams as AppProvidersParams +from .file_status_response import FileStatusResponse as FileStatusResponse +from .session_abort_params import SessionAbortParams as SessionAbortParams +from .session_share_params import SessionShareParams as SessionShareParams +from .session_shell_params import SessionShellParams as SessionShellParams +from .tool_state_completed import ToolStateCompleted as ToolStateCompleted +from .tui_open_help_params import TuiOpenHelpParams as TuiOpenHelpParams +from .command_list_response import CommandListResponse as CommandListResponse +from .file_part_input_param import FilePartInputParam as FilePartInputParam +from .file_part_source_text import FilePartSourceText as FilePartSourceText +from .find_symbols_response import FindSymbolsResponse as FindSymbolsResponse +from .project_list_response import ProjectListResponse as ProjectListResponse +from .session_create_params import SessionCreateParams as SessionCreateParams +from .session_delete_params import SessionDeleteParams as SessionDeleteParams +from .session_init_response import SessionInitResponse as SessionInitResponse +from .session_list_response import SessionListResponse as SessionListResponse +from .session_prompt_params import SessionPromptParams as SessionPromptParams +from .session_revert_params import SessionRevertParams as SessionRevertParams +from .session_update_params import SessionUpdateParams as SessionUpdateParams +from .text_part_input_param import TextPartInputParam as TextPartInputParam +from .tui_show_toast_params import TuiShowToastParams as TuiShowToastParams +from .agent_part_input_param import AgentPartInputParam as AgentPartInputParam +from .app_providers_response import AppProvidersResponse as AppProvidersResponse +from .file_part_source_param import FilePartSourceParam as FilePartSourceParam +from .project_current_params import ProjectCurrentParams as ProjectCurrentParams +from .session_abort_response import SessionAbortResponse as SessionAbortResponse +from .session_command_params import SessionCommandParams as SessionCommandParams +from .session_message_params import SessionMessageParams as SessionMessageParams +from .session_unshare_params import SessionUnshareParams as SessionUnshareParams +from .tui_open_help_response import TuiOpenHelpResponse as TuiOpenHelpResponse +from .tui_open_models_params import TuiOpenModelsParams as TuiOpenModelsParams +from .tui_open_themes_params import TuiOpenThemesParams as TuiOpenThemesParams +from .session_children_params import SessionChildrenParams as SessionChildrenParams +from .session_delete_response import SessionDeleteResponse as SessionDeleteResponse +from .session_messages_params import SessionMessagesParams as SessionMessagesParams +from .session_prompt_response import SessionPromptResponse as SessionPromptResponse +from .session_unrevert_params import SessionUnrevertParams as SessionUnrevertParams +from .tui_clear_prompt_params import TuiClearPromptParams as TuiClearPromptParams +from .tui_show_toast_response import TuiShowToastResponse as TuiShowToastResponse +from .session_command_response import SessionCommandResponse as SessionCommandResponse +from .session_message_response import SessionMessageResponse as SessionMessageResponse +from .session_summarize_params import SessionSummarizeParams as SessionSummarizeParams +from .tui_append_prompt_params import TuiAppendPromptParams as TuiAppendPromptParams +from .tui_open_models_response import TuiOpenModelsResponse as TuiOpenModelsResponse +from .tui_open_sessions_params import TuiOpenSessionsParams as TuiOpenSessionsParams +from .tui_open_themes_response import TuiOpenThemesResponse as TuiOpenThemesResponse +from .tui_submit_prompt_params import TuiSubmitPromptParams as TuiSubmitPromptParams +from .session_children_response import SessionChildrenResponse as SessionChildrenResponse +from .session_messages_response import SessionMessagesResponse as SessionMessagesResponse +from .tui_clear_prompt_response import TuiClearPromptResponse as TuiClearPromptResponse +from .session_summarize_response import SessionSummarizeResponse as SessionSummarizeResponse +from .tui_append_prompt_response import TuiAppendPromptResponse as TuiAppendPromptResponse +from .tui_execute_command_params import TuiExecuteCommandParams as TuiExecuteCommandParams +from .tui_open_sessions_response import TuiOpenSessionsResponse as TuiOpenSessionsResponse +from .tui_submit_prompt_response import TuiSubmitPromptResponse as TuiSubmitPromptResponse +from .file_part_source_text_param import FilePartSourceTextParam as FilePartSourceTextParam +from .tui_execute_command_response import TuiExecuteCommandResponse as TuiExecuteCommandResponse diff --git a/src/opencode_ai/types/agent.py b/src/opencode_ai/types/agent.py new file mode 100644 index 0000000..34f5c9f --- /dev/null +++ b/src/opencode_ai/types/agent.py @@ -0,0 +1,48 @@ +# File generated from our OpenAPI spec by Stainless. See CONTRIBUTING.md for details. + +from typing import Dict, Optional +from typing_extensions import Literal + +from pydantic import Field as FieldInfo + +from .._models import BaseModel + +__all__ = ["Agent", "Permission", "Model"] + + +class Permission(BaseModel): + bash: Dict[str, Literal["ask", "allow", "deny"]] + + edit: Literal["ask", "allow", "deny"] + + webfetch: Optional[Literal["ask", "allow", "deny"]] = None + + +class Model(BaseModel): + api_model_id: str = FieldInfo(alias="modelID") + + provider_id: str = FieldInfo(alias="providerID") + + +class Agent(BaseModel): + built_in: bool = FieldInfo(alias="builtIn") + + mode: Literal["subagent", "primary", "all"] + + name: str + + options: Dict[str, object] + + permission: Permission + + tools: Dict[str, bool] + + description: Optional[str] = None + + model: Optional[Model] = None + + prompt: Optional[str] = None + + temperature: Optional[float] = None + + top_p: Optional[float] = FieldInfo(alias="topP", default=None) diff --git a/src/opencode_ai/types/agent_list_params.py b/src/opencode_ai/types/agent_list_params.py new file mode 100644 index 0000000..a5ee594 --- /dev/null +++ b/src/opencode_ai/types/agent_list_params.py @@ -0,0 +1,11 @@ +# File generated from our OpenAPI spec by Stainless. See CONTRIBUTING.md for details. + +from __future__ import annotations + +from typing_extensions import TypedDict + +__all__ = ["AgentListParams"] + + +class AgentListParams(TypedDict, total=False): + directory: str diff --git a/src/opencode_ai/types/agent_list_response.py b/src/opencode_ai/types/agent_list_response.py new file mode 100644 index 0000000..f33d201 --- /dev/null +++ b/src/opencode_ai/types/agent_list_response.py @@ -0,0 +1,10 @@ +# File generated from our OpenAPI spec by Stainless. See CONTRIBUTING.md for details. + +from typing import List +from typing_extensions import TypeAlias + +from .agent import Agent + +__all__ = ["AgentListResponse"] + +AgentListResponse: TypeAlias = List[Agent] diff --git a/src/opencode_ai/types/agent_part.py b/src/opencode_ai/types/agent_part.py new file mode 100644 index 0000000..3b0fd98 --- /dev/null +++ b/src/opencode_ai/types/agent_part.py @@ -0,0 +1,32 @@ +# File generated from our OpenAPI spec by Stainless. See CONTRIBUTING.md for details. + +from typing import Optional +from typing_extensions import Literal + +from pydantic import Field as FieldInfo + +from .._models import BaseModel + +__all__ = ["AgentPart", "Source"] + + +class Source(BaseModel): + end: int + + start: int + + value: str + + +class AgentPart(BaseModel): + id: str + + message_id: str = FieldInfo(alias="messageID") + + name: str + + session_id: str = FieldInfo(alias="sessionID") + + type: Literal["agent"] + + source: Optional[Source] = None diff --git a/src/opencode_ai/types/agent_part_input_param.py b/src/opencode_ai/types/agent_part_input_param.py new file mode 100644 index 0000000..a500028 --- /dev/null +++ b/src/opencode_ai/types/agent_part_input_param.py @@ -0,0 +1,25 @@ +# File generated from our OpenAPI spec by Stainless. See CONTRIBUTING.md for details. + +from __future__ import annotations + +from typing_extensions import Literal, Required, TypedDict + +__all__ = ["AgentPartInputParam", "Source"] + + +class Source(TypedDict, total=False): + end: Required[int] + + start: Required[int] + + value: Required[str] + + +class AgentPartInputParam(TypedDict, total=False): + name: Required[str] + + type: Required[Literal["agent"]] + + id: str + + source: Source diff --git a/src/opencode_ai/types/app_log_params.py b/src/opencode_ai/types/app_log_params.py new file mode 100644 index 0000000..cb5a8ee --- /dev/null +++ b/src/opencode_ai/types/app_log_params.py @@ -0,0 +1,24 @@ +# File generated from our OpenAPI spec by Stainless. See CONTRIBUTING.md for details. + +from __future__ import annotations + +from typing import Dict +from typing_extensions import Literal, Required, TypedDict + +__all__ = ["AppLogParams"] + + +class AppLogParams(TypedDict, total=False): + level: Required[Literal["debug", "info", "error", "warn"]] + """Log level""" + + message: Required[str] + """Log message""" + + service: Required[str] + """Service name for the log entry""" + + directory: str + + extra: Dict[str, object] + """Additional metadata for the log entry""" diff --git a/src/opencode_ai/types/app_log_response.py b/src/opencode_ai/types/app_log_response.py new file mode 100644 index 0000000..f56ed8c --- /dev/null +++ b/src/opencode_ai/types/app_log_response.py @@ -0,0 +1,7 @@ +# File generated from our OpenAPI spec by Stainless. See CONTRIBUTING.md for details. + +from typing_extensions import TypeAlias + +__all__ = ["AppLogResponse"] + +AppLogResponse: TypeAlias = bool diff --git a/src/opencode_ai/types/app_providers_params.py b/src/opencode_ai/types/app_providers_params.py new file mode 100644 index 0000000..75e4783 --- /dev/null +++ b/src/opencode_ai/types/app_providers_params.py @@ -0,0 +1,11 @@ +# File generated from our OpenAPI spec by Stainless. See CONTRIBUTING.md for details. + +from __future__ import annotations + +from typing_extensions import TypedDict + +__all__ = ["AppProvidersParams"] + + +class AppProvidersParams(TypedDict, total=False): + directory: str diff --git a/src/opencode_ai/types/app_providers_response.py b/src/opencode_ai/types/app_providers_response.py new file mode 100644 index 0000000..62912e8 --- /dev/null +++ b/src/opencode_ai/types/app_providers_response.py @@ -0,0 +1,14 @@ +# File generated from our OpenAPI spec by Stainless. See CONTRIBUTING.md for details. + +from typing import Dict, List + +from .._models import BaseModel +from .provider import Provider + +__all__ = ["AppProvidersResponse"] + + +class AppProvidersResponse(BaseModel): + default: Dict[str, str] + + providers: List[Provider] diff --git a/src/opencode_ai/types/assistant_message.py b/src/opencode_ai/types/assistant_message.py new file mode 100644 index 0000000..490f829 --- /dev/null +++ b/src/opencode_ai/types/assistant_message.py @@ -0,0 +1,82 @@ +# File generated from our OpenAPI spec by Stainless. See CONTRIBUTING.md for details. + +from typing import List, Union, Optional +from typing_extensions import Literal, Annotated, TypeAlias + +from pydantic import Field as FieldInfo + +from .._utils import PropertyInfo +from .._models import BaseModel +from .shared.unknown_error import UnknownError +from .shared.provider_auth_error import ProviderAuthError +from .shared.message_aborted_error import MessageAbortedError + +__all__ = ["AssistantMessage", "Path", "Time", "Tokens", "TokensCache", "Error", "ErrorMessageOutputLengthError"] + + +class Path(BaseModel): + cwd: str + + root: str + + +class Time(BaseModel): + created: float + + completed: Optional[float] = None + + +class TokensCache(BaseModel): + read: float + + write: float + + +class Tokens(BaseModel): + cache: TokensCache + + input: float + + output: float + + reasoning: float + + +class ErrorMessageOutputLengthError(BaseModel): + data: object + + name: Literal["MessageOutputLengthError"] + + +Error: TypeAlias = Annotated[ + Union[ProviderAuthError, UnknownError, ErrorMessageOutputLengthError, MessageAbortedError], + PropertyInfo(discriminator="name"), +] + + +class AssistantMessage(BaseModel): + id: str + + cost: float + + mode: str + + api_model_id: str = FieldInfo(alias="modelID") + + path: Path + + provider_id: str = FieldInfo(alias="providerID") + + role: Literal["assistant"] + + session_id: str = FieldInfo(alias="sessionID") + + system: List[str] + + time: Time + + tokens: Tokens + + error: Optional[Error] = None + + summary: Optional[bool] = None diff --git a/src/opencode_ai/types/command.py b/src/opencode_ai/types/command.py new file mode 100644 index 0000000..116ab0b --- /dev/null +++ b/src/opencode_ai/types/command.py @@ -0,0 +1,19 @@ +# File generated from our OpenAPI spec by Stainless. See CONTRIBUTING.md for details. + +from typing import Optional + +from .._models import BaseModel + +__all__ = ["Command"] + + +class Command(BaseModel): + name: str + + template: str + + agent: Optional[str] = None + + description: Optional[str] = None + + model: Optional[str] = None diff --git a/src/opencode_ai/types/command_list_params.py b/src/opencode_ai/types/command_list_params.py new file mode 100644 index 0000000..96da71f --- /dev/null +++ b/src/opencode_ai/types/command_list_params.py @@ -0,0 +1,11 @@ +# File generated from our OpenAPI spec by Stainless. See CONTRIBUTING.md for details. + +from __future__ import annotations + +from typing_extensions import TypedDict + +__all__ = ["CommandListParams"] + + +class CommandListParams(TypedDict, total=False): + directory: str diff --git a/src/opencode_ai/types/command_list_response.py b/src/opencode_ai/types/command_list_response.py new file mode 100644 index 0000000..2b89a1e --- /dev/null +++ b/src/opencode_ai/types/command_list_response.py @@ -0,0 +1,10 @@ +# File generated from our OpenAPI spec by Stainless. See CONTRIBUTING.md for details. + +from typing import List +from typing_extensions import TypeAlias + +from .command import Command + +__all__ = ["CommandListResponse"] + +CommandListResponse: TypeAlias = List[Command] diff --git a/src/opencode_ai/types/config.py b/src/opencode_ai/types/config.py new file mode 100644 index 0000000..761532b --- /dev/null +++ b/src/opencode_ai/types/config.py @@ -0,0 +1,557 @@ +# File generated from our OpenAPI spec by Stainless. See CONTRIBUTING.md for details. + +from typing import TYPE_CHECKING, Dict, List, Union, Optional +from typing_extensions import Literal, Annotated, TypeAlias + +from pydantic import Field as FieldInfo + +from .._utils import PropertyInfo +from .._models import BaseModel +from .keybinds_config import KeybindsConfig +from .mcp_local_config import McpLocalConfig +from .mcp_remote_config import McpRemoteConfig + +__all__ = [ + "Config", + "Agent", + "AgentBuild", + "AgentBuildPermission", + "AgentGeneral", + "AgentGeneralPermission", + "AgentPlan", + "AgentPlanPermission", + "AgentAgentItem", + "AgentAgentItemPermission", + "Command", + "Experimental", + "ExperimentalHook", + "ExperimentalHookFileEdited", + "ExperimentalHookSessionCompleted", + "Formatter", + "Lsp", + "LspDisabled", + "LspUnionMember1", + "Mcp", + "Mode", + "ModeBuild", + "ModeBuildPermission", + "ModePlan", + "ModePlanPermission", + "ModeModeItem", + "ModeModeItemPermission", + "Permission", + "Provider", + "ProviderModels", + "ProviderModelsCost", + "ProviderModelsLimit", + "ProviderOptions", + "Tui", +] + + +class AgentBuildPermission(BaseModel): + bash: Union[Literal["ask", "allow", "deny"], Dict[str, Literal["ask", "allow", "deny"]], None] = None + + edit: Optional[Literal["ask", "allow", "deny"]] = None + + webfetch: Optional[Literal["ask", "allow", "deny"]] = None + + +class AgentBuild(BaseModel): + description: Optional[str] = None + """Description of when to use the agent""" + + disable: Optional[bool] = None + + mode: Optional[Literal["subagent", "primary", "all"]] = None + + model: Optional[str] = None + + permission: Optional[AgentBuildPermission] = None + + prompt: Optional[str] = None + + temperature: Optional[float] = None + + tools: Optional[Dict[str, bool]] = None + + top_p: Optional[float] = None + + __pydantic_extra__: Dict[str, object] = FieldInfo(init=False) # pyright: ignore[reportIncompatibleVariableOverride] + if TYPE_CHECKING: + # Stub to indicate that arbitrary properties are accepted. + # To access properties that are not valid identifiers you can use `getattr`, e.g. + # `getattr(obj, '$type')` + def __getattr__(self, attr: str) -> object: ... + + +class AgentGeneralPermission(BaseModel): + bash: Union[Literal["ask", "allow", "deny"], Dict[str, Literal["ask", "allow", "deny"]], None] = None + + edit: Optional[Literal["ask", "allow", "deny"]] = None + + webfetch: Optional[Literal["ask", "allow", "deny"]] = None + + +class AgentGeneral(BaseModel): + description: Optional[str] = None + """Description of when to use the agent""" + + disable: Optional[bool] = None + + mode: Optional[Literal["subagent", "primary", "all"]] = None + + model: Optional[str] = None + + permission: Optional[AgentGeneralPermission] = None + + prompt: Optional[str] = None + + temperature: Optional[float] = None + + tools: Optional[Dict[str, bool]] = None + + top_p: Optional[float] = None + + __pydantic_extra__: Dict[str, object] = FieldInfo(init=False) # pyright: ignore[reportIncompatibleVariableOverride] + if TYPE_CHECKING: + # Stub to indicate that arbitrary properties are accepted. + # To access properties that are not valid identifiers you can use `getattr`, e.g. + # `getattr(obj, '$type')` + def __getattr__(self, attr: str) -> object: ... + + +class AgentPlanPermission(BaseModel): + bash: Union[Literal["ask", "allow", "deny"], Dict[str, Literal["ask", "allow", "deny"]], None] = None + + edit: Optional[Literal["ask", "allow", "deny"]] = None + + webfetch: Optional[Literal["ask", "allow", "deny"]] = None + + +class AgentPlan(BaseModel): + description: Optional[str] = None + """Description of when to use the agent""" + + disable: Optional[bool] = None + + mode: Optional[Literal["subagent", "primary", "all"]] = None + + model: Optional[str] = None + + permission: Optional[AgentPlanPermission] = None + + prompt: Optional[str] = None + + temperature: Optional[float] = None + + tools: Optional[Dict[str, bool]] = None + + top_p: Optional[float] = None + + __pydantic_extra__: Dict[str, object] = FieldInfo(init=False) # pyright: ignore[reportIncompatibleVariableOverride] + if TYPE_CHECKING: + # Stub to indicate that arbitrary properties are accepted. + # To access properties that are not valid identifiers you can use `getattr`, e.g. + # `getattr(obj, '$type')` + def __getattr__(self, attr: str) -> object: ... + + +class AgentAgentItemPermission(BaseModel): + bash: Union[Literal["ask", "allow", "deny"], Dict[str, Literal["ask", "allow", "deny"]], None] = None + + edit: Optional[Literal["ask", "allow", "deny"]] = None + + webfetch: Optional[Literal["ask", "allow", "deny"]] = None + + +class AgentAgentItem(BaseModel): + description: Optional[str] = None + """Description of when to use the agent""" + + disable: Optional[bool] = None + + mode: Optional[Literal["subagent", "primary", "all"]] = None + + model: Optional[str] = None + + permission: Optional[AgentAgentItemPermission] = None + + prompt: Optional[str] = None + + temperature: Optional[float] = None + + tools: Optional[Dict[str, bool]] = None + + top_p: Optional[float] = None + + __pydantic_extra__: Dict[str, object] = FieldInfo(init=False) # pyright: ignore[reportIncompatibleVariableOverride] + if TYPE_CHECKING: + # Stub to indicate that arbitrary properties are accepted. + # To access properties that are not valid identifiers you can use `getattr`, e.g. + # `getattr(obj, '$type')` + def __getattr__(self, attr: str) -> object: ... + + +class Agent(BaseModel): + build: Optional[AgentBuild] = None + + general: Optional[AgentGeneral] = None + + plan: Optional[AgentPlan] = None + + __pydantic_extra__: Dict[str, AgentAgentItem] = FieldInfo(init=False) # pyright: ignore[reportIncompatibleVariableOverride] + if TYPE_CHECKING: + # Stub to indicate that arbitrary properties are accepted. + # To access properties that are not valid identifiers you can use `getattr`, e.g. + # `getattr(obj, '$type')` + def __getattr__(self, attr: str) -> AgentAgentItem: ... + + +class Command(BaseModel): + template: str + + agent: Optional[str] = None + + description: Optional[str] = None + + model: Optional[str] = None + + +class ExperimentalHookFileEdited(BaseModel): + command: List[str] + + environment: Optional[Dict[str, str]] = None + + +class ExperimentalHookSessionCompleted(BaseModel): + command: List[str] + + environment: Optional[Dict[str, str]] = None + + +class ExperimentalHook(BaseModel): + file_edited: Optional[Dict[str, List[ExperimentalHookFileEdited]]] = None + + session_completed: Optional[List[ExperimentalHookSessionCompleted]] = None + + +class Experimental(BaseModel): + hook: Optional[ExperimentalHook] = None + + +class Formatter(BaseModel): + command: Optional[List[str]] = None + + disabled: Optional[bool] = None + + environment: Optional[Dict[str, str]] = None + + extensions: Optional[List[str]] = None + + +class LspDisabled(BaseModel): + disabled: Literal[True] + + +class LspUnionMember1(BaseModel): + command: List[str] + + disabled: Optional[bool] = None + + env: Optional[Dict[str, str]] = None + + extensions: Optional[List[str]] = None + + initialization: Optional[Dict[str, object]] = None + + +Lsp: TypeAlias = Union[LspDisabled, LspUnionMember1] + +Mcp: TypeAlias = Annotated[Union[McpLocalConfig, McpRemoteConfig], PropertyInfo(discriminator="type")] + + +class ModeBuildPermission(BaseModel): + bash: Union[Literal["ask", "allow", "deny"], Dict[str, Literal["ask", "allow", "deny"]], None] = None + + edit: Optional[Literal["ask", "allow", "deny"]] = None + + webfetch: Optional[Literal["ask", "allow", "deny"]] = None + + +class ModeBuild(BaseModel): + description: Optional[str] = None + """Description of when to use the agent""" + + disable: Optional[bool] = None + + mode: Optional[Literal["subagent", "primary", "all"]] = None + + model: Optional[str] = None + + permission: Optional[ModeBuildPermission] = None + + prompt: Optional[str] = None + + temperature: Optional[float] = None + + tools: Optional[Dict[str, bool]] = None + + top_p: Optional[float] = None + + __pydantic_extra__: Dict[str, object] = FieldInfo(init=False) # pyright: ignore[reportIncompatibleVariableOverride] + if TYPE_CHECKING: + # Stub to indicate that arbitrary properties are accepted. + # To access properties that are not valid identifiers you can use `getattr`, e.g. + # `getattr(obj, '$type')` + def __getattr__(self, attr: str) -> object: ... + + +class ModePlanPermission(BaseModel): + bash: Union[Literal["ask", "allow", "deny"], Dict[str, Literal["ask", "allow", "deny"]], None] = None + + edit: Optional[Literal["ask", "allow", "deny"]] = None + + webfetch: Optional[Literal["ask", "allow", "deny"]] = None + + +class ModePlan(BaseModel): + description: Optional[str] = None + """Description of when to use the agent""" + + disable: Optional[bool] = None + + mode: Optional[Literal["subagent", "primary", "all"]] = None + + model: Optional[str] = None + + permission: Optional[ModePlanPermission] = None + + prompt: Optional[str] = None + + temperature: Optional[float] = None + + tools: Optional[Dict[str, bool]] = None + + top_p: Optional[float] = None + + __pydantic_extra__: Dict[str, object] = FieldInfo(init=False) # pyright: ignore[reportIncompatibleVariableOverride] + if TYPE_CHECKING: + # Stub to indicate that arbitrary properties are accepted. + # To access properties that are not valid identifiers you can use `getattr`, e.g. + # `getattr(obj, '$type')` + def __getattr__(self, attr: str) -> object: ... + + +class ModeModeItemPermission(BaseModel): + bash: Union[Literal["ask", "allow", "deny"], Dict[str, Literal["ask", "allow", "deny"]], None] = None + + edit: Optional[Literal["ask", "allow", "deny"]] = None + + webfetch: Optional[Literal["ask", "allow", "deny"]] = None + + +class ModeModeItem(BaseModel): + description: Optional[str] = None + """Description of when to use the agent""" + + disable: Optional[bool] = None + + mode: Optional[Literal["subagent", "primary", "all"]] = None + + model: Optional[str] = None + + permission: Optional[ModeModeItemPermission] = None + + prompt: Optional[str] = None + + temperature: Optional[float] = None + + tools: Optional[Dict[str, bool]] = None + + top_p: Optional[float] = None + + __pydantic_extra__: Dict[str, object] = FieldInfo(init=False) # pyright: ignore[reportIncompatibleVariableOverride] + if TYPE_CHECKING: + # Stub to indicate that arbitrary properties are accepted. + # To access properties that are not valid identifiers you can use `getattr`, e.g. + # `getattr(obj, '$type')` + def __getattr__(self, attr: str) -> object: ... + + +class Mode(BaseModel): + build: Optional[ModeBuild] = None + + plan: Optional[ModePlan] = None + + __pydantic_extra__: Dict[str, ModeModeItem] = FieldInfo(init=False) # pyright: ignore[reportIncompatibleVariableOverride] + if TYPE_CHECKING: + # Stub to indicate that arbitrary properties are accepted. + # To access properties that are not valid identifiers you can use `getattr`, e.g. + # `getattr(obj, '$type')` + def __getattr__(self, attr: str) -> ModeModeItem: ... + + +class Permission(BaseModel): + bash: Union[Literal["ask", "allow", "deny"], Dict[str, Literal["ask", "allow", "deny"]], None] = None + + edit: Optional[Literal["ask", "allow", "deny"]] = None + + webfetch: Optional[Literal["ask", "allow", "deny"]] = None + + +class ProviderModelsCost(BaseModel): + input: float + + output: float + + cache_read: Optional[float] = None + + cache_write: Optional[float] = None + + +class ProviderModelsLimit(BaseModel): + context: float + + output: float + + +class ProviderModels(BaseModel): + id: Optional[str] = None + + attachment: Optional[bool] = None + + cost: Optional[ProviderModelsCost] = None + + limit: Optional[ProviderModelsLimit] = None + + name: Optional[str] = None + + options: Optional[Dict[str, object]] = None + + reasoning: Optional[bool] = None + + release_date: Optional[str] = None + + temperature: Optional[bool] = None + + tool_call: Optional[bool] = None + + +class ProviderOptions(BaseModel): + api_key: Optional[str] = FieldInfo(alias="apiKey", default=None) + + base_url: Optional[str] = FieldInfo(alias="baseURL", default=None) + + timeout: Union[int, bool, None] = None + """Timeout in milliseconds for requests to this provider. + + Default is 300000 (5 minutes). Set to false to disable timeout. + """ + + __pydantic_extra__: Dict[str, object] = FieldInfo(init=False) # pyright: ignore[reportIncompatibleVariableOverride] + if TYPE_CHECKING: + # Stub to indicate that arbitrary properties are accepted. + # To access properties that are not valid identifiers you can use `getattr`, e.g. + # `getattr(obj, '$type')` + def __getattr__(self, attr: str) -> object: ... + + +class Provider(BaseModel): + id: Optional[str] = None + + api: Optional[str] = None + + env: Optional[List[str]] = None + + models: Optional[Dict[str, ProviderModels]] = None + + name: Optional[str] = None + + npm: Optional[str] = None + + options: Optional[ProviderOptions] = None + + +class Tui(BaseModel): + scroll_speed: float + """TUI scroll speed""" + + +class Config(BaseModel): + schema_: Optional[str] = FieldInfo(alias="$schema", default=None) + """JSON schema reference for configuration validation""" + + agent: Optional[Agent] = None + """Agent configuration, see https://opencode.ai/docs/agent""" + + autoshare: Optional[bool] = None + """@deprecated Use 'share' field instead. + + Share newly created sessions automatically + """ + + autoupdate: Optional[bool] = None + """Automatically update to the latest version""" + + command: Optional[Dict[str, Command]] = None + """Command configuration, see https://opencode.ai/docs/commands""" + + disabled_providers: Optional[List[str]] = None + """Disable providers that are loaded automatically""" + + experimental: Optional[Experimental] = None + + formatter: Optional[Dict[str, Formatter]] = None + + instructions: Optional[List[str]] = None + """Additional instruction files or patterns to include""" + + keybinds: Optional[KeybindsConfig] = None + """Custom keybind configurations""" + + layout: Optional[Literal["auto", "stretch"]] = None + """@deprecated Always uses stretch layout.""" + + lsp: Optional[Dict[str, Lsp]] = None + + mcp: Optional[Dict[str, Mcp]] = None + """MCP (Model Context Protocol) server configurations""" + + mode: Optional[Mode] = None + """@deprecated Use `agent` field instead.""" + + model: Optional[str] = None + """Model to use in the format of provider/model, eg anthropic/claude-2""" + + permission: Optional[Permission] = None + + plugin: Optional[List[str]] = None + + provider: Optional[Dict[str, Provider]] = None + """Custom provider configurations and model overrides""" + + share: Optional[Literal["manual", "auto", "disabled"]] = None + """ + Control sharing behavior:'manual' allows manual sharing via commands, 'auto' + enables automatic sharing, 'disabled' disables all sharing + """ + + small_model: Optional[str] = None + """ + Small model to use for tasks like title generation in the format of + provider/model + """ + + snapshot: Optional[bool] = None + + theme: Optional[str] = None + """Theme name to use for the interface""" + + tools: Optional[Dict[str, bool]] = None + + tui: Optional[Tui] = None + """TUI specific settings""" + + username: Optional[str] = None + """Custom username to display in conversations instead of system username""" diff --git a/src/opencode_ai/types/config_get_params.py b/src/opencode_ai/types/config_get_params.py new file mode 100644 index 0000000..338d65c --- /dev/null +++ b/src/opencode_ai/types/config_get_params.py @@ -0,0 +1,11 @@ +# File generated from our OpenAPI spec by Stainless. See CONTRIBUTING.md for details. + +from __future__ import annotations + +from typing_extensions import TypedDict + +__all__ = ["ConfigGetParams"] + + +class ConfigGetParams(TypedDict, total=False): + directory: str diff --git a/src/opencode_ai/types/event_list_params.py b/src/opencode_ai/types/event_list_params.py new file mode 100644 index 0000000..988815e --- /dev/null +++ b/src/opencode_ai/types/event_list_params.py @@ -0,0 +1,11 @@ +# File generated from our OpenAPI spec by Stainless. See CONTRIBUTING.md for details. + +from __future__ import annotations + +from typing_extensions import TypedDict + +__all__ = ["EventListParams"] + + +class EventListParams(TypedDict, total=False): + directory: str diff --git a/src/opencode_ai/types/event_list_response.py b/src/opencode_ai/types/event_list_response.py new file mode 100644 index 0000000..2df9d35 --- /dev/null +++ b/src/opencode_ai/types/event_list_response.py @@ -0,0 +1,229 @@ +# File generated from our OpenAPI spec by Stainless. See CONTRIBUTING.md for details. + +from typing import Union, Optional +from typing_extensions import Literal, Annotated, TypeAlias + +from pydantic import Field as FieldInfo + +from .part import Part +from .._utils import PropertyInfo +from .message import Message +from .._models import BaseModel +from .session.session import Session +from .session.permission import Permission +from .shared.unknown_error import UnknownError +from .shared.provider_auth_error import ProviderAuthError +from .shared.message_aborted_error import MessageAbortedError + +__all__ = [ + "EventListResponse", + "EventInstallationUpdated", + "EventInstallationUpdatedProperties", + "EventLspClientDiagnostics", + "EventLspClientDiagnosticsProperties", + "EventMessageUpdated", + "EventMessageUpdatedProperties", + "EventMessageRemoved", + "EventMessageRemovedProperties", + "EventMessagePartUpdated", + "EventMessagePartUpdatedProperties", + "EventMessagePartRemoved", + "EventMessagePartRemovedProperties", + "EventPermissionUpdated", + "EventPermissionReplied", + "EventPermissionRepliedProperties", + "EventFileEdited", + "EventFileEditedProperties", + "EventSessionUpdated", + "EventSessionUpdatedProperties", + "EventSessionDeleted", + "EventSessionDeletedProperties", + "EventSessionIdle", + "EventSessionIdleProperties", + "EventSessionError", + "EventSessionErrorProperties", + "EventSessionErrorPropertiesError", + "EventSessionErrorPropertiesErrorMessageOutputLengthError", + "EventServerConnected", +] + + +class EventInstallationUpdatedProperties(BaseModel): + version: str + + +class EventInstallationUpdated(BaseModel): + properties: EventInstallationUpdatedProperties + + type: Literal["installation.updated"] + + +class EventLspClientDiagnosticsProperties(BaseModel): + path: str + + server_id: str = FieldInfo(alias="serverID") + + +class EventLspClientDiagnostics(BaseModel): + properties: EventLspClientDiagnosticsProperties + + type: Literal["lsp.client.diagnostics"] + + +class EventMessageUpdatedProperties(BaseModel): + info: Message + + +class EventMessageUpdated(BaseModel): + properties: EventMessageUpdatedProperties + + type: Literal["message.updated"] + + +class EventMessageRemovedProperties(BaseModel): + message_id: str = FieldInfo(alias="messageID") + + session_id: str = FieldInfo(alias="sessionID") + + +class EventMessageRemoved(BaseModel): + properties: EventMessageRemovedProperties + + type: Literal["message.removed"] + + +class EventMessagePartUpdatedProperties(BaseModel): + part: Part + + +class EventMessagePartUpdated(BaseModel): + properties: EventMessagePartUpdatedProperties + + type: Literal["message.part.updated"] + + +class EventMessagePartRemovedProperties(BaseModel): + message_id: str = FieldInfo(alias="messageID") + + part_id: str = FieldInfo(alias="partID") + + session_id: str = FieldInfo(alias="sessionID") + + +class EventMessagePartRemoved(BaseModel): + properties: EventMessagePartRemovedProperties + + type: Literal["message.part.removed"] + + +class EventPermissionUpdated(BaseModel): + properties: Permission + + type: Literal["permission.updated"] + + +class EventPermissionRepliedProperties(BaseModel): + permission_id: str = FieldInfo(alias="permissionID") + + response: str + + session_id: str = FieldInfo(alias="sessionID") + + +class EventPermissionReplied(BaseModel): + properties: EventPermissionRepliedProperties + + type: Literal["permission.replied"] + + +class EventFileEditedProperties(BaseModel): + file: str + + +class EventFileEdited(BaseModel): + properties: EventFileEditedProperties + + type: Literal["file.edited"] + + +class EventSessionUpdatedProperties(BaseModel): + info: Session + + +class EventSessionUpdated(BaseModel): + properties: EventSessionUpdatedProperties + + type: Literal["session.updated"] + + +class EventSessionDeletedProperties(BaseModel): + info: Session + + +class EventSessionDeleted(BaseModel): + properties: EventSessionDeletedProperties + + type: Literal["session.deleted"] + + +class EventSessionIdleProperties(BaseModel): + session_id: str = FieldInfo(alias="sessionID") + + +class EventSessionIdle(BaseModel): + properties: EventSessionIdleProperties + + type: Literal["session.idle"] + + +class EventSessionErrorPropertiesErrorMessageOutputLengthError(BaseModel): + data: object + + name: Literal["MessageOutputLengthError"] + + +EventSessionErrorPropertiesError: TypeAlias = Annotated[ + Union[ + ProviderAuthError, UnknownError, EventSessionErrorPropertiesErrorMessageOutputLengthError, MessageAbortedError + ], + PropertyInfo(discriminator="name"), +] + + +class EventSessionErrorProperties(BaseModel): + error: Optional[EventSessionErrorPropertiesError] = None + + session_id: Optional[str] = FieldInfo(alias="sessionID", default=None) + + +class EventSessionError(BaseModel): + properties: EventSessionErrorProperties + + type: Literal["session.error"] + + +class EventServerConnected(BaseModel): + properties: object + + type: Literal["server.connected"] + + +EventListResponse: TypeAlias = Annotated[ + Union[ + EventInstallationUpdated, + EventLspClientDiagnostics, + EventMessageUpdated, + EventMessageRemoved, + EventMessagePartUpdated, + EventMessagePartRemoved, + EventPermissionUpdated, + EventPermissionReplied, + EventFileEdited, + EventSessionUpdated, + EventSessionDeleted, + EventSessionIdle, + EventSessionError, + EventServerConnected, + ], + PropertyInfo(discriminator="type"), +] diff --git a/src/opencode_ai/types/file.py b/src/opencode_ai/types/file.py new file mode 100644 index 0000000..f156d68 --- /dev/null +++ b/src/opencode_ai/types/file.py @@ -0,0 +1,17 @@ +# File generated from our OpenAPI spec by Stainless. See CONTRIBUTING.md for details. + +from typing_extensions import Literal + +from .._models import BaseModel + +__all__ = ["File"] + + +class File(BaseModel): + added: int + + path: str + + removed: int + + status: Literal["added", "deleted", "modified"] diff --git a/src/opencode_ai/types/file_list_params.py b/src/opencode_ai/types/file_list_params.py new file mode 100644 index 0000000..7fc1cbf --- /dev/null +++ b/src/opencode_ai/types/file_list_params.py @@ -0,0 +1,13 @@ +# File generated from our OpenAPI spec by Stainless. See CONTRIBUTING.md for details. + +from __future__ import annotations + +from typing_extensions import Required, TypedDict + +__all__ = ["FileListParams"] + + +class FileListParams(TypedDict, total=False): + path: Required[str] + + directory: str diff --git a/src/opencode_ai/types/file_list_response.py b/src/opencode_ai/types/file_list_response.py new file mode 100644 index 0000000..30e17d8 --- /dev/null +++ b/src/opencode_ai/types/file_list_response.py @@ -0,0 +1,10 @@ +# File generated from our OpenAPI spec by Stainless. See CONTRIBUTING.md for details. + +from typing import List +from typing_extensions import TypeAlias + +from .file_node import FileNode + +__all__ = ["FileListResponse"] + +FileListResponse: TypeAlias = List[FileNode] diff --git a/src/opencode_ai/types/file_node.py b/src/opencode_ai/types/file_node.py new file mode 100644 index 0000000..56b8ded --- /dev/null +++ b/src/opencode_ai/types/file_node.py @@ -0,0 +1,17 @@ +# File generated from our OpenAPI spec by Stainless. See CONTRIBUTING.md for details. + +from typing_extensions import Literal + +from .._models import BaseModel + +__all__ = ["FileNode"] + + +class FileNode(BaseModel): + ignored: bool + + name: str + + path: str + + type: Literal["file", "directory"] diff --git a/src/opencode_ai/types/file_part.py b/src/opencode_ai/types/file_part.py new file mode 100644 index 0000000..42851c9 --- /dev/null +++ b/src/opencode_ai/types/file_part.py @@ -0,0 +1,29 @@ +# File generated from our OpenAPI spec by Stainless. See CONTRIBUTING.md for details. + +from typing import Optional +from typing_extensions import Literal + +from pydantic import Field as FieldInfo + +from .._models import BaseModel +from .file_part_source import FilePartSource + +__all__ = ["FilePart"] + + +class FilePart(BaseModel): + id: str + + message_id: str = FieldInfo(alias="messageID") + + mime: str + + session_id: str = FieldInfo(alias="sessionID") + + type: Literal["file"] + + url: str + + filename: Optional[str] = None + + source: Optional[FilePartSource] = None diff --git a/src/opencode_ai/types/file_part_input_param.py b/src/opencode_ai/types/file_part_input_param.py new file mode 100644 index 0000000..96325e0 --- /dev/null +++ b/src/opencode_ai/types/file_part_input_param.py @@ -0,0 +1,23 @@ +# File generated from our OpenAPI spec by Stainless. See CONTRIBUTING.md for details. + +from __future__ import annotations + +from typing_extensions import Literal, Required, TypedDict + +from .file_part_source_param import FilePartSourceParam + +__all__ = ["FilePartInputParam"] + + +class FilePartInputParam(TypedDict, total=False): + mime: Required[str] + + type: Required[Literal["file"]] + + url: Required[str] + + id: str + + filename: str + + source: FilePartSourceParam diff --git a/src/opencode_ai/types/file_part_source.py b/src/opencode_ai/types/file_part_source.py new file mode 100644 index 0000000..25cb97d --- /dev/null +++ b/src/opencode_ai/types/file_part_source.py @@ -0,0 +1,12 @@ +# File generated from our OpenAPI spec by Stainless. See CONTRIBUTING.md for details. + +from typing import Union +from typing_extensions import Annotated, TypeAlias + +from .._utils import PropertyInfo +from .file_source import FileSource +from .symbol_source import SymbolSource + +__all__ = ["FilePartSource"] + +FilePartSource: TypeAlias = Annotated[Union[FileSource, SymbolSource], PropertyInfo(discriminator="type")] diff --git a/src/opencode_ai/types/file_part_source_param.py b/src/opencode_ai/types/file_part_source_param.py new file mode 100644 index 0000000..7b5bcbb --- /dev/null +++ b/src/opencode_ai/types/file_part_source_param.py @@ -0,0 +1,13 @@ +# File generated from our OpenAPI spec by Stainless. See CONTRIBUTING.md for details. + +from __future__ import annotations + +from typing import Union +from typing_extensions import TypeAlias + +from .file_source_param import FileSourceParam +from .symbol_source_param import SymbolSourceParam + +__all__ = ["FilePartSourceParam"] + +FilePartSourceParam: TypeAlias = Union[FileSourceParam, SymbolSourceParam] diff --git a/src/opencode_ai/types/file_part_source_text.py b/src/opencode_ai/types/file_part_source_text.py new file mode 100644 index 0000000..95af821 --- /dev/null +++ b/src/opencode_ai/types/file_part_source_text.py @@ -0,0 +1,13 @@ +# File generated from our OpenAPI spec by Stainless. See CONTRIBUTING.md for details. + +from .._models import BaseModel + +__all__ = ["FilePartSourceText"] + + +class FilePartSourceText(BaseModel): + end: int + + start: int + + value: str diff --git a/src/opencode_ai/types/file_part_source_text_param.py b/src/opencode_ai/types/file_part_source_text_param.py new file mode 100644 index 0000000..40d94bc --- /dev/null +++ b/src/opencode_ai/types/file_part_source_text_param.py @@ -0,0 +1,15 @@ +# File generated from our OpenAPI spec by Stainless. See CONTRIBUTING.md for details. + +from __future__ import annotations + +from typing_extensions import Required, TypedDict + +__all__ = ["FilePartSourceTextParam"] + + +class FilePartSourceTextParam(TypedDict, total=False): + end: Required[int] + + start: Required[int] + + value: Required[str] diff --git a/src/opencode_ai/types/file_read_params.py b/src/opencode_ai/types/file_read_params.py new file mode 100644 index 0000000..087534e --- /dev/null +++ b/src/opencode_ai/types/file_read_params.py @@ -0,0 +1,13 @@ +# File generated from our OpenAPI spec by Stainless. See CONTRIBUTING.md for details. + +from __future__ import annotations + +from typing_extensions import Required, TypedDict + +__all__ = ["FileReadParams"] + + +class FileReadParams(TypedDict, total=False): + path: Required[str] + + directory: str diff --git a/src/opencode_ai/types/file_read_response.py b/src/opencode_ai/types/file_read_response.py new file mode 100644 index 0000000..5392a07 --- /dev/null +++ b/src/opencode_ai/types/file_read_response.py @@ -0,0 +1,13 @@ +# File generated from our OpenAPI spec by Stainless. See CONTRIBUTING.md for details. + +from typing_extensions import Literal + +from .._models import BaseModel + +__all__ = ["FileReadResponse"] + + +class FileReadResponse(BaseModel): + content: str + + type: Literal["raw", "patch"] diff --git a/src/opencode_ai/types/file_source.py b/src/opencode_ai/types/file_source.py new file mode 100644 index 0000000..fd5f328 --- /dev/null +++ b/src/opencode_ai/types/file_source.py @@ -0,0 +1,16 @@ +# File generated from our OpenAPI spec by Stainless. See CONTRIBUTING.md for details. + +from typing_extensions import Literal + +from .._models import BaseModel +from .file_part_source_text import FilePartSourceText + +__all__ = ["FileSource"] + + +class FileSource(BaseModel): + path: str + + text: FilePartSourceText + + type: Literal["file"] diff --git a/src/opencode_ai/types/file_source_param.py b/src/opencode_ai/types/file_source_param.py new file mode 100644 index 0000000..caf14a5 --- /dev/null +++ b/src/opencode_ai/types/file_source_param.py @@ -0,0 +1,17 @@ +# File generated from our OpenAPI spec by Stainless. See CONTRIBUTING.md for details. + +from __future__ import annotations + +from typing_extensions import Literal, Required, TypedDict + +from .file_part_source_text_param import FilePartSourceTextParam + +__all__ = ["FileSourceParam"] + + +class FileSourceParam(TypedDict, total=False): + path: Required[str] + + text: Required[FilePartSourceTextParam] + + type: Required[Literal["file"]] diff --git a/src/opencode_ai/types/file_status_params.py b/src/opencode_ai/types/file_status_params.py new file mode 100644 index 0000000..d9d0922 --- /dev/null +++ b/src/opencode_ai/types/file_status_params.py @@ -0,0 +1,11 @@ +# File generated from our OpenAPI spec by Stainless. See CONTRIBUTING.md for details. + +from __future__ import annotations + +from typing_extensions import TypedDict + +__all__ = ["FileStatusParams"] + + +class FileStatusParams(TypedDict, total=False): + directory: str diff --git a/src/opencode_ai/types/file_status_response.py b/src/opencode_ai/types/file_status_response.py new file mode 100644 index 0000000..34a602b --- /dev/null +++ b/src/opencode_ai/types/file_status_response.py @@ -0,0 +1,10 @@ +# File generated from our OpenAPI spec by Stainless. See CONTRIBUTING.md for details. + +from typing import List +from typing_extensions import TypeAlias + +from .file import File + +__all__ = ["FileStatusResponse"] + +FileStatusResponse: TypeAlias = List[File] diff --git a/src/opencode_ai/types/find_files_params.py b/src/opencode_ai/types/find_files_params.py new file mode 100644 index 0000000..5b23a52 --- /dev/null +++ b/src/opencode_ai/types/find_files_params.py @@ -0,0 +1,13 @@ +# File generated from our OpenAPI spec by Stainless. See CONTRIBUTING.md for details. + +from __future__ import annotations + +from typing_extensions import Required, TypedDict + +__all__ = ["FindFilesParams"] + + +class FindFilesParams(TypedDict, total=False): + query: Required[str] + + directory: str diff --git a/src/opencode_ai/types/find_files_response.py b/src/opencode_ai/types/find_files_response.py new file mode 100644 index 0000000..2b408de --- /dev/null +++ b/src/opencode_ai/types/find_files_response.py @@ -0,0 +1,8 @@ +# File generated from our OpenAPI spec by Stainless. See CONTRIBUTING.md for details. + +from typing import List +from typing_extensions import TypeAlias + +__all__ = ["FindFilesResponse"] + +FindFilesResponse: TypeAlias = List[str] diff --git a/src/opencode_ai/types/find_symbols_params.py b/src/opencode_ai/types/find_symbols_params.py new file mode 100644 index 0000000..4cd4e34 --- /dev/null +++ b/src/opencode_ai/types/find_symbols_params.py @@ -0,0 +1,13 @@ +# File generated from our OpenAPI spec by Stainless. See CONTRIBUTING.md for details. + +from __future__ import annotations + +from typing_extensions import Required, TypedDict + +__all__ = ["FindSymbolsParams"] + + +class FindSymbolsParams(TypedDict, total=False): + query: Required[str] + + directory: str diff --git a/src/opencode_ai/types/find_symbols_response.py b/src/opencode_ai/types/find_symbols_response.py new file mode 100644 index 0000000..a0bc12a --- /dev/null +++ b/src/opencode_ai/types/find_symbols_response.py @@ -0,0 +1,10 @@ +# File generated from our OpenAPI spec by Stainless. See CONTRIBUTING.md for details. + +from typing import List +from typing_extensions import TypeAlias + +from .symbol import Symbol + +__all__ = ["FindSymbolsResponse"] + +FindSymbolsResponse: TypeAlias = List[Symbol] diff --git a/src/opencode_ai/types/find_text_params.py b/src/opencode_ai/types/find_text_params.py new file mode 100644 index 0000000..c1c31df --- /dev/null +++ b/src/opencode_ai/types/find_text_params.py @@ -0,0 +1,13 @@ +# File generated from our OpenAPI spec by Stainless. See CONTRIBUTING.md for details. + +from __future__ import annotations + +from typing_extensions import Required, TypedDict + +__all__ = ["FindTextParams"] + + +class FindTextParams(TypedDict, total=False): + pattern: Required[str] + + directory: str diff --git a/src/opencode_ai/types/find_text_response.py b/src/opencode_ai/types/find_text_response.py new file mode 100644 index 0000000..4557834 --- /dev/null +++ b/src/opencode_ai/types/find_text_response.py @@ -0,0 +1,50 @@ +# File generated from our OpenAPI spec by Stainless. See CONTRIBUTING.md for details. + +from typing import List +from typing_extensions import TypeAlias + +from .._models import BaseModel + +__all__ = [ + "FindTextResponse", + "FindTextResponseItem", + "FindTextResponseItemLines", + "FindTextResponseItemPath", + "FindTextResponseItemSubmatch", + "FindTextResponseItemSubmatchMatch", +] + + +class FindTextResponseItemLines(BaseModel): + text: str + + +class FindTextResponseItemPath(BaseModel): + text: str + + +class FindTextResponseItemSubmatchMatch(BaseModel): + text: str + + +class FindTextResponseItemSubmatch(BaseModel): + end: float + + match: FindTextResponseItemSubmatchMatch + + start: float + + +class FindTextResponseItem(BaseModel): + absolute_offset: float + + line_number: float + + lines: FindTextResponseItemLines + + path: FindTextResponseItemPath + + submatches: List[FindTextResponseItemSubmatch] + + +FindTextResponse: TypeAlias = List[FindTextResponseItem] diff --git a/src/opencode_ai/types/keybinds_config.py b/src/opencode_ai/types/keybinds_config.py new file mode 100644 index 0000000..452fa2b --- /dev/null +++ b/src/opencode_ai/types/keybinds_config.py @@ -0,0 +1,156 @@ +# File generated from our OpenAPI spec by Stainless. See CONTRIBUTING.md for details. + +from pydantic import Field as FieldInfo + +from .._models import BaseModel + +__all__ = ["KeybindsConfig"] + + +class KeybindsConfig(BaseModel): + agent_cycle: str + """Next agent""" + + agent_cycle_reverse: str + """Previous agent""" + + agent_list: str + """List agents""" + + app_exit: str + """Exit the application""" + + app_help: str + """Show help dialog""" + + editor_open: str + """Open external editor""" + + file_close: str + """@deprecated Close file""" + + file_diff_toggle: str + """@deprecated Split/unified diff""" + + file_list: str + """@deprecated Currently not available. List files""" + + file_search: str + """@deprecated Search file""" + + input_clear: str + """Clear input field""" + + input_newline: str + """Insert newline in input""" + + input_paste: str + """Paste from clipboard""" + + input_submit: str + """Submit input""" + + leader: str + """Leader key for keybind combinations""" + + messages_copy: str + """Copy message""" + + messages_first: str + """Navigate to first message""" + + messages_half_page_down: str + """Scroll messages down by half page""" + + messages_half_page_up: str + """Scroll messages up by half page""" + + messages_last: str + """Navigate to last message""" + + messages_layout_toggle: str + """@deprecated Toggle layout""" + + messages_next: str + """@deprecated Navigate to next message""" + + messages_page_down: str + """Scroll messages down by one page""" + + messages_page_up: str + """Scroll messages up by one page""" + + messages_previous: str + """@deprecated Navigate to previous message""" + + messages_redo: str + """Redo message""" + + messages_revert: str + """@deprecated use messages_undo. Revert message""" + + messages_undo: str + """Undo message""" + + api_model_cycle_recent: str = FieldInfo(alias="model_cycle_recent") + """Next recent model""" + + api_model_cycle_recent_reverse: str = FieldInfo(alias="model_cycle_recent_reverse") + """Previous recent model""" + + api_model_list: str = FieldInfo(alias="model_list") + """List available models""" + + project_init: str + """Create/update AGENTS.md""" + + session_child_cycle: str + """Cycle to next child session""" + + session_child_cycle_reverse: str + """Cycle to previous child session""" + + session_compact: str + """Compact the session""" + + session_export: str + """Export session to editor""" + + session_interrupt: str + """Interrupt current session""" + + session_list: str + """List all sessions""" + + session_new: str + """Create a new session""" + + session_share: str + """Share current session""" + + session_timeline: str + """Show session timeline""" + + session_unshare: str + """Unshare current session""" + + switch_agent: str + """@deprecated use agent_cycle. Next agent""" + + switch_agent_reverse: str + """@deprecated use agent_cycle_reverse. Previous agent""" + + switch_mode: str + """@deprecated use agent_cycle. Next mode""" + + switch_mode_reverse: str + """@deprecated use agent_cycle_reverse. Previous mode""" + + theme_list: str + """List available themes""" + + thinking_blocks: str + """Toggle thinking blocks""" + + tool_details: str + """Toggle tool details""" diff --git a/src/opencode_ai/types/mcp_local_config.py b/src/opencode_ai/types/mcp_local_config.py new file mode 100644 index 0000000..27118c2 --- /dev/null +++ b/src/opencode_ai/types/mcp_local_config.py @@ -0,0 +1,22 @@ +# File generated from our OpenAPI spec by Stainless. See CONTRIBUTING.md for details. + +from typing import Dict, List, Optional +from typing_extensions import Literal + +from .._models import BaseModel + +__all__ = ["McpLocalConfig"] + + +class McpLocalConfig(BaseModel): + command: List[str] + """Command and arguments to run the MCP server""" + + type: Literal["local"] + """Type of MCP server connection""" + + enabled: Optional[bool] = None + """Enable or disable the MCP server on startup""" + + environment: Optional[Dict[str, str]] = None + """Environment variables to set when running the MCP server""" diff --git a/src/opencode_ai/types/mcp_remote_config.py b/src/opencode_ai/types/mcp_remote_config.py new file mode 100644 index 0000000..6863ec7 --- /dev/null +++ b/src/opencode_ai/types/mcp_remote_config.py @@ -0,0 +1,22 @@ +# File generated from our OpenAPI spec by Stainless. See CONTRIBUTING.md for details. + +from typing import Dict, Optional +from typing_extensions import Literal + +from .._models import BaseModel + +__all__ = ["McpRemoteConfig"] + + +class McpRemoteConfig(BaseModel): + type: Literal["remote"] + """Type of MCP server connection""" + + url: str + """URL of the remote MCP server""" + + enabled: Optional[bool] = None + """Enable or disable the MCP server on startup""" + + headers: Optional[Dict[str, str]] = None + """Headers to send with the request""" diff --git a/src/opencode_ai/types/message.py b/src/opencode_ai/types/message.py new file mode 100644 index 0000000..6e27c8d --- /dev/null +++ b/src/opencode_ai/types/message.py @@ -0,0 +1,12 @@ +# File generated from our OpenAPI spec by Stainless. See CONTRIBUTING.md for details. + +from typing import Union +from typing_extensions import Annotated, TypeAlias + +from .._utils import PropertyInfo +from .user_message import UserMessage +from .assistant_message import AssistantMessage + +__all__ = ["Message"] + +Message: TypeAlias = Annotated[Union[UserMessage, AssistantMessage], PropertyInfo(discriminator="role")] diff --git a/src/opencode_ai/types/model.py b/src/opencode_ai/types/model.py new file mode 100644 index 0000000..32fc15a --- /dev/null +++ b/src/opencode_ai/types/model.py @@ -0,0 +1,45 @@ +# File generated from our OpenAPI spec by Stainless. See CONTRIBUTING.md for details. + +from typing import Dict, Optional + +from .._models import BaseModel + +__all__ = ["Model", "Cost", "Limit"] + + +class Cost(BaseModel): + input: float + + output: float + + cache_read: Optional[float] = None + + cache_write: Optional[float] = None + + +class Limit(BaseModel): + context: float + + output: float + + +class Model(BaseModel): + id: str + + attachment: bool + + cost: Cost + + limit: Limit + + name: str + + options: Dict[str, object] + + reasoning: bool + + release_date: str + + temperature: bool + + tool_call: bool diff --git a/src/opencode_ai/types/part.py b/src/opencode_ai/types/part.py new file mode 100644 index 0000000..820c1e1 --- /dev/null +++ b/src/opencode_ai/types/part.py @@ -0,0 +1,41 @@ +# File generated from our OpenAPI spec by Stainless. See CONTRIBUTING.md for details. + +from typing import List, Union +from typing_extensions import Literal, Annotated, TypeAlias + +from pydantic import Field as FieldInfo + +from .._utils import PropertyInfo +from .._models import BaseModel +from .file_part import FilePart +from .text_part import TextPart +from .tool_part import ToolPart +from .agent_part import AgentPart +from .snapshot_part import SnapshotPart +from .reasoning_part import ReasoningPart +from .step_start_part import StepStartPart +from .step_finish_part import StepFinishPart + +__all__ = ["Part", "PatchPart"] + + +class PatchPart(BaseModel): + id: str + + files: List[str] + + hash: str + + message_id: str = FieldInfo(alias="messageID") + + session_id: str = FieldInfo(alias="sessionID") + + type: Literal["patch"] + + +Part: TypeAlias = Annotated[ + Union[ + TextPart, ReasoningPart, FilePart, ToolPart, StepStartPart, StepFinishPart, SnapshotPart, PatchPart, AgentPart + ], + PropertyInfo(discriminator="type"), +] diff --git a/src/opencode_ai/types/path.py b/src/opencode_ai/types/path.py new file mode 100644 index 0000000..8885465 --- /dev/null +++ b/src/opencode_ai/types/path.py @@ -0,0 +1,15 @@ +# File generated from our OpenAPI spec by Stainless. See CONTRIBUTING.md for details. + +from .._models import BaseModel + +__all__ = ["Path"] + + +class Path(BaseModel): + config: str + + directory: str + + state: str + + worktree: str diff --git a/src/opencode_ai/types/path_get_params.py b/src/opencode_ai/types/path_get_params.py new file mode 100644 index 0000000..49bd72f --- /dev/null +++ b/src/opencode_ai/types/path_get_params.py @@ -0,0 +1,11 @@ +# File generated from our OpenAPI spec by Stainless. See CONTRIBUTING.md for details. + +from __future__ import annotations + +from typing_extensions import TypedDict + +__all__ = ["PathGetParams"] + + +class PathGetParams(TypedDict, total=False): + directory: str diff --git a/src/opencode_ai/types/project.py b/src/opencode_ai/types/project.py new file mode 100644 index 0000000..f19b103 --- /dev/null +++ b/src/opencode_ai/types/project.py @@ -0,0 +1,24 @@ +# File generated from our OpenAPI spec by Stainless. See CONTRIBUTING.md for details. + +from typing import Optional +from typing_extensions import Literal + +from .._models import BaseModel + +__all__ = ["Project", "Time"] + + +class Time(BaseModel): + created: float + + initialized: Optional[float] = None + + +class Project(BaseModel): + id: str + + time: Time + + worktree: str + + vcs: Optional[Literal["git"]] = None diff --git a/src/opencode_ai/types/project_current_params.py b/src/opencode_ai/types/project_current_params.py new file mode 100644 index 0000000..6b9a645 --- /dev/null +++ b/src/opencode_ai/types/project_current_params.py @@ -0,0 +1,11 @@ +# File generated from our OpenAPI spec by Stainless. See CONTRIBUTING.md for details. + +from __future__ import annotations + +from typing_extensions import TypedDict + +__all__ = ["ProjectCurrentParams"] + + +class ProjectCurrentParams(TypedDict, total=False): + directory: str diff --git a/src/opencode_ai/types/project_list_params.py b/src/opencode_ai/types/project_list_params.py new file mode 100644 index 0000000..c60aaca --- /dev/null +++ b/src/opencode_ai/types/project_list_params.py @@ -0,0 +1,11 @@ +# File generated from our OpenAPI spec by Stainless. See CONTRIBUTING.md for details. + +from __future__ import annotations + +from typing_extensions import TypedDict + +__all__ = ["ProjectListParams"] + + +class ProjectListParams(TypedDict, total=False): + directory: str diff --git a/src/opencode_ai/types/project_list_response.py b/src/opencode_ai/types/project_list_response.py new file mode 100644 index 0000000..2d05a23 --- /dev/null +++ b/src/opencode_ai/types/project_list_response.py @@ -0,0 +1,10 @@ +# File generated from our OpenAPI spec by Stainless. See CONTRIBUTING.md for details. + +from typing import List +from typing_extensions import TypeAlias + +from .project import Project + +__all__ = ["ProjectListResponse"] + +ProjectListResponse: TypeAlias = List[Project] diff --git a/src/opencode_ai/types/provider.py b/src/opencode_ai/types/provider.py new file mode 100644 index 0000000..ce04398 --- /dev/null +++ b/src/opencode_ai/types/provider.py @@ -0,0 +1,22 @@ +# File generated from our OpenAPI spec by Stainless. See CONTRIBUTING.md for details. + +from typing import Dict, List, Optional + +from .model import Model +from .._models import BaseModel + +__all__ = ["Provider"] + + +class Provider(BaseModel): + id: str + + env: List[str] + + models: Dict[str, Model] + + name: str + + api: Optional[str] = None + + npm: Optional[str] = None diff --git a/src/opencode_ai/types/reasoning_part.py b/src/opencode_ai/types/reasoning_part.py new file mode 100644 index 0000000..71e0052 --- /dev/null +++ b/src/opencode_ai/types/reasoning_part.py @@ -0,0 +1,32 @@ +# File generated from our OpenAPI spec by Stainless. See CONTRIBUTING.md for details. + +from typing import Dict, Optional +from typing_extensions import Literal + +from pydantic import Field as FieldInfo + +from .._models import BaseModel + +__all__ = ["ReasoningPart", "Time"] + + +class Time(BaseModel): + start: float + + end: Optional[float] = None + + +class ReasoningPart(BaseModel): + id: str + + message_id: str = FieldInfo(alias="messageID") + + session_id: str = FieldInfo(alias="sessionID") + + text: str + + time: Time + + type: Literal["reasoning"] + + metadata: Optional[Dict[str, object]] = None diff --git a/src/opencode_ai/types/session/__init__.py b/src/opencode_ai/types/session/__init__.py new file mode 100644 index 0000000..b360854 --- /dev/null +++ b/src/opencode_ai/types/session/__init__.py @@ -0,0 +1,8 @@ +# File generated from our OpenAPI spec by Stainless. See CONTRIBUTING.md for details. + +from __future__ import annotations + +from .session import Session as Session +from .permission import Permission as Permission +from .permission_respond_params import PermissionRespondParams as PermissionRespondParams +from .permission_respond_response import PermissionRespondResponse as PermissionRespondResponse diff --git a/src/opencode_ai/types/session/permission.py b/src/opencode_ai/types/session/permission.py new file mode 100644 index 0000000..88fc6cb --- /dev/null +++ b/src/opencode_ai/types/session/permission.py @@ -0,0 +1,33 @@ +# File generated from our OpenAPI spec by Stainless. See CONTRIBUTING.md for details. + +from typing import Dict, Optional + +from pydantic import Field as FieldInfo + +from ..._models import BaseModel + +__all__ = ["Permission", "Time"] + + +class Time(BaseModel): + created: float + + +class Permission(BaseModel): + id: str + + message_id: str = FieldInfo(alias="messageID") + + metadata: Dict[str, object] + + session_id: str = FieldInfo(alias="sessionID") + + time: Time + + title: str + + type: str + + call_id: Optional[str] = FieldInfo(alias="callID", default=None) + + pattern: Optional[str] = None diff --git a/src/opencode_ai/types/session/permission_respond_params.py b/src/opencode_ai/types/session/permission_respond_params.py new file mode 100644 index 0000000..8698254 --- /dev/null +++ b/src/opencode_ai/types/session/permission_respond_params.py @@ -0,0 +1,15 @@ +# File generated from our OpenAPI spec by Stainless. See CONTRIBUTING.md for details. + +from __future__ import annotations + +from typing_extensions import Literal, Required, TypedDict + +__all__ = ["PermissionRespondParams"] + + +class PermissionRespondParams(TypedDict, total=False): + id: Required[str] + + response: Required[Literal["once", "always", "reject"]] + + directory: str diff --git a/src/opencode_ai/types/session/permission_respond_response.py b/src/opencode_ai/types/session/permission_respond_response.py new file mode 100644 index 0000000..0cc1480 --- /dev/null +++ b/src/opencode_ai/types/session/permission_respond_response.py @@ -0,0 +1,7 @@ +# File generated from our OpenAPI spec by Stainless. See CONTRIBUTING.md for details. + +from typing_extensions import TypeAlias + +__all__ = ["PermissionRespondResponse"] + +PermissionRespondResponse: TypeAlias = bool diff --git a/src/opencode_ai/types/session/session.py b/src/opencode_ai/types/session/session.py new file mode 100644 index 0000000..af9fcd3 --- /dev/null +++ b/src/opencode_ai/types/session/session.py @@ -0,0 +1,49 @@ +# File generated from our OpenAPI spec by Stainless. See CONTRIBUTING.md for details. + +from typing import Optional + +from pydantic import Field as FieldInfo + +from ..._models import BaseModel + +__all__ = ["Session", "Time", "Revert", "Share"] + + +class Time(BaseModel): + created: float + + updated: float + + +class Revert(BaseModel): + message_id: str = FieldInfo(alias="messageID") + + diff: Optional[str] = None + + part_id: Optional[str] = FieldInfo(alias="partID", default=None) + + snapshot: Optional[str] = None + + +class Share(BaseModel): + url: str + + +class Session(BaseModel): + id: str + + directory: str + + project_id: str = FieldInfo(alias="projectID") + + time: Time + + title: str + + version: str + + parent_id: Optional[str] = FieldInfo(alias="parentID", default=None) + + revert: Optional[Revert] = None + + share: Optional[Share] = None diff --git a/src/opencode_ai/types/session_abort_params.py b/src/opencode_ai/types/session_abort_params.py new file mode 100644 index 0000000..15a923b --- /dev/null +++ b/src/opencode_ai/types/session_abort_params.py @@ -0,0 +1,11 @@ +# File generated from our OpenAPI spec by Stainless. See CONTRIBUTING.md for details. + +from __future__ import annotations + +from typing_extensions import TypedDict + +__all__ = ["SessionAbortParams"] + + +class SessionAbortParams(TypedDict, total=False): + directory: str diff --git a/src/opencode_ai/types/session_abort_response.py b/src/opencode_ai/types/session_abort_response.py new file mode 100644 index 0000000..544a0b2 --- /dev/null +++ b/src/opencode_ai/types/session_abort_response.py @@ -0,0 +1,7 @@ +# File generated from our OpenAPI spec by Stainless. See CONTRIBUTING.md for details. + +from typing_extensions import TypeAlias + +__all__ = ["SessionAbortResponse"] + +SessionAbortResponse: TypeAlias = bool diff --git a/src/opencode_ai/types/session_children_params.py b/src/opencode_ai/types/session_children_params.py new file mode 100644 index 0000000..49004c4 --- /dev/null +++ b/src/opencode_ai/types/session_children_params.py @@ -0,0 +1,11 @@ +# File generated from our OpenAPI spec by Stainless. See CONTRIBUTING.md for details. + +from __future__ import annotations + +from typing_extensions import TypedDict + +__all__ = ["SessionChildrenParams"] + + +class SessionChildrenParams(TypedDict, total=False): + directory: str diff --git a/src/opencode_ai/types/session_children_response.py b/src/opencode_ai/types/session_children_response.py new file mode 100644 index 0000000..975bc30 --- /dev/null +++ b/src/opencode_ai/types/session_children_response.py @@ -0,0 +1,10 @@ +# File generated from our OpenAPI spec by Stainless. See CONTRIBUTING.md for details. + +from typing import List +from typing_extensions import TypeAlias + +from .session.session import Session + +__all__ = ["SessionChildrenResponse"] + +SessionChildrenResponse: TypeAlias = List[Session] diff --git a/src/opencode_ai/types/session_command_params.py b/src/opencode_ai/types/session_command_params.py new file mode 100644 index 0000000..0ae5a37 --- /dev/null +++ b/src/opencode_ai/types/session_command_params.py @@ -0,0 +1,23 @@ +# File generated from our OpenAPI spec by Stainless. See CONTRIBUTING.md for details. + +from __future__ import annotations + +from typing_extensions import Required, Annotated, TypedDict + +from .._utils import PropertyInfo + +__all__ = ["SessionCommandParams"] + + +class SessionCommandParams(TypedDict, total=False): + arguments: Required[str] + + command: Required[str] + + directory: str + + agent: str + + message_id: Annotated[str, PropertyInfo(alias="messageID")] + + model: str diff --git a/src/opencode_ai/types/session_command_response.py b/src/opencode_ai/types/session_command_response.py new file mode 100644 index 0000000..c697bbd --- /dev/null +++ b/src/opencode_ai/types/session_command_response.py @@ -0,0 +1,15 @@ +# File generated from our OpenAPI spec by Stainless. See CONTRIBUTING.md for details. + +from typing import List + +from .part import Part +from .._models import BaseModel +from .assistant_message import AssistantMessage + +__all__ = ["SessionCommandResponse"] + + +class SessionCommandResponse(BaseModel): + info: AssistantMessage + + parts: List[Part] diff --git a/src/opencode_ai/types/session_create_params.py b/src/opencode_ai/types/session_create_params.py new file mode 100644 index 0000000..d41fa79 --- /dev/null +++ b/src/opencode_ai/types/session_create_params.py @@ -0,0 +1,17 @@ +# File generated from our OpenAPI spec by Stainless. See CONTRIBUTING.md for details. + +from __future__ import annotations + +from typing_extensions import Annotated, TypedDict + +from .._utils import PropertyInfo + +__all__ = ["SessionCreateParams"] + + +class SessionCreateParams(TypedDict, total=False): + directory: str + + parent_id: Annotated[str, PropertyInfo(alias="parentID")] + + title: str diff --git a/src/opencode_ai/types/session_delete_params.py b/src/opencode_ai/types/session_delete_params.py new file mode 100644 index 0000000..3549cea --- /dev/null +++ b/src/opencode_ai/types/session_delete_params.py @@ -0,0 +1,11 @@ +# File generated from our OpenAPI spec by Stainless. See CONTRIBUTING.md for details. + +from __future__ import annotations + +from typing_extensions import TypedDict + +__all__ = ["SessionDeleteParams"] + + +class SessionDeleteParams(TypedDict, total=False): + directory: str diff --git a/src/opencode_ai/types/session_delete_response.py b/src/opencode_ai/types/session_delete_response.py new file mode 100644 index 0000000..0ebd0ad --- /dev/null +++ b/src/opencode_ai/types/session_delete_response.py @@ -0,0 +1,7 @@ +# File generated from our OpenAPI spec by Stainless. See CONTRIBUTING.md for details. + +from typing_extensions import TypeAlias + +__all__ = ["SessionDeleteResponse"] + +SessionDeleteResponse: TypeAlias = bool diff --git a/src/opencode_ai/types/session_get_params.py b/src/opencode_ai/types/session_get_params.py new file mode 100644 index 0000000..f77e9b0 --- /dev/null +++ b/src/opencode_ai/types/session_get_params.py @@ -0,0 +1,11 @@ +# File generated from our OpenAPI spec by Stainless. See CONTRIBUTING.md for details. + +from __future__ import annotations + +from typing_extensions import TypedDict + +__all__ = ["SessionGetParams"] + + +class SessionGetParams(TypedDict, total=False): + directory: str diff --git a/src/opencode_ai/types/session_init_params.py b/src/opencode_ai/types/session_init_params.py new file mode 100644 index 0000000..cf370b8 --- /dev/null +++ b/src/opencode_ai/types/session_init_params.py @@ -0,0 +1,19 @@ +# File generated from our OpenAPI spec by Stainless. See CONTRIBUTING.md for details. + +from __future__ import annotations + +from typing_extensions import Required, Annotated, TypedDict + +from .._utils import PropertyInfo + +__all__ = ["SessionInitParams"] + + +class SessionInitParams(TypedDict, total=False): + message_id: Required[Annotated[str, PropertyInfo(alias="messageID")]] + + model_id: Required[Annotated[str, PropertyInfo(alias="modelID")]] + + provider_id: Required[Annotated[str, PropertyInfo(alias="providerID")]] + + directory: str diff --git a/src/opencode_ai/types/session_init_response.py b/src/opencode_ai/types/session_init_response.py new file mode 100644 index 0000000..69fcc6b --- /dev/null +++ b/src/opencode_ai/types/session_init_response.py @@ -0,0 +1,7 @@ +# File generated from our OpenAPI spec by Stainless. See CONTRIBUTING.md for details. + +from typing_extensions import TypeAlias + +__all__ = ["SessionInitResponse"] + +SessionInitResponse: TypeAlias = bool diff --git a/src/opencode_ai/types/session_list_params.py b/src/opencode_ai/types/session_list_params.py new file mode 100644 index 0000000..21d63f1 --- /dev/null +++ b/src/opencode_ai/types/session_list_params.py @@ -0,0 +1,11 @@ +# File generated from our OpenAPI spec by Stainless. See CONTRIBUTING.md for details. + +from __future__ import annotations + +from typing_extensions import TypedDict + +__all__ = ["SessionListParams"] + + +class SessionListParams(TypedDict, total=False): + directory: str diff --git a/src/opencode_ai/types/session_list_response.py b/src/opencode_ai/types/session_list_response.py new file mode 100644 index 0000000..5f221fa --- /dev/null +++ b/src/opencode_ai/types/session_list_response.py @@ -0,0 +1,10 @@ +# File generated from our OpenAPI spec by Stainless. See CONTRIBUTING.md for details. + +from typing import List +from typing_extensions import TypeAlias + +from .session.session import Session + +__all__ = ["SessionListResponse"] + +SessionListResponse: TypeAlias = List[Session] diff --git a/src/opencode_ai/types/session_message_params.py b/src/opencode_ai/types/session_message_params.py new file mode 100644 index 0000000..aa00f7c --- /dev/null +++ b/src/opencode_ai/types/session_message_params.py @@ -0,0 +1,14 @@ +# File generated from our OpenAPI spec by Stainless. See CONTRIBUTING.md for details. + +from __future__ import annotations + +from typing_extensions import Required, TypedDict + +__all__ = ["SessionMessageParams"] + + +class SessionMessageParams(TypedDict, total=False): + id: Required[str] + """Session ID""" + + directory: str diff --git a/src/opencode_ai/types/session_message_response.py b/src/opencode_ai/types/session_message_response.py new file mode 100644 index 0000000..a64b088 --- /dev/null +++ b/src/opencode_ai/types/session_message_response.py @@ -0,0 +1,15 @@ +# File generated from our OpenAPI spec by Stainless. See CONTRIBUTING.md for details. + +from typing import List + +from .part import Part +from .message import Message +from .._models import BaseModel + +__all__ = ["SessionMessageResponse"] + + +class SessionMessageResponse(BaseModel): + info: Message + + parts: List[Part] diff --git a/src/opencode_ai/types/session_messages_params.py b/src/opencode_ai/types/session_messages_params.py new file mode 100644 index 0000000..6831706 --- /dev/null +++ b/src/opencode_ai/types/session_messages_params.py @@ -0,0 +1,11 @@ +# File generated from our OpenAPI spec by Stainless. See CONTRIBUTING.md for details. + +from __future__ import annotations + +from typing_extensions import TypedDict + +__all__ = ["SessionMessagesParams"] + + +class SessionMessagesParams(TypedDict, total=False): + directory: str diff --git a/src/opencode_ai/types/session_messages_response.py b/src/opencode_ai/types/session_messages_response.py new file mode 100644 index 0000000..6eb8692 --- /dev/null +++ b/src/opencode_ai/types/session_messages_response.py @@ -0,0 +1,19 @@ +# File generated from our OpenAPI spec by Stainless. See CONTRIBUTING.md for details. + +from typing import List +from typing_extensions import TypeAlias + +from .part import Part +from .message import Message +from .._models import BaseModel + +__all__ = ["SessionMessagesResponse", "SessionMessagesResponseItem"] + + +class SessionMessagesResponseItem(BaseModel): + info: Message + + parts: List[Part] + + +SessionMessagesResponse: TypeAlias = List[SessionMessagesResponseItem] diff --git a/src/opencode_ai/types/session_prompt_params.py b/src/opencode_ai/types/session_prompt_params.py new file mode 100644 index 0000000..78fad20 --- /dev/null +++ b/src/opencode_ai/types/session_prompt_params.py @@ -0,0 +1,38 @@ +# File generated from our OpenAPI spec by Stainless. See CONTRIBUTING.md for details. + +from __future__ import annotations + +from typing import Dict, Union, Iterable +from typing_extensions import Required, Annotated, TypeAlias, TypedDict + +from .._utils import PropertyInfo +from .file_part_input_param import FilePartInputParam +from .text_part_input_param import TextPartInputParam +from .agent_part_input_param import AgentPartInputParam + +__all__ = ["SessionPromptParams", "Part", "Model"] + + +class SessionPromptParams(TypedDict, total=False): + parts: Required[Iterable[Part]] + + directory: str + + agent: str + + message_id: Annotated[str, PropertyInfo(alias="messageID")] + + model: Model + + system: str + + tools: Dict[str, bool] + + +Part: TypeAlias = Union[TextPartInputParam, FilePartInputParam, AgentPartInputParam] + + +class Model(TypedDict, total=False): + model_id: Required[Annotated[str, PropertyInfo(alias="modelID")]] + + provider_id: Required[Annotated[str, PropertyInfo(alias="providerID")]] diff --git a/src/opencode_ai/types/session_prompt_response.py b/src/opencode_ai/types/session_prompt_response.py new file mode 100644 index 0000000..2502def --- /dev/null +++ b/src/opencode_ai/types/session_prompt_response.py @@ -0,0 +1,15 @@ +# File generated from our OpenAPI spec by Stainless. See CONTRIBUTING.md for details. + +from typing import List + +from .part import Part +from .._models import BaseModel +from .assistant_message import AssistantMessage + +__all__ = ["SessionPromptResponse"] + + +class SessionPromptResponse(BaseModel): + info: AssistantMessage + + parts: List[Part] diff --git a/src/opencode_ai/types/session_revert_params.py b/src/opencode_ai/types/session_revert_params.py new file mode 100644 index 0000000..b56a5b4 --- /dev/null +++ b/src/opencode_ai/types/session_revert_params.py @@ -0,0 +1,17 @@ +# File generated from our OpenAPI spec by Stainless. See CONTRIBUTING.md for details. + +from __future__ import annotations + +from typing_extensions import Required, Annotated, TypedDict + +from .._utils import PropertyInfo + +__all__ = ["SessionRevertParams"] + + +class SessionRevertParams(TypedDict, total=False): + message_id: Required[Annotated[str, PropertyInfo(alias="messageID")]] + + directory: str + + part_id: Annotated[str, PropertyInfo(alias="partID")] diff --git a/src/opencode_ai/types/session_share_params.py b/src/opencode_ai/types/session_share_params.py new file mode 100644 index 0000000..9c1dbb6 --- /dev/null +++ b/src/opencode_ai/types/session_share_params.py @@ -0,0 +1,11 @@ +# File generated from our OpenAPI spec by Stainless. See CONTRIBUTING.md for details. + +from __future__ import annotations + +from typing_extensions import TypedDict + +__all__ = ["SessionShareParams"] + + +class SessionShareParams(TypedDict, total=False): + directory: str diff --git a/src/opencode_ai/types/session_shell_params.py b/src/opencode_ai/types/session_shell_params.py new file mode 100644 index 0000000..7e45f59 --- /dev/null +++ b/src/opencode_ai/types/session_shell_params.py @@ -0,0 +1,15 @@ +# File generated from our OpenAPI spec by Stainless. See CONTRIBUTING.md for details. + +from __future__ import annotations + +from typing_extensions import Required, TypedDict + +__all__ = ["SessionShellParams"] + + +class SessionShellParams(TypedDict, total=False): + agent: Required[str] + + command: Required[str] + + directory: str diff --git a/src/opencode_ai/types/session_summarize_params.py b/src/opencode_ai/types/session_summarize_params.py new file mode 100644 index 0000000..f976e9f --- /dev/null +++ b/src/opencode_ai/types/session_summarize_params.py @@ -0,0 +1,17 @@ +# File generated from our OpenAPI spec by Stainless. See CONTRIBUTING.md for details. + +from __future__ import annotations + +from typing_extensions import Required, Annotated, TypedDict + +from .._utils import PropertyInfo + +__all__ = ["SessionSummarizeParams"] + + +class SessionSummarizeParams(TypedDict, total=False): + model_id: Required[Annotated[str, PropertyInfo(alias="modelID")]] + + provider_id: Required[Annotated[str, PropertyInfo(alias="providerID")]] + + directory: str diff --git a/src/opencode_ai/types/session_summarize_response.py b/src/opencode_ai/types/session_summarize_response.py new file mode 100644 index 0000000..5165232 --- /dev/null +++ b/src/opencode_ai/types/session_summarize_response.py @@ -0,0 +1,7 @@ +# File generated from our OpenAPI spec by Stainless. See CONTRIBUTING.md for details. + +from typing_extensions import TypeAlias + +__all__ = ["SessionSummarizeResponse"] + +SessionSummarizeResponse: TypeAlias = bool diff --git a/src/opencode_ai/types/session_unrevert_params.py b/src/opencode_ai/types/session_unrevert_params.py new file mode 100644 index 0000000..24cbedd --- /dev/null +++ b/src/opencode_ai/types/session_unrevert_params.py @@ -0,0 +1,11 @@ +# File generated from our OpenAPI spec by Stainless. See CONTRIBUTING.md for details. + +from __future__ import annotations + +from typing_extensions import TypedDict + +__all__ = ["SessionUnrevertParams"] + + +class SessionUnrevertParams(TypedDict, total=False): + directory: str diff --git a/src/opencode_ai/types/session_unshare_params.py b/src/opencode_ai/types/session_unshare_params.py new file mode 100644 index 0000000..bbbd1be --- /dev/null +++ b/src/opencode_ai/types/session_unshare_params.py @@ -0,0 +1,11 @@ +# File generated from our OpenAPI spec by Stainless. See CONTRIBUTING.md for details. + +from __future__ import annotations + +from typing_extensions import TypedDict + +__all__ = ["SessionUnshareParams"] + + +class SessionUnshareParams(TypedDict, total=False): + directory: str diff --git a/src/opencode_ai/types/session_update_params.py b/src/opencode_ai/types/session_update_params.py new file mode 100644 index 0000000..c1169af --- /dev/null +++ b/src/opencode_ai/types/session_update_params.py @@ -0,0 +1,13 @@ +# File generated from our OpenAPI spec by Stainless. See CONTRIBUTING.md for details. + +from __future__ import annotations + +from typing_extensions import TypedDict + +__all__ = ["SessionUpdateParams"] + + +class SessionUpdateParams(TypedDict, total=False): + directory: str + + title: str diff --git a/src/opencode_ai/types/shared/__init__.py b/src/opencode_ai/types/shared/__init__.py new file mode 100644 index 0000000..bc579a8 --- /dev/null +++ b/src/opencode_ai/types/shared/__init__.py @@ -0,0 +1,5 @@ +# File generated from our OpenAPI spec by Stainless. See CONTRIBUTING.md for details. + +from .unknown_error import UnknownError as UnknownError +from .provider_auth_error import ProviderAuthError as ProviderAuthError +from .message_aborted_error import MessageAbortedError as MessageAbortedError diff --git a/src/opencode_ai/types/shared/message_aborted_error.py b/src/opencode_ai/types/shared/message_aborted_error.py new file mode 100644 index 0000000..9ffdcaa --- /dev/null +++ b/src/opencode_ai/types/shared/message_aborted_error.py @@ -0,0 +1,13 @@ +# File generated from our OpenAPI spec by Stainless. See CONTRIBUTING.md for details. + +from typing_extensions import Literal + +from ..._models import BaseModel + +__all__ = ["MessageAbortedError"] + + +class MessageAbortedError(BaseModel): + data: object + + name: Literal["MessageAbortedError"] diff --git a/src/opencode_ai/types/shared/provider_auth_error.py b/src/opencode_ai/types/shared/provider_auth_error.py new file mode 100644 index 0000000..7ce4908 --- /dev/null +++ b/src/opencode_ai/types/shared/provider_auth_error.py @@ -0,0 +1,21 @@ +# File generated from our OpenAPI spec by Stainless. See CONTRIBUTING.md for details. + +from typing_extensions import Literal + +from pydantic import Field as FieldInfo + +from ..._models import BaseModel + +__all__ = ["ProviderAuthError", "Data"] + + +class Data(BaseModel): + message: str + + provider_id: str = FieldInfo(alias="providerID") + + +class ProviderAuthError(BaseModel): + data: Data + + name: Literal["ProviderAuthError"] diff --git a/src/opencode_ai/types/shared/unknown_error.py b/src/opencode_ai/types/shared/unknown_error.py new file mode 100644 index 0000000..240d359 --- /dev/null +++ b/src/opencode_ai/types/shared/unknown_error.py @@ -0,0 +1,17 @@ +# File generated from our OpenAPI spec by Stainless. See CONTRIBUTING.md for details. + +from typing_extensions import Literal + +from ..._models import BaseModel + +__all__ = ["UnknownError", "Data"] + + +class Data(BaseModel): + message: str + + +class UnknownError(BaseModel): + data: Data + + name: Literal["UnknownError"] diff --git a/src/opencode_ai/types/snapshot_part.py b/src/opencode_ai/types/snapshot_part.py new file mode 100644 index 0000000..485f47b --- /dev/null +++ b/src/opencode_ai/types/snapshot_part.py @@ -0,0 +1,21 @@ +# File generated from our OpenAPI spec by Stainless. See CONTRIBUTING.md for details. + +from typing_extensions import Literal + +from pydantic import Field as FieldInfo + +from .._models import BaseModel + +__all__ = ["SnapshotPart"] + + +class SnapshotPart(BaseModel): + id: str + + message_id: str = FieldInfo(alias="messageID") + + session_id: str = FieldInfo(alias="sessionID") + + snapshot: str + + type: Literal["snapshot"] diff --git a/src/opencode_ai/types/step_finish_part.py b/src/opencode_ai/types/step_finish_part.py new file mode 100644 index 0000000..b9f5b4b --- /dev/null +++ b/src/opencode_ai/types/step_finish_part.py @@ -0,0 +1,39 @@ +# File generated from our OpenAPI spec by Stainless. See CONTRIBUTING.md for details. + +from typing_extensions import Literal + +from pydantic import Field as FieldInfo + +from .._models import BaseModel + +__all__ = ["StepFinishPart", "Tokens", "TokensCache"] + + +class TokensCache(BaseModel): + read: float + + write: float + + +class Tokens(BaseModel): + cache: TokensCache + + input: float + + output: float + + reasoning: float + + +class StepFinishPart(BaseModel): + id: str + + cost: float + + message_id: str = FieldInfo(alias="messageID") + + session_id: str = FieldInfo(alias="sessionID") + + tokens: Tokens + + type: Literal["step-finish"] diff --git a/src/opencode_ai/types/step_start_part.py b/src/opencode_ai/types/step_start_part.py new file mode 100644 index 0000000..6c9e0df --- /dev/null +++ b/src/opencode_ai/types/step_start_part.py @@ -0,0 +1,19 @@ +# File generated from our OpenAPI spec by Stainless. See CONTRIBUTING.md for details. + +from typing_extensions import Literal + +from pydantic import Field as FieldInfo + +from .._models import BaseModel + +__all__ = ["StepStartPart"] + + +class StepStartPart(BaseModel): + id: str + + message_id: str = FieldInfo(alias="messageID") + + session_id: str = FieldInfo(alias="sessionID") + + type: Literal["step-start"] diff --git a/src/opencode_ai/types/symbol.py b/src/opencode_ai/types/symbol.py new file mode 100644 index 0000000..c7d1f99 --- /dev/null +++ b/src/opencode_ai/types/symbol.py @@ -0,0 +1,37 @@ +# File generated from our OpenAPI spec by Stainless. See CONTRIBUTING.md for details. + +from .._models import BaseModel + +__all__ = ["Symbol", "Location", "LocationRange", "LocationRangeEnd", "LocationRangeStart"] + + +class LocationRangeEnd(BaseModel): + character: float + + line: float + + +class LocationRangeStart(BaseModel): + character: float + + line: float + + +class LocationRange(BaseModel): + end: LocationRangeEnd + + start: LocationRangeStart + + +class Location(BaseModel): + range: LocationRange + + uri: str + + +class Symbol(BaseModel): + kind: float + + location: Location + + name: str diff --git a/src/opencode_ai/types/symbol_source.py b/src/opencode_ai/types/symbol_source.py new file mode 100644 index 0000000..f8982d8 --- /dev/null +++ b/src/opencode_ai/types/symbol_source.py @@ -0,0 +1,40 @@ +# File generated from our OpenAPI spec by Stainless. See CONTRIBUTING.md for details. + +from typing_extensions import Literal + +from .._models import BaseModel +from .file_part_source_text import FilePartSourceText + +__all__ = ["SymbolSource", "Range", "RangeEnd", "RangeStart"] + + +class RangeEnd(BaseModel): + character: float + + line: float + + +class RangeStart(BaseModel): + character: float + + line: float + + +class Range(BaseModel): + end: RangeEnd + + start: RangeStart + + +class SymbolSource(BaseModel): + kind: int + + name: str + + path: str + + range: Range + + text: FilePartSourceText + + type: Literal["symbol"] diff --git a/src/opencode_ai/types/symbol_source_param.py b/src/opencode_ai/types/symbol_source_param.py new file mode 100644 index 0000000..b1ec7a0 --- /dev/null +++ b/src/opencode_ai/types/symbol_source_param.py @@ -0,0 +1,41 @@ +# File generated from our OpenAPI spec by Stainless. See CONTRIBUTING.md for details. + +from __future__ import annotations + +from typing_extensions import Literal, Required, TypedDict + +from .file_part_source_text_param import FilePartSourceTextParam + +__all__ = ["SymbolSourceParam", "Range", "RangeEnd", "RangeStart"] + + +class RangeEnd(TypedDict, total=False): + character: Required[float] + + line: Required[float] + + +class RangeStart(TypedDict, total=False): + character: Required[float] + + line: Required[float] + + +class Range(TypedDict, total=False): + end: Required[RangeEnd] + + start: Required[RangeStart] + + +class SymbolSourceParam(TypedDict, total=False): + kind: Required[int] + + name: Required[str] + + path: Required[str] + + range: Required[Range] + + text: Required[FilePartSourceTextParam] + + type: Required[Literal["symbol"]] diff --git a/src/opencode_ai/types/text_part.py b/src/opencode_ai/types/text_part.py new file mode 100644 index 0000000..514f409 --- /dev/null +++ b/src/opencode_ai/types/text_part.py @@ -0,0 +1,32 @@ +# File generated from our OpenAPI spec by Stainless. See CONTRIBUTING.md for details. + +from typing import Optional +from typing_extensions import Literal + +from pydantic import Field as FieldInfo + +from .._models import BaseModel + +__all__ = ["TextPart", "Time"] + + +class Time(BaseModel): + start: float + + end: Optional[float] = None + + +class TextPart(BaseModel): + id: str + + message_id: str = FieldInfo(alias="messageID") + + session_id: str = FieldInfo(alias="sessionID") + + text: str + + type: Literal["text"] + + synthetic: Optional[bool] = None + + time: Optional[Time] = None diff --git a/src/opencode_ai/types/text_part_input_param.py b/src/opencode_ai/types/text_part_input_param.py new file mode 100644 index 0000000..2850484 --- /dev/null +++ b/src/opencode_ai/types/text_part_input_param.py @@ -0,0 +1,25 @@ +# File generated from our OpenAPI spec by Stainless. See CONTRIBUTING.md for details. + +from __future__ import annotations + +from typing_extensions import Literal, Required, TypedDict + +__all__ = ["TextPartInputParam", "Time"] + + +class Time(TypedDict, total=False): + start: Required[float] + + end: float + + +class TextPartInputParam(TypedDict, total=False): + text: Required[str] + + type: Required[Literal["text"]] + + id: str + + synthetic: bool + + time: Time diff --git a/src/opencode_ai/types/tool_part.py b/src/opencode_ai/types/tool_part.py new file mode 100644 index 0000000..2de8ed9 --- /dev/null +++ b/src/opencode_ai/types/tool_part.py @@ -0,0 +1,35 @@ +# File generated from our OpenAPI spec by Stainless. See CONTRIBUTING.md for details. + +from typing import Union +from typing_extensions import Literal, Annotated, TypeAlias + +from pydantic import Field as FieldInfo + +from .._utils import PropertyInfo +from .._models import BaseModel +from .tool_state_error import ToolStateError +from .tool_state_pending import ToolStatePending +from .tool_state_running import ToolStateRunning +from .tool_state_completed import ToolStateCompleted + +__all__ = ["ToolPart", "State"] + +State: TypeAlias = Annotated[ + Union[ToolStatePending, ToolStateRunning, ToolStateCompleted, ToolStateError], PropertyInfo(discriminator="status") +] + + +class ToolPart(BaseModel): + id: str + + call_id: str = FieldInfo(alias="callID") + + message_id: str = FieldInfo(alias="messageID") + + session_id: str = FieldInfo(alias="sessionID") + + state: State + + tool: str + + type: Literal["tool"] diff --git a/src/opencode_ai/types/tool_state_completed.py b/src/opencode_ai/types/tool_state_completed.py new file mode 100644 index 0000000..5129842 --- /dev/null +++ b/src/opencode_ai/types/tool_state_completed.py @@ -0,0 +1,28 @@ +# File generated from our OpenAPI spec by Stainless. See CONTRIBUTING.md for details. + +from typing import Dict +from typing_extensions import Literal + +from .._models import BaseModel + +__all__ = ["ToolStateCompleted", "Time"] + + +class Time(BaseModel): + end: float + + start: float + + +class ToolStateCompleted(BaseModel): + input: Dict[str, object] + + metadata: Dict[str, object] + + output: str + + status: Literal["completed"] + + time: Time + + title: str diff --git a/src/opencode_ai/types/tool_state_error.py b/src/opencode_ai/types/tool_state_error.py new file mode 100644 index 0000000..8f6bf5a --- /dev/null +++ b/src/opencode_ai/types/tool_state_error.py @@ -0,0 +1,26 @@ +# File generated from our OpenAPI spec by Stainless. See CONTRIBUTING.md for details. + +from typing import Dict, Optional +from typing_extensions import Literal + +from .._models import BaseModel + +__all__ = ["ToolStateError", "Time"] + + +class Time(BaseModel): + end: float + + start: float + + +class ToolStateError(BaseModel): + error: str + + input: Dict[str, object] + + status: Literal["error"] + + time: Time + + metadata: Optional[Dict[str, object]] = None diff --git a/src/opencode_ai/types/tool_state_pending.py b/src/opencode_ai/types/tool_state_pending.py new file mode 100644 index 0000000..c678c92 --- /dev/null +++ b/src/opencode_ai/types/tool_state_pending.py @@ -0,0 +1,11 @@ +# File generated from our OpenAPI spec by Stainless. See CONTRIBUTING.md for details. + +from typing_extensions import Literal + +from .._models import BaseModel + +__all__ = ["ToolStatePending"] + + +class ToolStatePending(BaseModel): + status: Literal["pending"] diff --git a/src/opencode_ai/types/tool_state_running.py b/src/opencode_ai/types/tool_state_running.py new file mode 100644 index 0000000..87e2e8d --- /dev/null +++ b/src/opencode_ai/types/tool_state_running.py @@ -0,0 +1,24 @@ +# File generated from our OpenAPI spec by Stainless. See CONTRIBUTING.md for details. + +from typing import Dict, Optional +from typing_extensions import Literal + +from .._models import BaseModel + +__all__ = ["ToolStateRunning", "Time"] + + +class Time(BaseModel): + start: float + + +class ToolStateRunning(BaseModel): + status: Literal["running"] + + time: Time + + input: Optional[object] = None + + metadata: Optional[Dict[str, object]] = None + + title: Optional[str] = None diff --git a/src/opencode_ai/types/tui_append_prompt_params.py b/src/opencode_ai/types/tui_append_prompt_params.py new file mode 100644 index 0000000..48a0e32 --- /dev/null +++ b/src/opencode_ai/types/tui_append_prompt_params.py @@ -0,0 +1,13 @@ +# File generated from our OpenAPI spec by Stainless. See CONTRIBUTING.md for details. + +from __future__ import annotations + +from typing_extensions import Required, TypedDict + +__all__ = ["TuiAppendPromptParams"] + + +class TuiAppendPromptParams(TypedDict, total=False): + text: Required[str] + + directory: str diff --git a/src/opencode_ai/types/tui_append_prompt_response.py b/src/opencode_ai/types/tui_append_prompt_response.py new file mode 100644 index 0000000..85b6813 --- /dev/null +++ b/src/opencode_ai/types/tui_append_prompt_response.py @@ -0,0 +1,7 @@ +# File generated from our OpenAPI spec by Stainless. See CONTRIBUTING.md for details. + +from typing_extensions import TypeAlias + +__all__ = ["TuiAppendPromptResponse"] + +TuiAppendPromptResponse: TypeAlias = bool diff --git a/src/opencode_ai/types/tui_clear_prompt_params.py b/src/opencode_ai/types/tui_clear_prompt_params.py new file mode 100644 index 0000000..5f1a7f3 --- /dev/null +++ b/src/opencode_ai/types/tui_clear_prompt_params.py @@ -0,0 +1,11 @@ +# File generated from our OpenAPI spec by Stainless. See CONTRIBUTING.md for details. + +from __future__ import annotations + +from typing_extensions import TypedDict + +__all__ = ["TuiClearPromptParams"] + + +class TuiClearPromptParams(TypedDict, total=False): + directory: str diff --git a/src/opencode_ai/types/tui_clear_prompt_response.py b/src/opencode_ai/types/tui_clear_prompt_response.py new file mode 100644 index 0000000..3b96c7a --- /dev/null +++ b/src/opencode_ai/types/tui_clear_prompt_response.py @@ -0,0 +1,7 @@ +# File generated from our OpenAPI spec by Stainless. See CONTRIBUTING.md for details. + +from typing_extensions import TypeAlias + +__all__ = ["TuiClearPromptResponse"] + +TuiClearPromptResponse: TypeAlias = bool diff --git a/src/opencode_ai/types/tui_execute_command_params.py b/src/opencode_ai/types/tui_execute_command_params.py new file mode 100644 index 0000000..a73953f --- /dev/null +++ b/src/opencode_ai/types/tui_execute_command_params.py @@ -0,0 +1,13 @@ +# File generated from our OpenAPI spec by Stainless. See CONTRIBUTING.md for details. + +from __future__ import annotations + +from typing_extensions import Required, TypedDict + +__all__ = ["TuiExecuteCommandParams"] + + +class TuiExecuteCommandParams(TypedDict, total=False): + command: Required[str] + + directory: str diff --git a/src/opencode_ai/types/tui_execute_command_response.py b/src/opencode_ai/types/tui_execute_command_response.py new file mode 100644 index 0000000..5ceb1dd --- /dev/null +++ b/src/opencode_ai/types/tui_execute_command_response.py @@ -0,0 +1,7 @@ +# File generated from our OpenAPI spec by Stainless. See CONTRIBUTING.md for details. + +from typing_extensions import TypeAlias + +__all__ = ["TuiExecuteCommandResponse"] + +TuiExecuteCommandResponse: TypeAlias = bool diff --git a/src/opencode_ai/types/tui_open_help_params.py b/src/opencode_ai/types/tui_open_help_params.py new file mode 100644 index 0000000..7d613af --- /dev/null +++ b/src/opencode_ai/types/tui_open_help_params.py @@ -0,0 +1,11 @@ +# File generated from our OpenAPI spec by Stainless. See CONTRIBUTING.md for details. + +from __future__ import annotations + +from typing_extensions import TypedDict + +__all__ = ["TuiOpenHelpParams"] + + +class TuiOpenHelpParams(TypedDict, total=False): + directory: str diff --git a/src/opencode_ai/types/tui_open_help_response.py b/src/opencode_ai/types/tui_open_help_response.py new file mode 100644 index 0000000..59df1f1 --- /dev/null +++ b/src/opencode_ai/types/tui_open_help_response.py @@ -0,0 +1,7 @@ +# File generated from our OpenAPI spec by Stainless. See CONTRIBUTING.md for details. + +from typing_extensions import TypeAlias + +__all__ = ["TuiOpenHelpResponse"] + +TuiOpenHelpResponse: TypeAlias = bool diff --git a/src/opencode_ai/types/tui_open_models_params.py b/src/opencode_ai/types/tui_open_models_params.py new file mode 100644 index 0000000..f514e95 --- /dev/null +++ b/src/opencode_ai/types/tui_open_models_params.py @@ -0,0 +1,11 @@ +# File generated from our OpenAPI spec by Stainless. See CONTRIBUTING.md for details. + +from __future__ import annotations + +from typing_extensions import TypedDict + +__all__ = ["TuiOpenModelsParams"] + + +class TuiOpenModelsParams(TypedDict, total=False): + directory: str diff --git a/src/opencode_ai/types/tui_open_models_response.py b/src/opencode_ai/types/tui_open_models_response.py new file mode 100644 index 0000000..073a388 --- /dev/null +++ b/src/opencode_ai/types/tui_open_models_response.py @@ -0,0 +1,7 @@ +# File generated from our OpenAPI spec by Stainless. See CONTRIBUTING.md for details. + +from typing_extensions import TypeAlias + +__all__ = ["TuiOpenModelsResponse"] + +TuiOpenModelsResponse: TypeAlias = bool diff --git a/src/opencode_ai/types/tui_open_sessions_params.py b/src/opencode_ai/types/tui_open_sessions_params.py new file mode 100644 index 0000000..284cd70 --- /dev/null +++ b/src/opencode_ai/types/tui_open_sessions_params.py @@ -0,0 +1,11 @@ +# File generated from our OpenAPI spec by Stainless. See CONTRIBUTING.md for details. + +from __future__ import annotations + +from typing_extensions import TypedDict + +__all__ = ["TuiOpenSessionsParams"] + + +class TuiOpenSessionsParams(TypedDict, total=False): + directory: str diff --git a/src/opencode_ai/types/tui_open_sessions_response.py b/src/opencode_ai/types/tui_open_sessions_response.py new file mode 100644 index 0000000..eab352a --- /dev/null +++ b/src/opencode_ai/types/tui_open_sessions_response.py @@ -0,0 +1,7 @@ +# File generated from our OpenAPI spec by Stainless. See CONTRIBUTING.md for details. + +from typing_extensions import TypeAlias + +__all__ = ["TuiOpenSessionsResponse"] + +TuiOpenSessionsResponse: TypeAlias = bool diff --git a/src/opencode_ai/types/tui_open_themes_params.py b/src/opencode_ai/types/tui_open_themes_params.py new file mode 100644 index 0000000..8adc30d --- /dev/null +++ b/src/opencode_ai/types/tui_open_themes_params.py @@ -0,0 +1,11 @@ +# File generated from our OpenAPI spec by Stainless. See CONTRIBUTING.md for details. + +from __future__ import annotations + +from typing_extensions import TypedDict + +__all__ = ["TuiOpenThemesParams"] + + +class TuiOpenThemesParams(TypedDict, total=False): + directory: str diff --git a/src/opencode_ai/types/tui_open_themes_response.py b/src/opencode_ai/types/tui_open_themes_response.py new file mode 100644 index 0000000..8623270 --- /dev/null +++ b/src/opencode_ai/types/tui_open_themes_response.py @@ -0,0 +1,7 @@ +# File generated from our OpenAPI spec by Stainless. See CONTRIBUTING.md for details. + +from typing_extensions import TypeAlias + +__all__ = ["TuiOpenThemesResponse"] + +TuiOpenThemesResponse: TypeAlias = bool diff --git a/src/opencode_ai/types/tui_show_toast_params.py b/src/opencode_ai/types/tui_show_toast_params.py new file mode 100644 index 0000000..bbc7e15 --- /dev/null +++ b/src/opencode_ai/types/tui_show_toast_params.py @@ -0,0 +1,17 @@ +# File generated from our OpenAPI spec by Stainless. See CONTRIBUTING.md for details. + +from __future__ import annotations + +from typing_extensions import Literal, Required, TypedDict + +__all__ = ["TuiShowToastParams"] + + +class TuiShowToastParams(TypedDict, total=False): + message: Required[str] + + variant: Required[Literal["info", "success", "warning", "error"]] + + directory: str + + title: str diff --git a/src/opencode_ai/types/tui_show_toast_response.py b/src/opencode_ai/types/tui_show_toast_response.py new file mode 100644 index 0000000..e66d1de --- /dev/null +++ b/src/opencode_ai/types/tui_show_toast_response.py @@ -0,0 +1,7 @@ +# File generated from our OpenAPI spec by Stainless. See CONTRIBUTING.md for details. + +from typing_extensions import TypeAlias + +__all__ = ["TuiShowToastResponse"] + +TuiShowToastResponse: TypeAlias = bool diff --git a/src/opencode_ai/types/tui_submit_prompt_params.py b/src/opencode_ai/types/tui_submit_prompt_params.py new file mode 100644 index 0000000..4331fda --- /dev/null +++ b/src/opencode_ai/types/tui_submit_prompt_params.py @@ -0,0 +1,11 @@ +# File generated from our OpenAPI spec by Stainless. See CONTRIBUTING.md for details. + +from __future__ import annotations + +from typing_extensions import TypedDict + +__all__ = ["TuiSubmitPromptParams"] + + +class TuiSubmitPromptParams(TypedDict, total=False): + directory: str diff --git a/src/opencode_ai/types/tui_submit_prompt_response.py b/src/opencode_ai/types/tui_submit_prompt_response.py new file mode 100644 index 0000000..55cf124 --- /dev/null +++ b/src/opencode_ai/types/tui_submit_prompt_response.py @@ -0,0 +1,7 @@ +# File generated from our OpenAPI spec by Stainless. See CONTRIBUTING.md for details. + +from typing_extensions import TypeAlias + +__all__ = ["TuiSubmitPromptResponse"] + +TuiSubmitPromptResponse: TypeAlias = bool diff --git a/src/opencode_ai/types/user_message.py b/src/opencode_ai/types/user_message.py new file mode 100644 index 0000000..64c44bf --- /dev/null +++ b/src/opencode_ai/types/user_message.py @@ -0,0 +1,23 @@ +# File generated from our OpenAPI spec by Stainless. See CONTRIBUTING.md for details. + +from typing_extensions import Literal + +from pydantic import Field as FieldInfo + +from .._models import BaseModel + +__all__ = ["UserMessage", "Time"] + + +class Time(BaseModel): + created: float + + +class UserMessage(BaseModel): + id: str + + role: Literal["user"] + + session_id: str = FieldInfo(alias="sessionID") + + time: Time diff --git a/tests/__init__.py b/tests/__init__.py new file mode 100644 index 0000000..fd8019a --- /dev/null +++ b/tests/__init__.py @@ -0,0 +1 @@ +# File generated from our OpenAPI spec by Stainless. See CONTRIBUTING.md for details. diff --git a/tests/api_resources/__init__.py b/tests/api_resources/__init__.py new file mode 100644 index 0000000..fd8019a --- /dev/null +++ b/tests/api_resources/__init__.py @@ -0,0 +1 @@ +# File generated from our OpenAPI spec by Stainless. See CONTRIBUTING.md for details. diff --git a/tests/api_resources/session/__init__.py b/tests/api_resources/session/__init__.py new file mode 100644 index 0000000..fd8019a --- /dev/null +++ b/tests/api_resources/session/__init__.py @@ -0,0 +1 @@ +# File generated from our OpenAPI spec by Stainless. See CONTRIBUTING.md for details. diff --git a/tests/api_resources/session/test_permissions.py b/tests/api_resources/session/test_permissions.py new file mode 100644 index 0000000..97a1c50 --- /dev/null +++ b/tests/api_resources/session/test_permissions.py @@ -0,0 +1,160 @@ +# File generated from our OpenAPI spec by Stainless. See CONTRIBUTING.md for details. + +from __future__ import annotations + +import os +from typing import Any, cast + +import pytest + +from opencode_ai import Opencode, AsyncOpencode +from tests.utils import assert_matches_type +from opencode_ai.types.session import PermissionRespondResponse + +base_url = os.environ.get("TEST_API_BASE_URL", "http://127.0.0.1:4010") + + +class TestPermissions: + parametrize = pytest.mark.parametrize("client", [False, True], indirect=True, ids=["loose", "strict"]) + + @pytest.mark.skip(reason="Prism tests are disabled") + @parametrize + def test_method_respond(self, client: Opencode) -> None: + permission = client.session.permissions.respond( + permission_id="permissionID", + id="id", + response="once", + ) + assert_matches_type(PermissionRespondResponse, permission, path=["response"]) + + @pytest.mark.skip(reason="Prism tests are disabled") + @parametrize + def test_method_respond_with_all_params(self, client: Opencode) -> None: + permission = client.session.permissions.respond( + permission_id="permissionID", + id="id", + response="once", + directory="directory", + ) + assert_matches_type(PermissionRespondResponse, permission, path=["response"]) + + @pytest.mark.skip(reason="Prism tests are disabled") + @parametrize + def test_raw_response_respond(self, client: Opencode) -> None: + response = client.session.permissions.with_raw_response.respond( + permission_id="permissionID", + id="id", + response="once", + ) + + assert response.is_closed is True + assert response.http_request.headers.get("X-Stainless-Lang") == "python" + permission = response.parse() + assert_matches_type(PermissionRespondResponse, permission, path=["response"]) + + @pytest.mark.skip(reason="Prism tests are disabled") + @parametrize + def test_streaming_response_respond(self, client: Opencode) -> None: + with client.session.permissions.with_streaming_response.respond( + permission_id="permissionID", + id="id", + response="once", + ) as response: + assert not response.is_closed + assert response.http_request.headers.get("X-Stainless-Lang") == "python" + + permission = response.parse() + assert_matches_type(PermissionRespondResponse, permission, path=["response"]) + + assert cast(Any, response.is_closed) is True + + @pytest.mark.skip(reason="Prism tests are disabled") + @parametrize + def test_path_params_respond(self, client: Opencode) -> None: + with pytest.raises(ValueError, match=r"Expected a non-empty value for `id` but received ''"): + client.session.permissions.with_raw_response.respond( + permission_id="permissionID", + id="", + response="once", + ) + + with pytest.raises(ValueError, match=r"Expected a non-empty value for `permission_id` but received ''"): + client.session.permissions.with_raw_response.respond( + permission_id="", + id="id", + response="once", + ) + + +class TestAsyncPermissions: + parametrize = pytest.mark.parametrize( + "async_client", [False, True, {"http_client": "aiohttp"}], indirect=True, ids=["loose", "strict", "aiohttp"] + ) + + @pytest.mark.skip(reason="Prism tests are disabled") + @parametrize + async def test_method_respond(self, async_client: AsyncOpencode) -> None: + permission = await async_client.session.permissions.respond( + permission_id="permissionID", + id="id", + response="once", + ) + assert_matches_type(PermissionRespondResponse, permission, path=["response"]) + + @pytest.mark.skip(reason="Prism tests are disabled") + @parametrize + async def test_method_respond_with_all_params(self, async_client: AsyncOpencode) -> None: + permission = await async_client.session.permissions.respond( + permission_id="permissionID", + id="id", + response="once", + directory="directory", + ) + assert_matches_type(PermissionRespondResponse, permission, path=["response"]) + + @pytest.mark.skip(reason="Prism tests are disabled") + @parametrize + async def test_raw_response_respond(self, async_client: AsyncOpencode) -> None: + response = await async_client.session.permissions.with_raw_response.respond( + permission_id="permissionID", + id="id", + response="once", + ) + + assert response.is_closed is True + assert response.http_request.headers.get("X-Stainless-Lang") == "python" + permission = await response.parse() + assert_matches_type(PermissionRespondResponse, permission, path=["response"]) + + @pytest.mark.skip(reason="Prism tests are disabled") + @parametrize + async def test_streaming_response_respond(self, async_client: AsyncOpencode) -> None: + async with async_client.session.permissions.with_streaming_response.respond( + permission_id="permissionID", + id="id", + response="once", + ) as response: + assert not response.is_closed + assert response.http_request.headers.get("X-Stainless-Lang") == "python" + + permission = await response.parse() + assert_matches_type(PermissionRespondResponse, permission, path=["response"]) + + assert cast(Any, response.is_closed) is True + + @pytest.mark.skip(reason="Prism tests are disabled") + @parametrize + async def test_path_params_respond(self, async_client: AsyncOpencode) -> None: + with pytest.raises(ValueError, match=r"Expected a non-empty value for `id` but received ''"): + await async_client.session.permissions.with_raw_response.respond( + permission_id="permissionID", + id="", + response="once", + ) + + with pytest.raises(ValueError, match=r"Expected a non-empty value for `permission_id` but received ''"): + await async_client.session.permissions.with_raw_response.respond( + permission_id="", + id="id", + response="once", + ) diff --git a/tests/api_resources/test_agent.py b/tests/api_resources/test_agent.py new file mode 100644 index 0000000..0cafe73 --- /dev/null +++ b/tests/api_resources/test_agent.py @@ -0,0 +1,96 @@ +# File generated from our OpenAPI spec by Stainless. See CONTRIBUTING.md for details. + +from __future__ import annotations + +import os +from typing import Any, cast + +import pytest + +from opencode_ai import Opencode, AsyncOpencode +from tests.utils import assert_matches_type +from opencode_ai.types import AgentListResponse + +base_url = os.environ.get("TEST_API_BASE_URL", "http://127.0.0.1:4010") + + +class TestAgent: + parametrize = pytest.mark.parametrize("client", [False, True], indirect=True, ids=["loose", "strict"]) + + @pytest.mark.skip(reason="Prism tests are disabled") + @parametrize + def test_method_list(self, client: Opencode) -> None: + agent = client.agent.list() + assert_matches_type(AgentListResponse, agent, path=["response"]) + + @pytest.mark.skip(reason="Prism tests are disabled") + @parametrize + def test_method_list_with_all_params(self, client: Opencode) -> None: + agent = client.agent.list( + directory="directory", + ) + assert_matches_type(AgentListResponse, agent, path=["response"]) + + @pytest.mark.skip(reason="Prism tests are disabled") + @parametrize + def test_raw_response_list(self, client: Opencode) -> None: + response = client.agent.with_raw_response.list() + + assert response.is_closed is True + assert response.http_request.headers.get("X-Stainless-Lang") == "python" + agent = response.parse() + assert_matches_type(AgentListResponse, agent, path=["response"]) + + @pytest.mark.skip(reason="Prism tests are disabled") + @parametrize + def test_streaming_response_list(self, client: Opencode) -> None: + with client.agent.with_streaming_response.list() as response: + assert not response.is_closed + assert response.http_request.headers.get("X-Stainless-Lang") == "python" + + agent = response.parse() + assert_matches_type(AgentListResponse, agent, path=["response"]) + + assert cast(Any, response.is_closed) is True + + +class TestAsyncAgent: + parametrize = pytest.mark.parametrize( + "async_client", [False, True, {"http_client": "aiohttp"}], indirect=True, ids=["loose", "strict", "aiohttp"] + ) + + @pytest.mark.skip(reason="Prism tests are disabled") + @parametrize + async def test_method_list(self, async_client: AsyncOpencode) -> None: + agent = await async_client.agent.list() + assert_matches_type(AgentListResponse, agent, path=["response"]) + + @pytest.mark.skip(reason="Prism tests are disabled") + @parametrize + async def test_method_list_with_all_params(self, async_client: AsyncOpencode) -> None: + agent = await async_client.agent.list( + directory="directory", + ) + assert_matches_type(AgentListResponse, agent, path=["response"]) + + @pytest.mark.skip(reason="Prism tests are disabled") + @parametrize + async def test_raw_response_list(self, async_client: AsyncOpencode) -> None: + response = await async_client.agent.with_raw_response.list() + + assert response.is_closed is True + assert response.http_request.headers.get("X-Stainless-Lang") == "python" + agent = await response.parse() + assert_matches_type(AgentListResponse, agent, path=["response"]) + + @pytest.mark.skip(reason="Prism tests are disabled") + @parametrize + async def test_streaming_response_list(self, async_client: AsyncOpencode) -> None: + async with async_client.agent.with_streaming_response.list() as response: + assert not response.is_closed + assert response.http_request.headers.get("X-Stainless-Lang") == "python" + + agent = await response.parse() + assert_matches_type(AgentListResponse, agent, path=["response"]) + + assert cast(Any, response.is_closed) is True diff --git a/tests/api_resources/test_app.py b/tests/api_resources/test_app.py new file mode 100644 index 0000000..8a9bf1e --- /dev/null +++ b/tests/api_resources/test_app.py @@ -0,0 +1,200 @@ +# File generated from our OpenAPI spec by Stainless. See CONTRIBUTING.md for details. + +from __future__ import annotations + +import os +from typing import Any, cast + +import pytest + +from opencode_ai import Opencode, AsyncOpencode +from tests.utils import assert_matches_type +from opencode_ai.types import AppLogResponse, AppProvidersResponse + +base_url = os.environ.get("TEST_API_BASE_URL", "http://127.0.0.1:4010") + + +class TestApp: + parametrize = pytest.mark.parametrize("client", [False, True], indirect=True, ids=["loose", "strict"]) + + @pytest.mark.skip(reason="Prism tests are disabled") + @parametrize + def test_method_log(self, client: Opencode) -> None: + app = client.app.log( + level="debug", + message="message", + service="service", + ) + assert_matches_type(AppLogResponse, app, path=["response"]) + + @pytest.mark.skip(reason="Prism tests are disabled") + @parametrize + def test_method_log_with_all_params(self, client: Opencode) -> None: + app = client.app.log( + level="debug", + message="message", + service="service", + directory="directory", + extra={"foo": "bar"}, + ) + assert_matches_type(AppLogResponse, app, path=["response"]) + + @pytest.mark.skip(reason="Prism tests are disabled") + @parametrize + def test_raw_response_log(self, client: Opencode) -> None: + response = client.app.with_raw_response.log( + level="debug", + message="message", + service="service", + ) + + assert response.is_closed is True + assert response.http_request.headers.get("X-Stainless-Lang") == "python" + app = response.parse() + assert_matches_type(AppLogResponse, app, path=["response"]) + + @pytest.mark.skip(reason="Prism tests are disabled") + @parametrize + def test_streaming_response_log(self, client: Opencode) -> None: + with client.app.with_streaming_response.log( + level="debug", + message="message", + service="service", + ) as response: + assert not response.is_closed + assert response.http_request.headers.get("X-Stainless-Lang") == "python" + + app = response.parse() + assert_matches_type(AppLogResponse, app, path=["response"]) + + assert cast(Any, response.is_closed) is True + + @pytest.mark.skip(reason="Prism tests are disabled") + @parametrize + def test_method_providers(self, client: Opencode) -> None: + app = client.app.providers() + assert_matches_type(AppProvidersResponse, app, path=["response"]) + + @pytest.mark.skip(reason="Prism tests are disabled") + @parametrize + def test_method_providers_with_all_params(self, client: Opencode) -> None: + app = client.app.providers( + directory="directory", + ) + assert_matches_type(AppProvidersResponse, app, path=["response"]) + + @pytest.mark.skip(reason="Prism tests are disabled") + @parametrize + def test_raw_response_providers(self, client: Opencode) -> None: + response = client.app.with_raw_response.providers() + + assert response.is_closed is True + assert response.http_request.headers.get("X-Stainless-Lang") == "python" + app = response.parse() + assert_matches_type(AppProvidersResponse, app, path=["response"]) + + @pytest.mark.skip(reason="Prism tests are disabled") + @parametrize + def test_streaming_response_providers(self, client: Opencode) -> None: + with client.app.with_streaming_response.providers() as response: + assert not response.is_closed + assert response.http_request.headers.get("X-Stainless-Lang") == "python" + + app = response.parse() + assert_matches_type(AppProvidersResponse, app, path=["response"]) + + assert cast(Any, response.is_closed) is True + + +class TestAsyncApp: + parametrize = pytest.mark.parametrize( + "async_client", [False, True, {"http_client": "aiohttp"}], indirect=True, ids=["loose", "strict", "aiohttp"] + ) + + @pytest.mark.skip(reason="Prism tests are disabled") + @parametrize + async def test_method_log(self, async_client: AsyncOpencode) -> None: + app = await async_client.app.log( + level="debug", + message="message", + service="service", + ) + assert_matches_type(AppLogResponse, app, path=["response"]) + + @pytest.mark.skip(reason="Prism tests are disabled") + @parametrize + async def test_method_log_with_all_params(self, async_client: AsyncOpencode) -> None: + app = await async_client.app.log( + level="debug", + message="message", + service="service", + directory="directory", + extra={"foo": "bar"}, + ) + assert_matches_type(AppLogResponse, app, path=["response"]) + + @pytest.mark.skip(reason="Prism tests are disabled") + @parametrize + async def test_raw_response_log(self, async_client: AsyncOpencode) -> None: + response = await async_client.app.with_raw_response.log( + level="debug", + message="message", + service="service", + ) + + assert response.is_closed is True + assert response.http_request.headers.get("X-Stainless-Lang") == "python" + app = await response.parse() + assert_matches_type(AppLogResponse, app, path=["response"]) + + @pytest.mark.skip(reason="Prism tests are disabled") + @parametrize + async def test_streaming_response_log(self, async_client: AsyncOpencode) -> None: + async with async_client.app.with_streaming_response.log( + level="debug", + message="message", + service="service", + ) as response: + assert not response.is_closed + assert response.http_request.headers.get("X-Stainless-Lang") == "python" + + app = await response.parse() + assert_matches_type(AppLogResponse, app, path=["response"]) + + assert cast(Any, response.is_closed) is True + + @pytest.mark.skip(reason="Prism tests are disabled") + @parametrize + async def test_method_providers(self, async_client: AsyncOpencode) -> None: + app = await async_client.app.providers() + assert_matches_type(AppProvidersResponse, app, path=["response"]) + + @pytest.mark.skip(reason="Prism tests are disabled") + @parametrize + async def test_method_providers_with_all_params(self, async_client: AsyncOpencode) -> None: + app = await async_client.app.providers( + directory="directory", + ) + assert_matches_type(AppProvidersResponse, app, path=["response"]) + + @pytest.mark.skip(reason="Prism tests are disabled") + @parametrize + async def test_raw_response_providers(self, async_client: AsyncOpencode) -> None: + response = await async_client.app.with_raw_response.providers() + + assert response.is_closed is True + assert response.http_request.headers.get("X-Stainless-Lang") == "python" + app = await response.parse() + assert_matches_type(AppProvidersResponse, app, path=["response"]) + + @pytest.mark.skip(reason="Prism tests are disabled") + @parametrize + async def test_streaming_response_providers(self, async_client: AsyncOpencode) -> None: + async with async_client.app.with_streaming_response.providers() as response: + assert not response.is_closed + assert response.http_request.headers.get("X-Stainless-Lang") == "python" + + app = await response.parse() + assert_matches_type(AppProvidersResponse, app, path=["response"]) + + assert cast(Any, response.is_closed) is True diff --git a/tests/api_resources/test_command.py b/tests/api_resources/test_command.py new file mode 100644 index 0000000..c966fef --- /dev/null +++ b/tests/api_resources/test_command.py @@ -0,0 +1,96 @@ +# File generated from our OpenAPI spec by Stainless. See CONTRIBUTING.md for details. + +from __future__ import annotations + +import os +from typing import Any, cast + +import pytest + +from opencode_ai import Opencode, AsyncOpencode +from tests.utils import assert_matches_type +from opencode_ai.types import CommandListResponse + +base_url = os.environ.get("TEST_API_BASE_URL", "http://127.0.0.1:4010") + + +class TestCommand: + parametrize = pytest.mark.parametrize("client", [False, True], indirect=True, ids=["loose", "strict"]) + + @pytest.mark.skip(reason="Prism tests are disabled") + @parametrize + def test_method_list(self, client: Opencode) -> None: + command = client.command.list() + assert_matches_type(CommandListResponse, command, path=["response"]) + + @pytest.mark.skip(reason="Prism tests are disabled") + @parametrize + def test_method_list_with_all_params(self, client: Opencode) -> None: + command = client.command.list( + directory="directory", + ) + assert_matches_type(CommandListResponse, command, path=["response"]) + + @pytest.mark.skip(reason="Prism tests are disabled") + @parametrize + def test_raw_response_list(self, client: Opencode) -> None: + response = client.command.with_raw_response.list() + + assert response.is_closed is True + assert response.http_request.headers.get("X-Stainless-Lang") == "python" + command = response.parse() + assert_matches_type(CommandListResponse, command, path=["response"]) + + @pytest.mark.skip(reason="Prism tests are disabled") + @parametrize + def test_streaming_response_list(self, client: Opencode) -> None: + with client.command.with_streaming_response.list() as response: + assert not response.is_closed + assert response.http_request.headers.get("X-Stainless-Lang") == "python" + + command = response.parse() + assert_matches_type(CommandListResponse, command, path=["response"]) + + assert cast(Any, response.is_closed) is True + + +class TestAsyncCommand: + parametrize = pytest.mark.parametrize( + "async_client", [False, True, {"http_client": "aiohttp"}], indirect=True, ids=["loose", "strict", "aiohttp"] + ) + + @pytest.mark.skip(reason="Prism tests are disabled") + @parametrize + async def test_method_list(self, async_client: AsyncOpencode) -> None: + command = await async_client.command.list() + assert_matches_type(CommandListResponse, command, path=["response"]) + + @pytest.mark.skip(reason="Prism tests are disabled") + @parametrize + async def test_method_list_with_all_params(self, async_client: AsyncOpencode) -> None: + command = await async_client.command.list( + directory="directory", + ) + assert_matches_type(CommandListResponse, command, path=["response"]) + + @pytest.mark.skip(reason="Prism tests are disabled") + @parametrize + async def test_raw_response_list(self, async_client: AsyncOpencode) -> None: + response = await async_client.command.with_raw_response.list() + + assert response.is_closed is True + assert response.http_request.headers.get("X-Stainless-Lang") == "python" + command = await response.parse() + assert_matches_type(CommandListResponse, command, path=["response"]) + + @pytest.mark.skip(reason="Prism tests are disabled") + @parametrize + async def test_streaming_response_list(self, async_client: AsyncOpencode) -> None: + async with async_client.command.with_streaming_response.list() as response: + assert not response.is_closed + assert response.http_request.headers.get("X-Stainless-Lang") == "python" + + command = await response.parse() + assert_matches_type(CommandListResponse, command, path=["response"]) + + assert cast(Any, response.is_closed) is True diff --git a/tests/api_resources/test_config.py b/tests/api_resources/test_config.py new file mode 100644 index 0000000..6c6f053 --- /dev/null +++ b/tests/api_resources/test_config.py @@ -0,0 +1,96 @@ +# File generated from our OpenAPI spec by Stainless. See CONTRIBUTING.md for details. + +from __future__ import annotations + +import os +from typing import Any, cast + +import pytest + +from opencode_ai import Opencode, AsyncOpencode +from tests.utils import assert_matches_type +from opencode_ai.types import Config + +base_url = os.environ.get("TEST_API_BASE_URL", "http://127.0.0.1:4010") + + +class TestConfig: + parametrize = pytest.mark.parametrize("client", [False, True], indirect=True, ids=["loose", "strict"]) + + @pytest.mark.skip(reason="Prism tests are disabled") + @parametrize + def test_method_get(self, client: Opencode) -> None: + config = client.config.get() + assert_matches_type(Config, config, path=["response"]) + + @pytest.mark.skip(reason="Prism tests are disabled") + @parametrize + def test_method_get_with_all_params(self, client: Opencode) -> None: + config = client.config.get( + directory="directory", + ) + assert_matches_type(Config, config, path=["response"]) + + @pytest.mark.skip(reason="Prism tests are disabled") + @parametrize + def test_raw_response_get(self, client: Opencode) -> None: + response = client.config.with_raw_response.get() + + assert response.is_closed is True + assert response.http_request.headers.get("X-Stainless-Lang") == "python" + config = response.parse() + assert_matches_type(Config, config, path=["response"]) + + @pytest.mark.skip(reason="Prism tests are disabled") + @parametrize + def test_streaming_response_get(self, client: Opencode) -> None: + with client.config.with_streaming_response.get() as response: + assert not response.is_closed + assert response.http_request.headers.get("X-Stainless-Lang") == "python" + + config = response.parse() + assert_matches_type(Config, config, path=["response"]) + + assert cast(Any, response.is_closed) is True + + +class TestAsyncConfig: + parametrize = pytest.mark.parametrize( + "async_client", [False, True, {"http_client": "aiohttp"}], indirect=True, ids=["loose", "strict", "aiohttp"] + ) + + @pytest.mark.skip(reason="Prism tests are disabled") + @parametrize + async def test_method_get(self, async_client: AsyncOpencode) -> None: + config = await async_client.config.get() + assert_matches_type(Config, config, path=["response"]) + + @pytest.mark.skip(reason="Prism tests are disabled") + @parametrize + async def test_method_get_with_all_params(self, async_client: AsyncOpencode) -> None: + config = await async_client.config.get( + directory="directory", + ) + assert_matches_type(Config, config, path=["response"]) + + @pytest.mark.skip(reason="Prism tests are disabled") + @parametrize + async def test_raw_response_get(self, async_client: AsyncOpencode) -> None: + response = await async_client.config.with_raw_response.get() + + assert response.is_closed is True + assert response.http_request.headers.get("X-Stainless-Lang") == "python" + config = await response.parse() + assert_matches_type(Config, config, path=["response"]) + + @pytest.mark.skip(reason="Prism tests are disabled") + @parametrize + async def test_streaming_response_get(self, async_client: AsyncOpencode) -> None: + async with async_client.config.with_streaming_response.get() as response: + assert not response.is_closed + assert response.http_request.headers.get("X-Stainless-Lang") == "python" + + config = await response.parse() + assert_matches_type(Config, config, path=["response"]) + + assert cast(Any, response.is_closed) is True diff --git a/tests/api_resources/test_event.py b/tests/api_resources/test_event.py new file mode 100644 index 0000000..6955fbe --- /dev/null +++ b/tests/api_resources/test_event.py @@ -0,0 +1,92 @@ +# File generated from our OpenAPI spec by Stainless. See CONTRIBUTING.md for details. + +from __future__ import annotations + +import os +from typing import Any, cast + +import pytest + +from opencode_ai import Opencode, AsyncOpencode + +base_url = os.environ.get("TEST_API_BASE_URL", "http://127.0.0.1:4010") + + +class TestEvent: + parametrize = pytest.mark.parametrize("client", [False, True], indirect=True, ids=["loose", "strict"]) + + @pytest.mark.skip(reason="Prism doesn't support text/event-stream responses") + @parametrize + def test_method_list(self, client: Opencode) -> None: + event_stream = client.event.list() + event_stream.response.close() + + @pytest.mark.skip(reason="Prism doesn't support text/event-stream responses") + @parametrize + def test_method_list_with_all_params(self, client: Opencode) -> None: + event_stream = client.event.list( + directory="directory", + ) + event_stream.response.close() + + @pytest.mark.skip(reason="Prism doesn't support text/event-stream responses") + @parametrize + def test_raw_response_list(self, client: Opencode) -> None: + response = client.event.with_raw_response.list() + + assert response.http_request.headers.get("X-Stainless-Lang") == "python" + stream = response.parse() + stream.close() + + @pytest.mark.skip(reason="Prism doesn't support text/event-stream responses") + @parametrize + def test_streaming_response_list(self, client: Opencode) -> None: + with client.event.with_streaming_response.list() as response: + assert not response.is_closed + assert response.http_request.headers.get("X-Stainless-Lang") == "python" + + stream = response.parse() + stream.close() + + assert cast(Any, response.is_closed) is True + + +class TestAsyncEvent: + parametrize = pytest.mark.parametrize( + "async_client", [False, True, {"http_client": "aiohttp"}], indirect=True, ids=["loose", "strict", "aiohttp"] + ) + + @pytest.mark.skip(reason="Prism doesn't support text/event-stream responses") + @parametrize + async def test_method_list(self, async_client: AsyncOpencode) -> None: + event_stream = await async_client.event.list() + await event_stream.response.aclose() + + @pytest.mark.skip(reason="Prism doesn't support text/event-stream responses") + @parametrize + async def test_method_list_with_all_params(self, async_client: AsyncOpencode) -> None: + event_stream = await async_client.event.list( + directory="directory", + ) + await event_stream.response.aclose() + + @pytest.mark.skip(reason="Prism doesn't support text/event-stream responses") + @parametrize + async def test_raw_response_list(self, async_client: AsyncOpencode) -> None: + response = await async_client.event.with_raw_response.list() + + assert response.http_request.headers.get("X-Stainless-Lang") == "python" + stream = await response.parse() + await stream.close() + + @pytest.mark.skip(reason="Prism doesn't support text/event-stream responses") + @parametrize + async def test_streaming_response_list(self, async_client: AsyncOpencode) -> None: + async with async_client.event.with_streaming_response.list() as response: + assert not response.is_closed + assert response.http_request.headers.get("X-Stainless-Lang") == "python" + + stream = await response.parse() + await stream.close() + + assert cast(Any, response.is_closed) is True diff --git a/tests/api_resources/test_file.py b/tests/api_resources/test_file.py new file mode 100644 index 0000000..5641905 --- /dev/null +++ b/tests/api_resources/test_file.py @@ -0,0 +1,272 @@ +# File generated from our OpenAPI spec by Stainless. See CONTRIBUTING.md for details. + +from __future__ import annotations + +import os +from typing import Any, cast + +import pytest + +from opencode_ai import Opencode, AsyncOpencode +from tests.utils import assert_matches_type +from opencode_ai.types import ( + FileListResponse, + FileReadResponse, + FileStatusResponse, +) + +base_url = os.environ.get("TEST_API_BASE_URL", "http://127.0.0.1:4010") + + +class TestFile: + parametrize = pytest.mark.parametrize("client", [False, True], indirect=True, ids=["loose", "strict"]) + + @pytest.mark.skip(reason="Prism tests are disabled") + @parametrize + def test_method_list(self, client: Opencode) -> None: + file = client.file.list( + path="path", + ) + assert_matches_type(FileListResponse, file, path=["response"]) + + @pytest.mark.skip(reason="Prism tests are disabled") + @parametrize + def test_method_list_with_all_params(self, client: Opencode) -> None: + file = client.file.list( + path="path", + directory="directory", + ) + assert_matches_type(FileListResponse, file, path=["response"]) + + @pytest.mark.skip(reason="Prism tests are disabled") + @parametrize + def test_raw_response_list(self, client: Opencode) -> None: + response = client.file.with_raw_response.list( + path="path", + ) + + assert response.is_closed is True + assert response.http_request.headers.get("X-Stainless-Lang") == "python" + file = response.parse() + assert_matches_type(FileListResponse, file, path=["response"]) + + @pytest.mark.skip(reason="Prism tests are disabled") + @parametrize + def test_streaming_response_list(self, client: Opencode) -> None: + with client.file.with_streaming_response.list( + path="path", + ) as response: + assert not response.is_closed + assert response.http_request.headers.get("X-Stainless-Lang") == "python" + + file = response.parse() + assert_matches_type(FileListResponse, file, path=["response"]) + + assert cast(Any, response.is_closed) is True + + @pytest.mark.skip(reason="Prism tests are disabled") + @parametrize + def test_method_read(self, client: Opencode) -> None: + file = client.file.read( + path="path", + ) + assert_matches_type(FileReadResponse, file, path=["response"]) + + @pytest.mark.skip(reason="Prism tests are disabled") + @parametrize + def test_method_read_with_all_params(self, client: Opencode) -> None: + file = client.file.read( + path="path", + directory="directory", + ) + assert_matches_type(FileReadResponse, file, path=["response"]) + + @pytest.mark.skip(reason="Prism tests are disabled") + @parametrize + def test_raw_response_read(self, client: Opencode) -> None: + response = client.file.with_raw_response.read( + path="path", + ) + + assert response.is_closed is True + assert response.http_request.headers.get("X-Stainless-Lang") == "python" + file = response.parse() + assert_matches_type(FileReadResponse, file, path=["response"]) + + @pytest.mark.skip(reason="Prism tests are disabled") + @parametrize + def test_streaming_response_read(self, client: Opencode) -> None: + with client.file.with_streaming_response.read( + path="path", + ) as response: + assert not response.is_closed + assert response.http_request.headers.get("X-Stainless-Lang") == "python" + + file = response.parse() + assert_matches_type(FileReadResponse, file, path=["response"]) + + assert cast(Any, response.is_closed) is True + + @pytest.mark.skip(reason="Prism tests are disabled") + @parametrize + def test_method_status(self, client: Opencode) -> None: + file = client.file.status() + assert_matches_type(FileStatusResponse, file, path=["response"]) + + @pytest.mark.skip(reason="Prism tests are disabled") + @parametrize + def test_method_status_with_all_params(self, client: Opencode) -> None: + file = client.file.status( + directory="directory", + ) + assert_matches_type(FileStatusResponse, file, path=["response"]) + + @pytest.mark.skip(reason="Prism tests are disabled") + @parametrize + def test_raw_response_status(self, client: Opencode) -> None: + response = client.file.with_raw_response.status() + + assert response.is_closed is True + assert response.http_request.headers.get("X-Stainless-Lang") == "python" + file = response.parse() + assert_matches_type(FileStatusResponse, file, path=["response"]) + + @pytest.mark.skip(reason="Prism tests are disabled") + @parametrize + def test_streaming_response_status(self, client: Opencode) -> None: + with client.file.with_streaming_response.status() as response: + assert not response.is_closed + assert response.http_request.headers.get("X-Stainless-Lang") == "python" + + file = response.parse() + assert_matches_type(FileStatusResponse, file, path=["response"]) + + assert cast(Any, response.is_closed) is True + + +class TestAsyncFile: + parametrize = pytest.mark.parametrize( + "async_client", [False, True, {"http_client": "aiohttp"}], indirect=True, ids=["loose", "strict", "aiohttp"] + ) + + @pytest.mark.skip(reason="Prism tests are disabled") + @parametrize + async def test_method_list(self, async_client: AsyncOpencode) -> None: + file = await async_client.file.list( + path="path", + ) + assert_matches_type(FileListResponse, file, path=["response"]) + + @pytest.mark.skip(reason="Prism tests are disabled") + @parametrize + async def test_method_list_with_all_params(self, async_client: AsyncOpencode) -> None: + file = await async_client.file.list( + path="path", + directory="directory", + ) + assert_matches_type(FileListResponse, file, path=["response"]) + + @pytest.mark.skip(reason="Prism tests are disabled") + @parametrize + async def test_raw_response_list(self, async_client: AsyncOpencode) -> None: + response = await async_client.file.with_raw_response.list( + path="path", + ) + + assert response.is_closed is True + assert response.http_request.headers.get("X-Stainless-Lang") == "python" + file = await response.parse() + assert_matches_type(FileListResponse, file, path=["response"]) + + @pytest.mark.skip(reason="Prism tests are disabled") + @parametrize + async def test_streaming_response_list(self, async_client: AsyncOpencode) -> None: + async with async_client.file.with_streaming_response.list( + path="path", + ) as response: + assert not response.is_closed + assert response.http_request.headers.get("X-Stainless-Lang") == "python" + + file = await response.parse() + assert_matches_type(FileListResponse, file, path=["response"]) + + assert cast(Any, response.is_closed) is True + + @pytest.mark.skip(reason="Prism tests are disabled") + @parametrize + async def test_method_read(self, async_client: AsyncOpencode) -> None: + file = await async_client.file.read( + path="path", + ) + assert_matches_type(FileReadResponse, file, path=["response"]) + + @pytest.mark.skip(reason="Prism tests are disabled") + @parametrize + async def test_method_read_with_all_params(self, async_client: AsyncOpencode) -> None: + file = await async_client.file.read( + path="path", + directory="directory", + ) + assert_matches_type(FileReadResponse, file, path=["response"]) + + @pytest.mark.skip(reason="Prism tests are disabled") + @parametrize + async def test_raw_response_read(self, async_client: AsyncOpencode) -> None: + response = await async_client.file.with_raw_response.read( + path="path", + ) + + assert response.is_closed is True + assert response.http_request.headers.get("X-Stainless-Lang") == "python" + file = await response.parse() + assert_matches_type(FileReadResponse, file, path=["response"]) + + @pytest.mark.skip(reason="Prism tests are disabled") + @parametrize + async def test_streaming_response_read(self, async_client: AsyncOpencode) -> None: + async with async_client.file.with_streaming_response.read( + path="path", + ) as response: + assert not response.is_closed + assert response.http_request.headers.get("X-Stainless-Lang") == "python" + + file = await response.parse() + assert_matches_type(FileReadResponse, file, path=["response"]) + + assert cast(Any, response.is_closed) is True + + @pytest.mark.skip(reason="Prism tests are disabled") + @parametrize + async def test_method_status(self, async_client: AsyncOpencode) -> None: + file = await async_client.file.status() + assert_matches_type(FileStatusResponse, file, path=["response"]) + + @pytest.mark.skip(reason="Prism tests are disabled") + @parametrize + async def test_method_status_with_all_params(self, async_client: AsyncOpencode) -> None: + file = await async_client.file.status( + directory="directory", + ) + assert_matches_type(FileStatusResponse, file, path=["response"]) + + @pytest.mark.skip(reason="Prism tests are disabled") + @parametrize + async def test_raw_response_status(self, async_client: AsyncOpencode) -> None: + response = await async_client.file.with_raw_response.status() + + assert response.is_closed is True + assert response.http_request.headers.get("X-Stainless-Lang") == "python" + file = await response.parse() + assert_matches_type(FileStatusResponse, file, path=["response"]) + + @pytest.mark.skip(reason="Prism tests are disabled") + @parametrize + async def test_streaming_response_status(self, async_client: AsyncOpencode) -> None: + async with async_client.file.with_streaming_response.status() as response: + assert not response.is_closed + assert response.http_request.headers.get("X-Stainless-Lang") == "python" + + file = await response.parse() + assert_matches_type(FileStatusResponse, file, path=["response"]) + + assert cast(Any, response.is_closed) is True diff --git a/tests/api_resources/test_find.py b/tests/api_resources/test_find.py new file mode 100644 index 0000000..40b1e5d --- /dev/null +++ b/tests/api_resources/test_find.py @@ -0,0 +1,286 @@ +# File generated from our OpenAPI spec by Stainless. See CONTRIBUTING.md for details. + +from __future__ import annotations + +import os +from typing import Any, cast + +import pytest + +from opencode_ai import Opencode, AsyncOpencode +from tests.utils import assert_matches_type +from opencode_ai.types import ( + FindTextResponse, + FindFilesResponse, + FindSymbolsResponse, +) + +base_url = os.environ.get("TEST_API_BASE_URL", "http://127.0.0.1:4010") + + +class TestFind: + parametrize = pytest.mark.parametrize("client", [False, True], indirect=True, ids=["loose", "strict"]) + + @pytest.mark.skip(reason="Prism tests are disabled") + @parametrize + def test_method_files(self, client: Opencode) -> None: + find = client.find.files( + query="query", + ) + assert_matches_type(FindFilesResponse, find, path=["response"]) + + @pytest.mark.skip(reason="Prism tests are disabled") + @parametrize + def test_method_files_with_all_params(self, client: Opencode) -> None: + find = client.find.files( + query="query", + directory="directory", + ) + assert_matches_type(FindFilesResponse, find, path=["response"]) + + @pytest.mark.skip(reason="Prism tests are disabled") + @parametrize + def test_raw_response_files(self, client: Opencode) -> None: + response = client.find.with_raw_response.files( + query="query", + ) + + assert response.is_closed is True + assert response.http_request.headers.get("X-Stainless-Lang") == "python" + find = response.parse() + assert_matches_type(FindFilesResponse, find, path=["response"]) + + @pytest.mark.skip(reason="Prism tests are disabled") + @parametrize + def test_streaming_response_files(self, client: Opencode) -> None: + with client.find.with_streaming_response.files( + query="query", + ) as response: + assert not response.is_closed + assert response.http_request.headers.get("X-Stainless-Lang") == "python" + + find = response.parse() + assert_matches_type(FindFilesResponse, find, path=["response"]) + + assert cast(Any, response.is_closed) is True + + @pytest.mark.skip(reason="Prism tests are disabled") + @parametrize + def test_method_symbols(self, client: Opencode) -> None: + find = client.find.symbols( + query="query", + ) + assert_matches_type(FindSymbolsResponse, find, path=["response"]) + + @pytest.mark.skip(reason="Prism tests are disabled") + @parametrize + def test_method_symbols_with_all_params(self, client: Opencode) -> None: + find = client.find.symbols( + query="query", + directory="directory", + ) + assert_matches_type(FindSymbolsResponse, find, path=["response"]) + + @pytest.mark.skip(reason="Prism tests are disabled") + @parametrize + def test_raw_response_symbols(self, client: Opencode) -> None: + response = client.find.with_raw_response.symbols( + query="query", + ) + + assert response.is_closed is True + assert response.http_request.headers.get("X-Stainless-Lang") == "python" + find = response.parse() + assert_matches_type(FindSymbolsResponse, find, path=["response"]) + + @pytest.mark.skip(reason="Prism tests are disabled") + @parametrize + def test_streaming_response_symbols(self, client: Opencode) -> None: + with client.find.with_streaming_response.symbols( + query="query", + ) as response: + assert not response.is_closed + assert response.http_request.headers.get("X-Stainless-Lang") == "python" + + find = response.parse() + assert_matches_type(FindSymbolsResponse, find, path=["response"]) + + assert cast(Any, response.is_closed) is True + + @pytest.mark.skip(reason="Prism tests are disabled") + @parametrize + def test_method_text(self, client: Opencode) -> None: + find = client.find.text( + pattern="pattern", + ) + assert_matches_type(FindTextResponse, find, path=["response"]) + + @pytest.mark.skip(reason="Prism tests are disabled") + @parametrize + def test_method_text_with_all_params(self, client: Opencode) -> None: + find = client.find.text( + pattern="pattern", + directory="directory", + ) + assert_matches_type(FindTextResponse, find, path=["response"]) + + @pytest.mark.skip(reason="Prism tests are disabled") + @parametrize + def test_raw_response_text(self, client: Opencode) -> None: + response = client.find.with_raw_response.text( + pattern="pattern", + ) + + assert response.is_closed is True + assert response.http_request.headers.get("X-Stainless-Lang") == "python" + find = response.parse() + assert_matches_type(FindTextResponse, find, path=["response"]) + + @pytest.mark.skip(reason="Prism tests are disabled") + @parametrize + def test_streaming_response_text(self, client: Opencode) -> None: + with client.find.with_streaming_response.text( + pattern="pattern", + ) as response: + assert not response.is_closed + assert response.http_request.headers.get("X-Stainless-Lang") == "python" + + find = response.parse() + assert_matches_type(FindTextResponse, find, path=["response"]) + + assert cast(Any, response.is_closed) is True + + +class TestAsyncFind: + parametrize = pytest.mark.parametrize( + "async_client", [False, True, {"http_client": "aiohttp"}], indirect=True, ids=["loose", "strict", "aiohttp"] + ) + + @pytest.mark.skip(reason="Prism tests are disabled") + @parametrize + async def test_method_files(self, async_client: AsyncOpencode) -> None: + find = await async_client.find.files( + query="query", + ) + assert_matches_type(FindFilesResponse, find, path=["response"]) + + @pytest.mark.skip(reason="Prism tests are disabled") + @parametrize + async def test_method_files_with_all_params(self, async_client: AsyncOpencode) -> None: + find = await async_client.find.files( + query="query", + directory="directory", + ) + assert_matches_type(FindFilesResponse, find, path=["response"]) + + @pytest.mark.skip(reason="Prism tests are disabled") + @parametrize + async def test_raw_response_files(self, async_client: AsyncOpencode) -> None: + response = await async_client.find.with_raw_response.files( + query="query", + ) + + assert response.is_closed is True + assert response.http_request.headers.get("X-Stainless-Lang") == "python" + find = await response.parse() + assert_matches_type(FindFilesResponse, find, path=["response"]) + + @pytest.mark.skip(reason="Prism tests are disabled") + @parametrize + async def test_streaming_response_files(self, async_client: AsyncOpencode) -> None: + async with async_client.find.with_streaming_response.files( + query="query", + ) as response: + assert not response.is_closed + assert response.http_request.headers.get("X-Stainless-Lang") == "python" + + find = await response.parse() + assert_matches_type(FindFilesResponse, find, path=["response"]) + + assert cast(Any, response.is_closed) is True + + @pytest.mark.skip(reason="Prism tests are disabled") + @parametrize + async def test_method_symbols(self, async_client: AsyncOpencode) -> None: + find = await async_client.find.symbols( + query="query", + ) + assert_matches_type(FindSymbolsResponse, find, path=["response"]) + + @pytest.mark.skip(reason="Prism tests are disabled") + @parametrize + async def test_method_symbols_with_all_params(self, async_client: AsyncOpencode) -> None: + find = await async_client.find.symbols( + query="query", + directory="directory", + ) + assert_matches_type(FindSymbolsResponse, find, path=["response"]) + + @pytest.mark.skip(reason="Prism tests are disabled") + @parametrize + async def test_raw_response_symbols(self, async_client: AsyncOpencode) -> None: + response = await async_client.find.with_raw_response.symbols( + query="query", + ) + + assert response.is_closed is True + assert response.http_request.headers.get("X-Stainless-Lang") == "python" + find = await response.parse() + assert_matches_type(FindSymbolsResponse, find, path=["response"]) + + @pytest.mark.skip(reason="Prism tests are disabled") + @parametrize + async def test_streaming_response_symbols(self, async_client: AsyncOpencode) -> None: + async with async_client.find.with_streaming_response.symbols( + query="query", + ) as response: + assert not response.is_closed + assert response.http_request.headers.get("X-Stainless-Lang") == "python" + + find = await response.parse() + assert_matches_type(FindSymbolsResponse, find, path=["response"]) + + assert cast(Any, response.is_closed) is True + + @pytest.mark.skip(reason="Prism tests are disabled") + @parametrize + async def test_method_text(self, async_client: AsyncOpencode) -> None: + find = await async_client.find.text( + pattern="pattern", + ) + assert_matches_type(FindTextResponse, find, path=["response"]) + + @pytest.mark.skip(reason="Prism tests are disabled") + @parametrize + async def test_method_text_with_all_params(self, async_client: AsyncOpencode) -> None: + find = await async_client.find.text( + pattern="pattern", + directory="directory", + ) + assert_matches_type(FindTextResponse, find, path=["response"]) + + @pytest.mark.skip(reason="Prism tests are disabled") + @parametrize + async def test_raw_response_text(self, async_client: AsyncOpencode) -> None: + response = await async_client.find.with_raw_response.text( + pattern="pattern", + ) + + assert response.is_closed is True + assert response.http_request.headers.get("X-Stainless-Lang") == "python" + find = await response.parse() + assert_matches_type(FindTextResponse, find, path=["response"]) + + @pytest.mark.skip(reason="Prism tests are disabled") + @parametrize + async def test_streaming_response_text(self, async_client: AsyncOpencode) -> None: + async with async_client.find.with_streaming_response.text( + pattern="pattern", + ) as response: + assert not response.is_closed + assert response.http_request.headers.get("X-Stainless-Lang") == "python" + + find = await response.parse() + assert_matches_type(FindTextResponse, find, path=["response"]) + + assert cast(Any, response.is_closed) is True diff --git a/tests/api_resources/test_path.py b/tests/api_resources/test_path.py new file mode 100644 index 0000000..c612834 --- /dev/null +++ b/tests/api_resources/test_path.py @@ -0,0 +1,96 @@ +# File generated from our OpenAPI spec by Stainless. See CONTRIBUTING.md for details. + +from __future__ import annotations + +import os +from typing import Any, cast + +import pytest + +from opencode_ai import Opencode, AsyncOpencode +from tests.utils import assert_matches_type +from opencode_ai.types import Path + +base_url = os.environ.get("TEST_API_BASE_URL", "http://127.0.0.1:4010") + + +class TestPath: + parametrize = pytest.mark.parametrize("client", [False, True], indirect=True, ids=["loose", "strict"]) + + @pytest.mark.skip(reason="Prism tests are disabled") + @parametrize + def test_method_get(self, client: Opencode) -> None: + path = client.path.get() + assert_matches_type(Path, path, path=["response"]) + + @pytest.mark.skip(reason="Prism tests are disabled") + @parametrize + def test_method_get_with_all_params(self, client: Opencode) -> None: + path = client.path.get( + directory="directory", + ) + assert_matches_type(Path, path, path=["response"]) + + @pytest.mark.skip(reason="Prism tests are disabled") + @parametrize + def test_raw_response_get(self, client: Opencode) -> None: + response = client.path.with_raw_response.get() + + assert response.is_closed is True + assert response.http_request.headers.get("X-Stainless-Lang") == "python" + path = response.parse() + assert_matches_type(Path, path, path=["response"]) + + @pytest.mark.skip(reason="Prism tests are disabled") + @parametrize + def test_streaming_response_get(self, client: Opencode) -> None: + with client.path.with_streaming_response.get() as response: + assert not response.is_closed + assert response.http_request.headers.get("X-Stainless-Lang") == "python" + + path = response.parse() + assert_matches_type(Path, path, path=["response"]) + + assert cast(Any, response.is_closed) is True + + +class TestAsyncPath: + parametrize = pytest.mark.parametrize( + "async_client", [False, True, {"http_client": "aiohttp"}], indirect=True, ids=["loose", "strict", "aiohttp"] + ) + + @pytest.mark.skip(reason="Prism tests are disabled") + @parametrize + async def test_method_get(self, async_client: AsyncOpencode) -> None: + path = await async_client.path.get() + assert_matches_type(Path, path, path=["response"]) + + @pytest.mark.skip(reason="Prism tests are disabled") + @parametrize + async def test_method_get_with_all_params(self, async_client: AsyncOpencode) -> None: + path = await async_client.path.get( + directory="directory", + ) + assert_matches_type(Path, path, path=["response"]) + + @pytest.mark.skip(reason="Prism tests are disabled") + @parametrize + async def test_raw_response_get(self, async_client: AsyncOpencode) -> None: + response = await async_client.path.with_raw_response.get() + + assert response.is_closed is True + assert response.http_request.headers.get("X-Stainless-Lang") == "python" + path = await response.parse() + assert_matches_type(Path, path, path=["response"]) + + @pytest.mark.skip(reason="Prism tests are disabled") + @parametrize + async def test_streaming_response_get(self, async_client: AsyncOpencode) -> None: + async with async_client.path.with_streaming_response.get() as response: + assert not response.is_closed + assert response.http_request.headers.get("X-Stainless-Lang") == "python" + + path = await response.parse() + assert_matches_type(Path, path, path=["response"]) + + assert cast(Any, response.is_closed) is True diff --git a/tests/api_resources/test_project.py b/tests/api_resources/test_project.py new file mode 100644 index 0000000..b0ea3f4 --- /dev/null +++ b/tests/api_resources/test_project.py @@ -0,0 +1,168 @@ +# File generated from our OpenAPI spec by Stainless. See CONTRIBUTING.md for details. + +from __future__ import annotations + +import os +from typing import Any, cast + +import pytest + +from opencode_ai import Opencode, AsyncOpencode +from tests.utils import assert_matches_type +from opencode_ai.types import Project, ProjectListResponse + +base_url = os.environ.get("TEST_API_BASE_URL", "http://127.0.0.1:4010") + + +class TestProject: + parametrize = pytest.mark.parametrize("client", [False, True], indirect=True, ids=["loose", "strict"]) + + @pytest.mark.skip(reason="Prism tests are disabled") + @parametrize + def test_method_list(self, client: Opencode) -> None: + project = client.project.list() + assert_matches_type(ProjectListResponse, project, path=["response"]) + + @pytest.mark.skip(reason="Prism tests are disabled") + @parametrize + def test_method_list_with_all_params(self, client: Opencode) -> None: + project = client.project.list( + directory="directory", + ) + assert_matches_type(ProjectListResponse, project, path=["response"]) + + @pytest.mark.skip(reason="Prism tests are disabled") + @parametrize + def test_raw_response_list(self, client: Opencode) -> None: + response = client.project.with_raw_response.list() + + assert response.is_closed is True + assert response.http_request.headers.get("X-Stainless-Lang") == "python" + project = response.parse() + assert_matches_type(ProjectListResponse, project, path=["response"]) + + @pytest.mark.skip(reason="Prism tests are disabled") + @parametrize + def test_streaming_response_list(self, client: Opencode) -> None: + with client.project.with_streaming_response.list() as response: + assert not response.is_closed + assert response.http_request.headers.get("X-Stainless-Lang") == "python" + + project = response.parse() + assert_matches_type(ProjectListResponse, project, path=["response"]) + + assert cast(Any, response.is_closed) is True + + @pytest.mark.skip(reason="Prism tests are disabled") + @parametrize + def test_method_current(self, client: Opencode) -> None: + project = client.project.current() + assert_matches_type(Project, project, path=["response"]) + + @pytest.mark.skip(reason="Prism tests are disabled") + @parametrize + def test_method_current_with_all_params(self, client: Opencode) -> None: + project = client.project.current( + directory="directory", + ) + assert_matches_type(Project, project, path=["response"]) + + @pytest.mark.skip(reason="Prism tests are disabled") + @parametrize + def test_raw_response_current(self, client: Opencode) -> None: + response = client.project.with_raw_response.current() + + assert response.is_closed is True + assert response.http_request.headers.get("X-Stainless-Lang") == "python" + project = response.parse() + assert_matches_type(Project, project, path=["response"]) + + @pytest.mark.skip(reason="Prism tests are disabled") + @parametrize + def test_streaming_response_current(self, client: Opencode) -> None: + with client.project.with_streaming_response.current() as response: + assert not response.is_closed + assert response.http_request.headers.get("X-Stainless-Lang") == "python" + + project = response.parse() + assert_matches_type(Project, project, path=["response"]) + + assert cast(Any, response.is_closed) is True + + +class TestAsyncProject: + parametrize = pytest.mark.parametrize( + "async_client", [False, True, {"http_client": "aiohttp"}], indirect=True, ids=["loose", "strict", "aiohttp"] + ) + + @pytest.mark.skip(reason="Prism tests are disabled") + @parametrize + async def test_method_list(self, async_client: AsyncOpencode) -> None: + project = await async_client.project.list() + assert_matches_type(ProjectListResponse, project, path=["response"]) + + @pytest.mark.skip(reason="Prism tests are disabled") + @parametrize + async def test_method_list_with_all_params(self, async_client: AsyncOpencode) -> None: + project = await async_client.project.list( + directory="directory", + ) + assert_matches_type(ProjectListResponse, project, path=["response"]) + + @pytest.mark.skip(reason="Prism tests are disabled") + @parametrize + async def test_raw_response_list(self, async_client: AsyncOpencode) -> None: + response = await async_client.project.with_raw_response.list() + + assert response.is_closed is True + assert response.http_request.headers.get("X-Stainless-Lang") == "python" + project = await response.parse() + assert_matches_type(ProjectListResponse, project, path=["response"]) + + @pytest.mark.skip(reason="Prism tests are disabled") + @parametrize + async def test_streaming_response_list(self, async_client: AsyncOpencode) -> None: + async with async_client.project.with_streaming_response.list() as response: + assert not response.is_closed + assert response.http_request.headers.get("X-Stainless-Lang") == "python" + + project = await response.parse() + assert_matches_type(ProjectListResponse, project, path=["response"]) + + assert cast(Any, response.is_closed) is True + + @pytest.mark.skip(reason="Prism tests are disabled") + @parametrize + async def test_method_current(self, async_client: AsyncOpencode) -> None: + project = await async_client.project.current() + assert_matches_type(Project, project, path=["response"]) + + @pytest.mark.skip(reason="Prism tests are disabled") + @parametrize + async def test_method_current_with_all_params(self, async_client: AsyncOpencode) -> None: + project = await async_client.project.current( + directory="directory", + ) + assert_matches_type(Project, project, path=["response"]) + + @pytest.mark.skip(reason="Prism tests are disabled") + @parametrize + async def test_raw_response_current(self, async_client: AsyncOpencode) -> None: + response = await async_client.project.with_raw_response.current() + + assert response.is_closed is True + assert response.http_request.headers.get("X-Stainless-Lang") == "python" + project = await response.parse() + assert_matches_type(Project, project, path=["response"]) + + @pytest.mark.skip(reason="Prism tests are disabled") + @parametrize + async def test_streaming_response_current(self, async_client: AsyncOpencode) -> None: + async with async_client.project.with_streaming_response.current() as response: + assert not response.is_closed + assert response.http_request.headers.get("X-Stainless-Lang") == "python" + + project = await response.parse() + assert_matches_type(Project, project, path=["response"]) + + assert cast(Any, response.is_closed) is True diff --git a/tests/api_resources/test_session.py b/tests/api_resources/test_session.py new file mode 100644 index 0000000..555bb75 --- /dev/null +++ b/tests/api_resources/test_session.py @@ -0,0 +1,2037 @@ +# File generated from our OpenAPI spec by Stainless. See CONTRIBUTING.md for details. + +from __future__ import annotations + +import os +from typing import Any, cast + +import pytest + +from opencode_ai import Opencode, AsyncOpencode +from tests.utils import assert_matches_type +from opencode_ai.types import ( + AssistantMessage, + SessionInitResponse, + SessionListResponse, + SessionAbortResponse, + SessionDeleteResponse, + SessionPromptResponse, + SessionCommandResponse, + SessionMessageResponse, + SessionChildrenResponse, + SessionMessagesResponse, + SessionSummarizeResponse, +) +from opencode_ai.types.session import Session + +base_url = os.environ.get("TEST_API_BASE_URL", "http://127.0.0.1:4010") + + +class TestSession: + parametrize = pytest.mark.parametrize("client", [False, True], indirect=True, ids=["loose", "strict"]) + + @pytest.mark.skip(reason="Prism tests are disabled") + @parametrize + def test_method_create(self, client: Opencode) -> None: + session = client.session.create() + assert_matches_type(Session, session, path=["response"]) + + @pytest.mark.skip(reason="Prism tests are disabled") + @parametrize + def test_method_create_with_all_params(self, client: Opencode) -> None: + session = client.session.create( + directory="directory", + parent_id="parentID", + title="title", + ) + assert_matches_type(Session, session, path=["response"]) + + @pytest.mark.skip(reason="Prism tests are disabled") + @parametrize + def test_raw_response_create(self, client: Opencode) -> None: + response = client.session.with_raw_response.create() + + assert response.is_closed is True + assert response.http_request.headers.get("X-Stainless-Lang") == "python" + session = response.parse() + assert_matches_type(Session, session, path=["response"]) + + @pytest.mark.skip(reason="Prism tests are disabled") + @parametrize + def test_streaming_response_create(self, client: Opencode) -> None: + with client.session.with_streaming_response.create() as response: + assert not response.is_closed + assert response.http_request.headers.get("X-Stainless-Lang") == "python" + + session = response.parse() + assert_matches_type(Session, session, path=["response"]) + + assert cast(Any, response.is_closed) is True + + @pytest.mark.skip(reason="Prism tests are disabled") + @parametrize + def test_method_update(self, client: Opencode) -> None: + session = client.session.update( + id="id", + ) + assert_matches_type(Session, session, path=["response"]) + + @pytest.mark.skip(reason="Prism tests are disabled") + @parametrize + def test_method_update_with_all_params(self, client: Opencode) -> None: + session = client.session.update( + id="id", + directory="directory", + title="title", + ) + assert_matches_type(Session, session, path=["response"]) + + @pytest.mark.skip(reason="Prism tests are disabled") + @parametrize + def test_raw_response_update(self, client: Opencode) -> None: + response = client.session.with_raw_response.update( + id="id", + ) + + assert response.is_closed is True + assert response.http_request.headers.get("X-Stainless-Lang") == "python" + session = response.parse() + assert_matches_type(Session, session, path=["response"]) + + @pytest.mark.skip(reason="Prism tests are disabled") + @parametrize + def test_streaming_response_update(self, client: Opencode) -> None: + with client.session.with_streaming_response.update( + id="id", + ) as response: + assert not response.is_closed + assert response.http_request.headers.get("X-Stainless-Lang") == "python" + + session = response.parse() + assert_matches_type(Session, session, path=["response"]) + + assert cast(Any, response.is_closed) is True + + @pytest.mark.skip(reason="Prism tests are disabled") + @parametrize + def test_path_params_update(self, client: Opencode) -> None: + with pytest.raises(ValueError, match=r"Expected a non-empty value for `id` but received ''"): + client.session.with_raw_response.update( + id="", + ) + + @pytest.mark.skip(reason="Prism tests are disabled") + @parametrize + def test_method_list(self, client: Opencode) -> None: + session = client.session.list() + assert_matches_type(SessionListResponse, session, path=["response"]) + + @pytest.mark.skip(reason="Prism tests are disabled") + @parametrize + def test_method_list_with_all_params(self, client: Opencode) -> None: + session = client.session.list( + directory="directory", + ) + assert_matches_type(SessionListResponse, session, path=["response"]) + + @pytest.mark.skip(reason="Prism tests are disabled") + @parametrize + def test_raw_response_list(self, client: Opencode) -> None: + response = client.session.with_raw_response.list() + + assert response.is_closed is True + assert response.http_request.headers.get("X-Stainless-Lang") == "python" + session = response.parse() + assert_matches_type(SessionListResponse, session, path=["response"]) + + @pytest.mark.skip(reason="Prism tests are disabled") + @parametrize + def test_streaming_response_list(self, client: Opencode) -> None: + with client.session.with_streaming_response.list() as response: + assert not response.is_closed + assert response.http_request.headers.get("X-Stainless-Lang") == "python" + + session = response.parse() + assert_matches_type(SessionListResponse, session, path=["response"]) + + assert cast(Any, response.is_closed) is True + + @pytest.mark.skip(reason="Prism tests are disabled") + @parametrize + def test_method_delete(self, client: Opencode) -> None: + session = client.session.delete( + id="id", + ) + assert_matches_type(SessionDeleteResponse, session, path=["response"]) + + @pytest.mark.skip(reason="Prism tests are disabled") + @parametrize + def test_method_delete_with_all_params(self, client: Opencode) -> None: + session = client.session.delete( + id="id", + directory="directory", + ) + assert_matches_type(SessionDeleteResponse, session, path=["response"]) + + @pytest.mark.skip(reason="Prism tests are disabled") + @parametrize + def test_raw_response_delete(self, client: Opencode) -> None: + response = client.session.with_raw_response.delete( + id="id", + ) + + assert response.is_closed is True + assert response.http_request.headers.get("X-Stainless-Lang") == "python" + session = response.parse() + assert_matches_type(SessionDeleteResponse, session, path=["response"]) + + @pytest.mark.skip(reason="Prism tests are disabled") + @parametrize + def test_streaming_response_delete(self, client: Opencode) -> None: + with client.session.with_streaming_response.delete( + id="id", + ) as response: + assert not response.is_closed + assert response.http_request.headers.get("X-Stainless-Lang") == "python" + + session = response.parse() + assert_matches_type(SessionDeleteResponse, session, path=["response"]) + + assert cast(Any, response.is_closed) is True + + @pytest.mark.skip(reason="Prism tests are disabled") + @parametrize + def test_path_params_delete(self, client: Opencode) -> None: + with pytest.raises(ValueError, match=r"Expected a non-empty value for `id` but received ''"): + client.session.with_raw_response.delete( + id="", + ) + + @pytest.mark.skip(reason="Prism tests are disabled") + @parametrize + def test_method_abort(self, client: Opencode) -> None: + session = client.session.abort( + id="id", + ) + assert_matches_type(SessionAbortResponse, session, path=["response"]) + + @pytest.mark.skip(reason="Prism tests are disabled") + @parametrize + def test_method_abort_with_all_params(self, client: Opencode) -> None: + session = client.session.abort( + id="id", + directory="directory", + ) + assert_matches_type(SessionAbortResponse, session, path=["response"]) + + @pytest.mark.skip(reason="Prism tests are disabled") + @parametrize + def test_raw_response_abort(self, client: Opencode) -> None: + response = client.session.with_raw_response.abort( + id="id", + ) + + assert response.is_closed is True + assert response.http_request.headers.get("X-Stainless-Lang") == "python" + session = response.parse() + assert_matches_type(SessionAbortResponse, session, path=["response"]) + + @pytest.mark.skip(reason="Prism tests are disabled") + @parametrize + def test_streaming_response_abort(self, client: Opencode) -> None: + with client.session.with_streaming_response.abort( + id="id", + ) as response: + assert not response.is_closed + assert response.http_request.headers.get("X-Stainless-Lang") == "python" + + session = response.parse() + assert_matches_type(SessionAbortResponse, session, path=["response"]) + + assert cast(Any, response.is_closed) is True + + @pytest.mark.skip(reason="Prism tests are disabled") + @parametrize + def test_path_params_abort(self, client: Opencode) -> None: + with pytest.raises(ValueError, match=r"Expected a non-empty value for `id` but received ''"): + client.session.with_raw_response.abort( + id="", + ) + + @pytest.mark.skip(reason="Prism tests are disabled") + @parametrize + def test_method_children(self, client: Opencode) -> None: + session = client.session.children( + id="id", + ) + assert_matches_type(SessionChildrenResponse, session, path=["response"]) + + @pytest.mark.skip(reason="Prism tests are disabled") + @parametrize + def test_method_children_with_all_params(self, client: Opencode) -> None: + session = client.session.children( + id="id", + directory="directory", + ) + assert_matches_type(SessionChildrenResponse, session, path=["response"]) + + @pytest.mark.skip(reason="Prism tests are disabled") + @parametrize + def test_raw_response_children(self, client: Opencode) -> None: + response = client.session.with_raw_response.children( + id="id", + ) + + assert response.is_closed is True + assert response.http_request.headers.get("X-Stainless-Lang") == "python" + session = response.parse() + assert_matches_type(SessionChildrenResponse, session, path=["response"]) + + @pytest.mark.skip(reason="Prism tests are disabled") + @parametrize + def test_streaming_response_children(self, client: Opencode) -> None: + with client.session.with_streaming_response.children( + id="id", + ) as response: + assert not response.is_closed + assert response.http_request.headers.get("X-Stainless-Lang") == "python" + + session = response.parse() + assert_matches_type(SessionChildrenResponse, session, path=["response"]) + + assert cast(Any, response.is_closed) is True + + @pytest.mark.skip(reason="Prism tests are disabled") + @parametrize + def test_path_params_children(self, client: Opencode) -> None: + with pytest.raises(ValueError, match=r"Expected a non-empty value for `id` but received ''"): + client.session.with_raw_response.children( + id="", + ) + + @pytest.mark.skip(reason="Prism tests are disabled") + @parametrize + def test_method_command(self, client: Opencode) -> None: + session = client.session.command( + id="id", + arguments="arguments", + command="command", + ) + assert_matches_type(SessionCommandResponse, session, path=["response"]) + + @pytest.mark.skip(reason="Prism tests are disabled") + @parametrize + def test_method_command_with_all_params(self, client: Opencode) -> None: + session = client.session.command( + id="id", + arguments="arguments", + command="command", + directory="directory", + agent="agent", + message_id="msg", + model="model", + ) + assert_matches_type(SessionCommandResponse, session, path=["response"]) + + @pytest.mark.skip(reason="Prism tests are disabled") + @parametrize + def test_raw_response_command(self, client: Opencode) -> None: + response = client.session.with_raw_response.command( + id="id", + arguments="arguments", + command="command", + ) + + assert response.is_closed is True + assert response.http_request.headers.get("X-Stainless-Lang") == "python" + session = response.parse() + assert_matches_type(SessionCommandResponse, session, path=["response"]) + + @pytest.mark.skip(reason="Prism tests are disabled") + @parametrize + def test_streaming_response_command(self, client: Opencode) -> None: + with client.session.with_streaming_response.command( + id="id", + arguments="arguments", + command="command", + ) as response: + assert not response.is_closed + assert response.http_request.headers.get("X-Stainless-Lang") == "python" + + session = response.parse() + assert_matches_type(SessionCommandResponse, session, path=["response"]) + + assert cast(Any, response.is_closed) is True + + @pytest.mark.skip(reason="Prism tests are disabled") + @parametrize + def test_path_params_command(self, client: Opencode) -> None: + with pytest.raises(ValueError, match=r"Expected a non-empty value for `id` but received ''"): + client.session.with_raw_response.command( + id="", + arguments="arguments", + command="command", + ) + + @pytest.mark.skip(reason="Prism tests are disabled") + @parametrize + def test_method_get(self, client: Opencode) -> None: + session = client.session.get( + id="id", + ) + assert_matches_type(Session, session, path=["response"]) + + @pytest.mark.skip(reason="Prism tests are disabled") + @parametrize + def test_method_get_with_all_params(self, client: Opencode) -> None: + session = client.session.get( + id="id", + directory="directory", + ) + assert_matches_type(Session, session, path=["response"]) + + @pytest.mark.skip(reason="Prism tests are disabled") + @parametrize + def test_raw_response_get(self, client: Opencode) -> None: + response = client.session.with_raw_response.get( + id="id", + ) + + assert response.is_closed is True + assert response.http_request.headers.get("X-Stainless-Lang") == "python" + session = response.parse() + assert_matches_type(Session, session, path=["response"]) + + @pytest.mark.skip(reason="Prism tests are disabled") + @parametrize + def test_streaming_response_get(self, client: Opencode) -> None: + with client.session.with_streaming_response.get( + id="id", + ) as response: + assert not response.is_closed + assert response.http_request.headers.get("X-Stainless-Lang") == "python" + + session = response.parse() + assert_matches_type(Session, session, path=["response"]) + + assert cast(Any, response.is_closed) is True + + @pytest.mark.skip(reason="Prism tests are disabled") + @parametrize + def test_path_params_get(self, client: Opencode) -> None: + with pytest.raises(ValueError, match=r"Expected a non-empty value for `id` but received ''"): + client.session.with_raw_response.get( + id="", + ) + + @pytest.mark.skip(reason="Prism tests are disabled") + @parametrize + def test_method_init(self, client: Opencode) -> None: + session = client.session.init( + id="id", + message_id="messageID", + model_id="modelID", + provider_id="providerID", + ) + assert_matches_type(SessionInitResponse, session, path=["response"]) + + @pytest.mark.skip(reason="Prism tests are disabled") + @parametrize + def test_method_init_with_all_params(self, client: Opencode) -> None: + session = client.session.init( + id="id", + message_id="messageID", + model_id="modelID", + provider_id="providerID", + directory="directory", + ) + assert_matches_type(SessionInitResponse, session, path=["response"]) + + @pytest.mark.skip(reason="Prism tests are disabled") + @parametrize + def test_raw_response_init(self, client: Opencode) -> None: + response = client.session.with_raw_response.init( + id="id", + message_id="messageID", + model_id="modelID", + provider_id="providerID", + ) + + assert response.is_closed is True + assert response.http_request.headers.get("X-Stainless-Lang") == "python" + session = response.parse() + assert_matches_type(SessionInitResponse, session, path=["response"]) + + @pytest.mark.skip(reason="Prism tests are disabled") + @parametrize + def test_streaming_response_init(self, client: Opencode) -> None: + with client.session.with_streaming_response.init( + id="id", + message_id="messageID", + model_id="modelID", + provider_id="providerID", + ) as response: + assert not response.is_closed + assert response.http_request.headers.get("X-Stainless-Lang") == "python" + + session = response.parse() + assert_matches_type(SessionInitResponse, session, path=["response"]) + + assert cast(Any, response.is_closed) is True + + @pytest.mark.skip(reason="Prism tests are disabled") + @parametrize + def test_path_params_init(self, client: Opencode) -> None: + with pytest.raises(ValueError, match=r"Expected a non-empty value for `id` but received ''"): + client.session.with_raw_response.init( + id="", + message_id="messageID", + model_id="modelID", + provider_id="providerID", + ) + + @pytest.mark.skip(reason="Prism tests are disabled") + @parametrize + def test_method_message(self, client: Opencode) -> None: + session = client.session.message( + message_id="messageID", + id="id", + ) + assert_matches_type(SessionMessageResponse, session, path=["response"]) + + @pytest.mark.skip(reason="Prism tests are disabled") + @parametrize + def test_method_message_with_all_params(self, client: Opencode) -> None: + session = client.session.message( + message_id="messageID", + id="id", + directory="directory", + ) + assert_matches_type(SessionMessageResponse, session, path=["response"]) + + @pytest.mark.skip(reason="Prism tests are disabled") + @parametrize + def test_raw_response_message(self, client: Opencode) -> None: + response = client.session.with_raw_response.message( + message_id="messageID", + id="id", + ) + + assert response.is_closed is True + assert response.http_request.headers.get("X-Stainless-Lang") == "python" + session = response.parse() + assert_matches_type(SessionMessageResponse, session, path=["response"]) + + @pytest.mark.skip(reason="Prism tests are disabled") + @parametrize + def test_streaming_response_message(self, client: Opencode) -> None: + with client.session.with_streaming_response.message( + message_id="messageID", + id="id", + ) as response: + assert not response.is_closed + assert response.http_request.headers.get("X-Stainless-Lang") == "python" + + session = response.parse() + assert_matches_type(SessionMessageResponse, session, path=["response"]) + + assert cast(Any, response.is_closed) is True + + @pytest.mark.skip(reason="Prism tests are disabled") + @parametrize + def test_path_params_message(self, client: Opencode) -> None: + with pytest.raises(ValueError, match=r"Expected a non-empty value for `id` but received ''"): + client.session.with_raw_response.message( + message_id="messageID", + id="", + ) + + with pytest.raises(ValueError, match=r"Expected a non-empty value for `message_id` but received ''"): + client.session.with_raw_response.message( + message_id="", + id="id", + ) + + @pytest.mark.skip(reason="Prism tests are disabled") + @parametrize + def test_method_messages(self, client: Opencode) -> None: + session = client.session.messages( + id="id", + ) + assert_matches_type(SessionMessagesResponse, session, path=["response"]) + + @pytest.mark.skip(reason="Prism tests are disabled") + @parametrize + def test_method_messages_with_all_params(self, client: Opencode) -> None: + session = client.session.messages( + id="id", + directory="directory", + ) + assert_matches_type(SessionMessagesResponse, session, path=["response"]) + + @pytest.mark.skip(reason="Prism tests are disabled") + @parametrize + def test_raw_response_messages(self, client: Opencode) -> None: + response = client.session.with_raw_response.messages( + id="id", + ) + + assert response.is_closed is True + assert response.http_request.headers.get("X-Stainless-Lang") == "python" + session = response.parse() + assert_matches_type(SessionMessagesResponse, session, path=["response"]) + + @pytest.mark.skip(reason="Prism tests are disabled") + @parametrize + def test_streaming_response_messages(self, client: Opencode) -> None: + with client.session.with_streaming_response.messages( + id="id", + ) as response: + assert not response.is_closed + assert response.http_request.headers.get("X-Stainless-Lang") == "python" + + session = response.parse() + assert_matches_type(SessionMessagesResponse, session, path=["response"]) + + assert cast(Any, response.is_closed) is True + + @pytest.mark.skip(reason="Prism tests are disabled") + @parametrize + def test_path_params_messages(self, client: Opencode) -> None: + with pytest.raises(ValueError, match=r"Expected a non-empty value for `id` but received ''"): + client.session.with_raw_response.messages( + id="", + ) + + @pytest.mark.skip(reason="Prism tests are disabled") + @parametrize + def test_method_prompt(self, client: Opencode) -> None: + session = client.session.prompt( + id="id", + parts=[ + { + "text": "text", + "type": "text", + } + ], + ) + assert_matches_type(SessionPromptResponse, session, path=["response"]) + + @pytest.mark.skip(reason="Prism tests are disabled") + @parametrize + def test_method_prompt_with_all_params(self, client: Opencode) -> None: + session = client.session.prompt( + id="id", + parts=[ + { + "text": "text", + "type": "text", + "id": "id", + "synthetic": True, + "time": { + "start": 0, + "end": 0, + }, + } + ], + directory="directory", + agent="agent", + message_id="msg", + model={ + "model_id": "modelID", + "provider_id": "providerID", + }, + system="system", + tools={"foo": True}, + ) + assert_matches_type(SessionPromptResponse, session, path=["response"]) + + @pytest.mark.skip(reason="Prism tests are disabled") + @parametrize + def test_raw_response_prompt(self, client: Opencode) -> None: + response = client.session.with_raw_response.prompt( + id="id", + parts=[ + { + "text": "text", + "type": "text", + } + ], + ) + + assert response.is_closed is True + assert response.http_request.headers.get("X-Stainless-Lang") == "python" + session = response.parse() + assert_matches_type(SessionPromptResponse, session, path=["response"]) + + @pytest.mark.skip(reason="Prism tests are disabled") + @parametrize + def test_streaming_response_prompt(self, client: Opencode) -> None: + with client.session.with_streaming_response.prompt( + id="id", + parts=[ + { + "text": "text", + "type": "text", + } + ], + ) as response: + assert not response.is_closed + assert response.http_request.headers.get("X-Stainless-Lang") == "python" + + session = response.parse() + assert_matches_type(SessionPromptResponse, session, path=["response"]) + + assert cast(Any, response.is_closed) is True + + @pytest.mark.skip(reason="Prism tests are disabled") + @parametrize + def test_path_params_prompt(self, client: Opencode) -> None: + with pytest.raises(ValueError, match=r"Expected a non-empty value for `id` but received ''"): + client.session.with_raw_response.prompt( + id="", + parts=[ + { + "text": "text", + "type": "text", + } + ], + ) + + @pytest.mark.skip(reason="Prism tests are disabled") + @parametrize + def test_method_revert(self, client: Opencode) -> None: + session = client.session.revert( + id="id", + message_id="msg", + ) + assert_matches_type(Session, session, path=["response"]) + + @pytest.mark.skip(reason="Prism tests are disabled") + @parametrize + def test_method_revert_with_all_params(self, client: Opencode) -> None: + session = client.session.revert( + id="id", + message_id="msg", + directory="directory", + part_id="prt", + ) + assert_matches_type(Session, session, path=["response"]) + + @pytest.mark.skip(reason="Prism tests are disabled") + @parametrize + def test_raw_response_revert(self, client: Opencode) -> None: + response = client.session.with_raw_response.revert( + id="id", + message_id="msg", + ) + + assert response.is_closed is True + assert response.http_request.headers.get("X-Stainless-Lang") == "python" + session = response.parse() + assert_matches_type(Session, session, path=["response"]) + + @pytest.mark.skip(reason="Prism tests are disabled") + @parametrize + def test_streaming_response_revert(self, client: Opencode) -> None: + with client.session.with_streaming_response.revert( + id="id", + message_id="msg", + ) as response: + assert not response.is_closed + assert response.http_request.headers.get("X-Stainless-Lang") == "python" + + session = response.parse() + assert_matches_type(Session, session, path=["response"]) + + assert cast(Any, response.is_closed) is True + + @pytest.mark.skip(reason="Prism tests are disabled") + @parametrize + def test_path_params_revert(self, client: Opencode) -> None: + with pytest.raises(ValueError, match=r"Expected a non-empty value for `id` but received ''"): + client.session.with_raw_response.revert( + id="", + message_id="msg", + ) + + @pytest.mark.skip(reason="Prism tests are disabled") + @parametrize + def test_method_share(self, client: Opencode) -> None: + session = client.session.share( + id="id", + ) + assert_matches_type(Session, session, path=["response"]) + + @pytest.mark.skip(reason="Prism tests are disabled") + @parametrize + def test_method_share_with_all_params(self, client: Opencode) -> None: + session = client.session.share( + id="id", + directory="directory", + ) + assert_matches_type(Session, session, path=["response"]) + + @pytest.mark.skip(reason="Prism tests are disabled") + @parametrize + def test_raw_response_share(self, client: Opencode) -> None: + response = client.session.with_raw_response.share( + id="id", + ) + + assert response.is_closed is True + assert response.http_request.headers.get("X-Stainless-Lang") == "python" + session = response.parse() + assert_matches_type(Session, session, path=["response"]) + + @pytest.mark.skip(reason="Prism tests are disabled") + @parametrize + def test_streaming_response_share(self, client: Opencode) -> None: + with client.session.with_streaming_response.share( + id="id", + ) as response: + assert not response.is_closed + assert response.http_request.headers.get("X-Stainless-Lang") == "python" + + session = response.parse() + assert_matches_type(Session, session, path=["response"]) + + assert cast(Any, response.is_closed) is True + + @pytest.mark.skip(reason="Prism tests are disabled") + @parametrize + def test_path_params_share(self, client: Opencode) -> None: + with pytest.raises(ValueError, match=r"Expected a non-empty value for `id` but received ''"): + client.session.with_raw_response.share( + id="", + ) + + @pytest.mark.skip(reason="Prism tests are disabled") + @parametrize + def test_method_shell(self, client: Opencode) -> None: + session = client.session.shell( + id="id", + agent="agent", + command="command", + ) + assert_matches_type(AssistantMessage, session, path=["response"]) + + @pytest.mark.skip(reason="Prism tests are disabled") + @parametrize + def test_method_shell_with_all_params(self, client: Opencode) -> None: + session = client.session.shell( + id="id", + agent="agent", + command="command", + directory="directory", + ) + assert_matches_type(AssistantMessage, session, path=["response"]) + + @pytest.mark.skip(reason="Prism tests are disabled") + @parametrize + def test_raw_response_shell(self, client: Opencode) -> None: + response = client.session.with_raw_response.shell( + id="id", + agent="agent", + command="command", + ) + + assert response.is_closed is True + assert response.http_request.headers.get("X-Stainless-Lang") == "python" + session = response.parse() + assert_matches_type(AssistantMessage, session, path=["response"]) + + @pytest.mark.skip(reason="Prism tests are disabled") + @parametrize + def test_streaming_response_shell(self, client: Opencode) -> None: + with client.session.with_streaming_response.shell( + id="id", + agent="agent", + command="command", + ) as response: + assert not response.is_closed + assert response.http_request.headers.get("X-Stainless-Lang") == "python" + + session = response.parse() + assert_matches_type(AssistantMessage, session, path=["response"]) + + assert cast(Any, response.is_closed) is True + + @pytest.mark.skip(reason="Prism tests are disabled") + @parametrize + def test_path_params_shell(self, client: Opencode) -> None: + with pytest.raises(ValueError, match=r"Expected a non-empty value for `id` but received ''"): + client.session.with_raw_response.shell( + id="", + agent="agent", + command="command", + ) + + @pytest.mark.skip(reason="Prism tests are disabled") + @parametrize + def test_method_summarize(self, client: Opencode) -> None: + session = client.session.summarize( + id="id", + model_id="modelID", + provider_id="providerID", + ) + assert_matches_type(SessionSummarizeResponse, session, path=["response"]) + + @pytest.mark.skip(reason="Prism tests are disabled") + @parametrize + def test_method_summarize_with_all_params(self, client: Opencode) -> None: + session = client.session.summarize( + id="id", + model_id="modelID", + provider_id="providerID", + directory="directory", + ) + assert_matches_type(SessionSummarizeResponse, session, path=["response"]) + + @pytest.mark.skip(reason="Prism tests are disabled") + @parametrize + def test_raw_response_summarize(self, client: Opencode) -> None: + response = client.session.with_raw_response.summarize( + id="id", + model_id="modelID", + provider_id="providerID", + ) + + assert response.is_closed is True + assert response.http_request.headers.get("X-Stainless-Lang") == "python" + session = response.parse() + assert_matches_type(SessionSummarizeResponse, session, path=["response"]) + + @pytest.mark.skip(reason="Prism tests are disabled") + @parametrize + def test_streaming_response_summarize(self, client: Opencode) -> None: + with client.session.with_streaming_response.summarize( + id="id", + model_id="modelID", + provider_id="providerID", + ) as response: + assert not response.is_closed + assert response.http_request.headers.get("X-Stainless-Lang") == "python" + + session = response.parse() + assert_matches_type(SessionSummarizeResponse, session, path=["response"]) + + assert cast(Any, response.is_closed) is True + + @pytest.mark.skip(reason="Prism tests are disabled") + @parametrize + def test_path_params_summarize(self, client: Opencode) -> None: + with pytest.raises(ValueError, match=r"Expected a non-empty value for `id` but received ''"): + client.session.with_raw_response.summarize( + id="", + model_id="modelID", + provider_id="providerID", + ) + + @pytest.mark.skip(reason="Prism tests are disabled") + @parametrize + def test_method_unrevert(self, client: Opencode) -> None: + session = client.session.unrevert( + id="id", + ) + assert_matches_type(Session, session, path=["response"]) + + @pytest.mark.skip(reason="Prism tests are disabled") + @parametrize + def test_method_unrevert_with_all_params(self, client: Opencode) -> None: + session = client.session.unrevert( + id="id", + directory="directory", + ) + assert_matches_type(Session, session, path=["response"]) + + @pytest.mark.skip(reason="Prism tests are disabled") + @parametrize + def test_raw_response_unrevert(self, client: Opencode) -> None: + response = client.session.with_raw_response.unrevert( + id="id", + ) + + assert response.is_closed is True + assert response.http_request.headers.get("X-Stainless-Lang") == "python" + session = response.parse() + assert_matches_type(Session, session, path=["response"]) + + @pytest.mark.skip(reason="Prism tests are disabled") + @parametrize + def test_streaming_response_unrevert(self, client: Opencode) -> None: + with client.session.with_streaming_response.unrevert( + id="id", + ) as response: + assert not response.is_closed + assert response.http_request.headers.get("X-Stainless-Lang") == "python" + + session = response.parse() + assert_matches_type(Session, session, path=["response"]) + + assert cast(Any, response.is_closed) is True + + @pytest.mark.skip(reason="Prism tests are disabled") + @parametrize + def test_path_params_unrevert(self, client: Opencode) -> None: + with pytest.raises(ValueError, match=r"Expected a non-empty value for `id` but received ''"): + client.session.with_raw_response.unrevert( + id="", + ) + + @pytest.mark.skip(reason="Prism tests are disabled") + @parametrize + def test_method_unshare(self, client: Opencode) -> None: + session = client.session.unshare( + id="id", + ) + assert_matches_type(Session, session, path=["response"]) + + @pytest.mark.skip(reason="Prism tests are disabled") + @parametrize + def test_method_unshare_with_all_params(self, client: Opencode) -> None: + session = client.session.unshare( + id="id", + directory="directory", + ) + assert_matches_type(Session, session, path=["response"]) + + @pytest.mark.skip(reason="Prism tests are disabled") + @parametrize + def test_raw_response_unshare(self, client: Opencode) -> None: + response = client.session.with_raw_response.unshare( + id="id", + ) + + assert response.is_closed is True + assert response.http_request.headers.get("X-Stainless-Lang") == "python" + session = response.parse() + assert_matches_type(Session, session, path=["response"]) + + @pytest.mark.skip(reason="Prism tests are disabled") + @parametrize + def test_streaming_response_unshare(self, client: Opencode) -> None: + with client.session.with_streaming_response.unshare( + id="id", + ) as response: + assert not response.is_closed + assert response.http_request.headers.get("X-Stainless-Lang") == "python" + + session = response.parse() + assert_matches_type(Session, session, path=["response"]) + + assert cast(Any, response.is_closed) is True + + @pytest.mark.skip(reason="Prism tests are disabled") + @parametrize + def test_path_params_unshare(self, client: Opencode) -> None: + with pytest.raises(ValueError, match=r"Expected a non-empty value for `id` but received ''"): + client.session.with_raw_response.unshare( + id="", + ) + + +class TestAsyncSession: + parametrize = pytest.mark.parametrize( + "async_client", [False, True, {"http_client": "aiohttp"}], indirect=True, ids=["loose", "strict", "aiohttp"] + ) + + @pytest.mark.skip(reason="Prism tests are disabled") + @parametrize + async def test_method_create(self, async_client: AsyncOpencode) -> None: + session = await async_client.session.create() + assert_matches_type(Session, session, path=["response"]) + + @pytest.mark.skip(reason="Prism tests are disabled") + @parametrize + async def test_method_create_with_all_params(self, async_client: AsyncOpencode) -> None: + session = await async_client.session.create( + directory="directory", + parent_id="parentID", + title="title", + ) + assert_matches_type(Session, session, path=["response"]) + + @pytest.mark.skip(reason="Prism tests are disabled") + @parametrize + async def test_raw_response_create(self, async_client: AsyncOpencode) -> None: + response = await async_client.session.with_raw_response.create() + + assert response.is_closed is True + assert response.http_request.headers.get("X-Stainless-Lang") == "python" + session = await response.parse() + assert_matches_type(Session, session, path=["response"]) + + @pytest.mark.skip(reason="Prism tests are disabled") + @parametrize + async def test_streaming_response_create(self, async_client: AsyncOpencode) -> None: + async with async_client.session.with_streaming_response.create() as response: + assert not response.is_closed + assert response.http_request.headers.get("X-Stainless-Lang") == "python" + + session = await response.parse() + assert_matches_type(Session, session, path=["response"]) + + assert cast(Any, response.is_closed) is True + + @pytest.mark.skip(reason="Prism tests are disabled") + @parametrize + async def test_method_update(self, async_client: AsyncOpencode) -> None: + session = await async_client.session.update( + id="id", + ) + assert_matches_type(Session, session, path=["response"]) + + @pytest.mark.skip(reason="Prism tests are disabled") + @parametrize + async def test_method_update_with_all_params(self, async_client: AsyncOpencode) -> None: + session = await async_client.session.update( + id="id", + directory="directory", + title="title", + ) + assert_matches_type(Session, session, path=["response"]) + + @pytest.mark.skip(reason="Prism tests are disabled") + @parametrize + async def test_raw_response_update(self, async_client: AsyncOpencode) -> None: + response = await async_client.session.with_raw_response.update( + id="id", + ) + + assert response.is_closed is True + assert response.http_request.headers.get("X-Stainless-Lang") == "python" + session = await response.parse() + assert_matches_type(Session, session, path=["response"]) + + @pytest.mark.skip(reason="Prism tests are disabled") + @parametrize + async def test_streaming_response_update(self, async_client: AsyncOpencode) -> None: + async with async_client.session.with_streaming_response.update( + id="id", + ) as response: + assert not response.is_closed + assert response.http_request.headers.get("X-Stainless-Lang") == "python" + + session = await response.parse() + assert_matches_type(Session, session, path=["response"]) + + assert cast(Any, response.is_closed) is True + + @pytest.mark.skip(reason="Prism tests are disabled") + @parametrize + async def test_path_params_update(self, async_client: AsyncOpencode) -> None: + with pytest.raises(ValueError, match=r"Expected a non-empty value for `id` but received ''"): + await async_client.session.with_raw_response.update( + id="", + ) + + @pytest.mark.skip(reason="Prism tests are disabled") + @parametrize + async def test_method_list(self, async_client: AsyncOpencode) -> None: + session = await async_client.session.list() + assert_matches_type(SessionListResponse, session, path=["response"]) + + @pytest.mark.skip(reason="Prism tests are disabled") + @parametrize + async def test_method_list_with_all_params(self, async_client: AsyncOpencode) -> None: + session = await async_client.session.list( + directory="directory", + ) + assert_matches_type(SessionListResponse, session, path=["response"]) + + @pytest.mark.skip(reason="Prism tests are disabled") + @parametrize + async def test_raw_response_list(self, async_client: AsyncOpencode) -> None: + response = await async_client.session.with_raw_response.list() + + assert response.is_closed is True + assert response.http_request.headers.get("X-Stainless-Lang") == "python" + session = await response.parse() + assert_matches_type(SessionListResponse, session, path=["response"]) + + @pytest.mark.skip(reason="Prism tests are disabled") + @parametrize + async def test_streaming_response_list(self, async_client: AsyncOpencode) -> None: + async with async_client.session.with_streaming_response.list() as response: + assert not response.is_closed + assert response.http_request.headers.get("X-Stainless-Lang") == "python" + + session = await response.parse() + assert_matches_type(SessionListResponse, session, path=["response"]) + + assert cast(Any, response.is_closed) is True + + @pytest.mark.skip(reason="Prism tests are disabled") + @parametrize + async def test_method_delete(self, async_client: AsyncOpencode) -> None: + session = await async_client.session.delete( + id="id", + ) + assert_matches_type(SessionDeleteResponse, session, path=["response"]) + + @pytest.mark.skip(reason="Prism tests are disabled") + @parametrize + async def test_method_delete_with_all_params(self, async_client: AsyncOpencode) -> None: + session = await async_client.session.delete( + id="id", + directory="directory", + ) + assert_matches_type(SessionDeleteResponse, session, path=["response"]) + + @pytest.mark.skip(reason="Prism tests are disabled") + @parametrize + async def test_raw_response_delete(self, async_client: AsyncOpencode) -> None: + response = await async_client.session.with_raw_response.delete( + id="id", + ) + + assert response.is_closed is True + assert response.http_request.headers.get("X-Stainless-Lang") == "python" + session = await response.parse() + assert_matches_type(SessionDeleteResponse, session, path=["response"]) + + @pytest.mark.skip(reason="Prism tests are disabled") + @parametrize + async def test_streaming_response_delete(self, async_client: AsyncOpencode) -> None: + async with async_client.session.with_streaming_response.delete( + id="id", + ) as response: + assert not response.is_closed + assert response.http_request.headers.get("X-Stainless-Lang") == "python" + + session = await response.parse() + assert_matches_type(SessionDeleteResponse, session, path=["response"]) + + assert cast(Any, response.is_closed) is True + + @pytest.mark.skip(reason="Prism tests are disabled") + @parametrize + async def test_path_params_delete(self, async_client: AsyncOpencode) -> None: + with pytest.raises(ValueError, match=r"Expected a non-empty value for `id` but received ''"): + await async_client.session.with_raw_response.delete( + id="", + ) + + @pytest.mark.skip(reason="Prism tests are disabled") + @parametrize + async def test_method_abort(self, async_client: AsyncOpencode) -> None: + session = await async_client.session.abort( + id="id", + ) + assert_matches_type(SessionAbortResponse, session, path=["response"]) + + @pytest.mark.skip(reason="Prism tests are disabled") + @parametrize + async def test_method_abort_with_all_params(self, async_client: AsyncOpencode) -> None: + session = await async_client.session.abort( + id="id", + directory="directory", + ) + assert_matches_type(SessionAbortResponse, session, path=["response"]) + + @pytest.mark.skip(reason="Prism tests are disabled") + @parametrize + async def test_raw_response_abort(self, async_client: AsyncOpencode) -> None: + response = await async_client.session.with_raw_response.abort( + id="id", + ) + + assert response.is_closed is True + assert response.http_request.headers.get("X-Stainless-Lang") == "python" + session = await response.parse() + assert_matches_type(SessionAbortResponse, session, path=["response"]) + + @pytest.mark.skip(reason="Prism tests are disabled") + @parametrize + async def test_streaming_response_abort(self, async_client: AsyncOpencode) -> None: + async with async_client.session.with_streaming_response.abort( + id="id", + ) as response: + assert not response.is_closed + assert response.http_request.headers.get("X-Stainless-Lang") == "python" + + session = await response.parse() + assert_matches_type(SessionAbortResponse, session, path=["response"]) + + assert cast(Any, response.is_closed) is True + + @pytest.mark.skip(reason="Prism tests are disabled") + @parametrize + async def test_path_params_abort(self, async_client: AsyncOpencode) -> None: + with pytest.raises(ValueError, match=r"Expected a non-empty value for `id` but received ''"): + await async_client.session.with_raw_response.abort( + id="", + ) + + @pytest.mark.skip(reason="Prism tests are disabled") + @parametrize + async def test_method_children(self, async_client: AsyncOpencode) -> None: + session = await async_client.session.children( + id="id", + ) + assert_matches_type(SessionChildrenResponse, session, path=["response"]) + + @pytest.mark.skip(reason="Prism tests are disabled") + @parametrize + async def test_method_children_with_all_params(self, async_client: AsyncOpencode) -> None: + session = await async_client.session.children( + id="id", + directory="directory", + ) + assert_matches_type(SessionChildrenResponse, session, path=["response"]) + + @pytest.mark.skip(reason="Prism tests are disabled") + @parametrize + async def test_raw_response_children(self, async_client: AsyncOpencode) -> None: + response = await async_client.session.with_raw_response.children( + id="id", + ) + + assert response.is_closed is True + assert response.http_request.headers.get("X-Stainless-Lang") == "python" + session = await response.parse() + assert_matches_type(SessionChildrenResponse, session, path=["response"]) + + @pytest.mark.skip(reason="Prism tests are disabled") + @parametrize + async def test_streaming_response_children(self, async_client: AsyncOpencode) -> None: + async with async_client.session.with_streaming_response.children( + id="id", + ) as response: + assert not response.is_closed + assert response.http_request.headers.get("X-Stainless-Lang") == "python" + + session = await response.parse() + assert_matches_type(SessionChildrenResponse, session, path=["response"]) + + assert cast(Any, response.is_closed) is True + + @pytest.mark.skip(reason="Prism tests are disabled") + @parametrize + async def test_path_params_children(self, async_client: AsyncOpencode) -> None: + with pytest.raises(ValueError, match=r"Expected a non-empty value for `id` but received ''"): + await async_client.session.with_raw_response.children( + id="", + ) + + @pytest.mark.skip(reason="Prism tests are disabled") + @parametrize + async def test_method_command(self, async_client: AsyncOpencode) -> None: + session = await async_client.session.command( + id="id", + arguments="arguments", + command="command", + ) + assert_matches_type(SessionCommandResponse, session, path=["response"]) + + @pytest.mark.skip(reason="Prism tests are disabled") + @parametrize + async def test_method_command_with_all_params(self, async_client: AsyncOpencode) -> None: + session = await async_client.session.command( + id="id", + arguments="arguments", + command="command", + directory="directory", + agent="agent", + message_id="msg", + model="model", + ) + assert_matches_type(SessionCommandResponse, session, path=["response"]) + + @pytest.mark.skip(reason="Prism tests are disabled") + @parametrize + async def test_raw_response_command(self, async_client: AsyncOpencode) -> None: + response = await async_client.session.with_raw_response.command( + id="id", + arguments="arguments", + command="command", + ) + + assert response.is_closed is True + assert response.http_request.headers.get("X-Stainless-Lang") == "python" + session = await response.parse() + assert_matches_type(SessionCommandResponse, session, path=["response"]) + + @pytest.mark.skip(reason="Prism tests are disabled") + @parametrize + async def test_streaming_response_command(self, async_client: AsyncOpencode) -> None: + async with async_client.session.with_streaming_response.command( + id="id", + arguments="arguments", + command="command", + ) as response: + assert not response.is_closed + assert response.http_request.headers.get("X-Stainless-Lang") == "python" + + session = await response.parse() + assert_matches_type(SessionCommandResponse, session, path=["response"]) + + assert cast(Any, response.is_closed) is True + + @pytest.mark.skip(reason="Prism tests are disabled") + @parametrize + async def test_path_params_command(self, async_client: AsyncOpencode) -> None: + with pytest.raises(ValueError, match=r"Expected a non-empty value for `id` but received ''"): + await async_client.session.with_raw_response.command( + id="", + arguments="arguments", + command="command", + ) + + @pytest.mark.skip(reason="Prism tests are disabled") + @parametrize + async def test_method_get(self, async_client: AsyncOpencode) -> None: + session = await async_client.session.get( + id="id", + ) + assert_matches_type(Session, session, path=["response"]) + + @pytest.mark.skip(reason="Prism tests are disabled") + @parametrize + async def test_method_get_with_all_params(self, async_client: AsyncOpencode) -> None: + session = await async_client.session.get( + id="id", + directory="directory", + ) + assert_matches_type(Session, session, path=["response"]) + + @pytest.mark.skip(reason="Prism tests are disabled") + @parametrize + async def test_raw_response_get(self, async_client: AsyncOpencode) -> None: + response = await async_client.session.with_raw_response.get( + id="id", + ) + + assert response.is_closed is True + assert response.http_request.headers.get("X-Stainless-Lang") == "python" + session = await response.parse() + assert_matches_type(Session, session, path=["response"]) + + @pytest.mark.skip(reason="Prism tests are disabled") + @parametrize + async def test_streaming_response_get(self, async_client: AsyncOpencode) -> None: + async with async_client.session.with_streaming_response.get( + id="id", + ) as response: + assert not response.is_closed + assert response.http_request.headers.get("X-Stainless-Lang") == "python" + + session = await response.parse() + assert_matches_type(Session, session, path=["response"]) + + assert cast(Any, response.is_closed) is True + + @pytest.mark.skip(reason="Prism tests are disabled") + @parametrize + async def test_path_params_get(self, async_client: AsyncOpencode) -> None: + with pytest.raises(ValueError, match=r"Expected a non-empty value for `id` but received ''"): + await async_client.session.with_raw_response.get( + id="", + ) + + @pytest.mark.skip(reason="Prism tests are disabled") + @parametrize + async def test_method_init(self, async_client: AsyncOpencode) -> None: + session = await async_client.session.init( + id="id", + message_id="messageID", + model_id="modelID", + provider_id="providerID", + ) + assert_matches_type(SessionInitResponse, session, path=["response"]) + + @pytest.mark.skip(reason="Prism tests are disabled") + @parametrize + async def test_method_init_with_all_params(self, async_client: AsyncOpencode) -> None: + session = await async_client.session.init( + id="id", + message_id="messageID", + model_id="modelID", + provider_id="providerID", + directory="directory", + ) + assert_matches_type(SessionInitResponse, session, path=["response"]) + + @pytest.mark.skip(reason="Prism tests are disabled") + @parametrize + async def test_raw_response_init(self, async_client: AsyncOpencode) -> None: + response = await async_client.session.with_raw_response.init( + id="id", + message_id="messageID", + model_id="modelID", + provider_id="providerID", + ) + + assert response.is_closed is True + assert response.http_request.headers.get("X-Stainless-Lang") == "python" + session = await response.parse() + assert_matches_type(SessionInitResponse, session, path=["response"]) + + @pytest.mark.skip(reason="Prism tests are disabled") + @parametrize + async def test_streaming_response_init(self, async_client: AsyncOpencode) -> None: + async with async_client.session.with_streaming_response.init( + id="id", + message_id="messageID", + model_id="modelID", + provider_id="providerID", + ) as response: + assert not response.is_closed + assert response.http_request.headers.get("X-Stainless-Lang") == "python" + + session = await response.parse() + assert_matches_type(SessionInitResponse, session, path=["response"]) + + assert cast(Any, response.is_closed) is True + + @pytest.mark.skip(reason="Prism tests are disabled") + @parametrize + async def test_path_params_init(self, async_client: AsyncOpencode) -> None: + with pytest.raises(ValueError, match=r"Expected a non-empty value for `id` but received ''"): + await async_client.session.with_raw_response.init( + id="", + message_id="messageID", + model_id="modelID", + provider_id="providerID", + ) + + @pytest.mark.skip(reason="Prism tests are disabled") + @parametrize + async def test_method_message(self, async_client: AsyncOpencode) -> None: + session = await async_client.session.message( + message_id="messageID", + id="id", + ) + assert_matches_type(SessionMessageResponse, session, path=["response"]) + + @pytest.mark.skip(reason="Prism tests are disabled") + @parametrize + async def test_method_message_with_all_params(self, async_client: AsyncOpencode) -> None: + session = await async_client.session.message( + message_id="messageID", + id="id", + directory="directory", + ) + assert_matches_type(SessionMessageResponse, session, path=["response"]) + + @pytest.mark.skip(reason="Prism tests are disabled") + @parametrize + async def test_raw_response_message(self, async_client: AsyncOpencode) -> None: + response = await async_client.session.with_raw_response.message( + message_id="messageID", + id="id", + ) + + assert response.is_closed is True + assert response.http_request.headers.get("X-Stainless-Lang") == "python" + session = await response.parse() + assert_matches_type(SessionMessageResponse, session, path=["response"]) + + @pytest.mark.skip(reason="Prism tests are disabled") + @parametrize + async def test_streaming_response_message(self, async_client: AsyncOpencode) -> None: + async with async_client.session.with_streaming_response.message( + message_id="messageID", + id="id", + ) as response: + assert not response.is_closed + assert response.http_request.headers.get("X-Stainless-Lang") == "python" + + session = await response.parse() + assert_matches_type(SessionMessageResponse, session, path=["response"]) + + assert cast(Any, response.is_closed) is True + + @pytest.mark.skip(reason="Prism tests are disabled") + @parametrize + async def test_path_params_message(self, async_client: AsyncOpencode) -> None: + with pytest.raises(ValueError, match=r"Expected a non-empty value for `id` but received ''"): + await async_client.session.with_raw_response.message( + message_id="messageID", + id="", + ) + + with pytest.raises(ValueError, match=r"Expected a non-empty value for `message_id` but received ''"): + await async_client.session.with_raw_response.message( + message_id="", + id="id", + ) + + @pytest.mark.skip(reason="Prism tests are disabled") + @parametrize + async def test_method_messages(self, async_client: AsyncOpencode) -> None: + session = await async_client.session.messages( + id="id", + ) + assert_matches_type(SessionMessagesResponse, session, path=["response"]) + + @pytest.mark.skip(reason="Prism tests are disabled") + @parametrize + async def test_method_messages_with_all_params(self, async_client: AsyncOpencode) -> None: + session = await async_client.session.messages( + id="id", + directory="directory", + ) + assert_matches_type(SessionMessagesResponse, session, path=["response"]) + + @pytest.mark.skip(reason="Prism tests are disabled") + @parametrize + async def test_raw_response_messages(self, async_client: AsyncOpencode) -> None: + response = await async_client.session.with_raw_response.messages( + id="id", + ) + + assert response.is_closed is True + assert response.http_request.headers.get("X-Stainless-Lang") == "python" + session = await response.parse() + assert_matches_type(SessionMessagesResponse, session, path=["response"]) + + @pytest.mark.skip(reason="Prism tests are disabled") + @parametrize + async def test_streaming_response_messages(self, async_client: AsyncOpencode) -> None: + async with async_client.session.with_streaming_response.messages( + id="id", + ) as response: + assert not response.is_closed + assert response.http_request.headers.get("X-Stainless-Lang") == "python" + + session = await response.parse() + assert_matches_type(SessionMessagesResponse, session, path=["response"]) + + assert cast(Any, response.is_closed) is True + + @pytest.mark.skip(reason="Prism tests are disabled") + @parametrize + async def test_path_params_messages(self, async_client: AsyncOpencode) -> None: + with pytest.raises(ValueError, match=r"Expected a non-empty value for `id` but received ''"): + await async_client.session.with_raw_response.messages( + id="", + ) + + @pytest.mark.skip(reason="Prism tests are disabled") + @parametrize + async def test_method_prompt(self, async_client: AsyncOpencode) -> None: + session = await async_client.session.prompt( + id="id", + parts=[ + { + "text": "text", + "type": "text", + } + ], + ) + assert_matches_type(SessionPromptResponse, session, path=["response"]) + + @pytest.mark.skip(reason="Prism tests are disabled") + @parametrize + async def test_method_prompt_with_all_params(self, async_client: AsyncOpencode) -> None: + session = await async_client.session.prompt( + id="id", + parts=[ + { + "text": "text", + "type": "text", + "id": "id", + "synthetic": True, + "time": { + "start": 0, + "end": 0, + }, + } + ], + directory="directory", + agent="agent", + message_id="msg", + model={ + "model_id": "modelID", + "provider_id": "providerID", + }, + system="system", + tools={"foo": True}, + ) + assert_matches_type(SessionPromptResponse, session, path=["response"]) + + @pytest.mark.skip(reason="Prism tests are disabled") + @parametrize + async def test_raw_response_prompt(self, async_client: AsyncOpencode) -> None: + response = await async_client.session.with_raw_response.prompt( + id="id", + parts=[ + { + "text": "text", + "type": "text", + } + ], + ) + + assert response.is_closed is True + assert response.http_request.headers.get("X-Stainless-Lang") == "python" + session = await response.parse() + assert_matches_type(SessionPromptResponse, session, path=["response"]) + + @pytest.mark.skip(reason="Prism tests are disabled") + @parametrize + async def test_streaming_response_prompt(self, async_client: AsyncOpencode) -> None: + async with async_client.session.with_streaming_response.prompt( + id="id", + parts=[ + { + "text": "text", + "type": "text", + } + ], + ) as response: + assert not response.is_closed + assert response.http_request.headers.get("X-Stainless-Lang") == "python" + + session = await response.parse() + assert_matches_type(SessionPromptResponse, session, path=["response"]) + + assert cast(Any, response.is_closed) is True + + @pytest.mark.skip(reason="Prism tests are disabled") + @parametrize + async def test_path_params_prompt(self, async_client: AsyncOpencode) -> None: + with pytest.raises(ValueError, match=r"Expected a non-empty value for `id` but received ''"): + await async_client.session.with_raw_response.prompt( + id="", + parts=[ + { + "text": "text", + "type": "text", + } + ], + ) + + @pytest.mark.skip(reason="Prism tests are disabled") + @parametrize + async def test_method_revert(self, async_client: AsyncOpencode) -> None: + session = await async_client.session.revert( + id="id", + message_id="msg", + ) + assert_matches_type(Session, session, path=["response"]) + + @pytest.mark.skip(reason="Prism tests are disabled") + @parametrize + async def test_method_revert_with_all_params(self, async_client: AsyncOpencode) -> None: + session = await async_client.session.revert( + id="id", + message_id="msg", + directory="directory", + part_id="prt", + ) + assert_matches_type(Session, session, path=["response"]) + + @pytest.mark.skip(reason="Prism tests are disabled") + @parametrize + async def test_raw_response_revert(self, async_client: AsyncOpencode) -> None: + response = await async_client.session.with_raw_response.revert( + id="id", + message_id="msg", + ) + + assert response.is_closed is True + assert response.http_request.headers.get("X-Stainless-Lang") == "python" + session = await response.parse() + assert_matches_type(Session, session, path=["response"]) + + @pytest.mark.skip(reason="Prism tests are disabled") + @parametrize + async def test_streaming_response_revert(self, async_client: AsyncOpencode) -> None: + async with async_client.session.with_streaming_response.revert( + id="id", + message_id="msg", + ) as response: + assert not response.is_closed + assert response.http_request.headers.get("X-Stainless-Lang") == "python" + + session = await response.parse() + assert_matches_type(Session, session, path=["response"]) + + assert cast(Any, response.is_closed) is True + + @pytest.mark.skip(reason="Prism tests are disabled") + @parametrize + async def test_path_params_revert(self, async_client: AsyncOpencode) -> None: + with pytest.raises(ValueError, match=r"Expected a non-empty value for `id` but received ''"): + await async_client.session.with_raw_response.revert( + id="", + message_id="msg", + ) + + @pytest.mark.skip(reason="Prism tests are disabled") + @parametrize + async def test_method_share(self, async_client: AsyncOpencode) -> None: + session = await async_client.session.share( + id="id", + ) + assert_matches_type(Session, session, path=["response"]) + + @pytest.mark.skip(reason="Prism tests are disabled") + @parametrize + async def test_method_share_with_all_params(self, async_client: AsyncOpencode) -> None: + session = await async_client.session.share( + id="id", + directory="directory", + ) + assert_matches_type(Session, session, path=["response"]) + + @pytest.mark.skip(reason="Prism tests are disabled") + @parametrize + async def test_raw_response_share(self, async_client: AsyncOpencode) -> None: + response = await async_client.session.with_raw_response.share( + id="id", + ) + + assert response.is_closed is True + assert response.http_request.headers.get("X-Stainless-Lang") == "python" + session = await response.parse() + assert_matches_type(Session, session, path=["response"]) + + @pytest.mark.skip(reason="Prism tests are disabled") + @parametrize + async def test_streaming_response_share(self, async_client: AsyncOpencode) -> None: + async with async_client.session.with_streaming_response.share( + id="id", + ) as response: + assert not response.is_closed + assert response.http_request.headers.get("X-Stainless-Lang") == "python" + + session = await response.parse() + assert_matches_type(Session, session, path=["response"]) + + assert cast(Any, response.is_closed) is True + + @pytest.mark.skip(reason="Prism tests are disabled") + @parametrize + async def test_path_params_share(self, async_client: AsyncOpencode) -> None: + with pytest.raises(ValueError, match=r"Expected a non-empty value for `id` but received ''"): + await async_client.session.with_raw_response.share( + id="", + ) + + @pytest.mark.skip(reason="Prism tests are disabled") + @parametrize + async def test_method_shell(self, async_client: AsyncOpencode) -> None: + session = await async_client.session.shell( + id="id", + agent="agent", + command="command", + ) + assert_matches_type(AssistantMessage, session, path=["response"]) + + @pytest.mark.skip(reason="Prism tests are disabled") + @parametrize + async def test_method_shell_with_all_params(self, async_client: AsyncOpencode) -> None: + session = await async_client.session.shell( + id="id", + agent="agent", + command="command", + directory="directory", + ) + assert_matches_type(AssistantMessage, session, path=["response"]) + + @pytest.mark.skip(reason="Prism tests are disabled") + @parametrize + async def test_raw_response_shell(self, async_client: AsyncOpencode) -> None: + response = await async_client.session.with_raw_response.shell( + id="id", + agent="agent", + command="command", + ) + + assert response.is_closed is True + assert response.http_request.headers.get("X-Stainless-Lang") == "python" + session = await response.parse() + assert_matches_type(AssistantMessage, session, path=["response"]) + + @pytest.mark.skip(reason="Prism tests are disabled") + @parametrize + async def test_streaming_response_shell(self, async_client: AsyncOpencode) -> None: + async with async_client.session.with_streaming_response.shell( + id="id", + agent="agent", + command="command", + ) as response: + assert not response.is_closed + assert response.http_request.headers.get("X-Stainless-Lang") == "python" + + session = await response.parse() + assert_matches_type(AssistantMessage, session, path=["response"]) + + assert cast(Any, response.is_closed) is True + + @pytest.mark.skip(reason="Prism tests are disabled") + @parametrize + async def test_path_params_shell(self, async_client: AsyncOpencode) -> None: + with pytest.raises(ValueError, match=r"Expected a non-empty value for `id` but received ''"): + await async_client.session.with_raw_response.shell( + id="", + agent="agent", + command="command", + ) + + @pytest.mark.skip(reason="Prism tests are disabled") + @parametrize + async def test_method_summarize(self, async_client: AsyncOpencode) -> None: + session = await async_client.session.summarize( + id="id", + model_id="modelID", + provider_id="providerID", + ) + assert_matches_type(SessionSummarizeResponse, session, path=["response"]) + + @pytest.mark.skip(reason="Prism tests are disabled") + @parametrize + async def test_method_summarize_with_all_params(self, async_client: AsyncOpencode) -> None: + session = await async_client.session.summarize( + id="id", + model_id="modelID", + provider_id="providerID", + directory="directory", + ) + assert_matches_type(SessionSummarizeResponse, session, path=["response"]) + + @pytest.mark.skip(reason="Prism tests are disabled") + @parametrize + async def test_raw_response_summarize(self, async_client: AsyncOpencode) -> None: + response = await async_client.session.with_raw_response.summarize( + id="id", + model_id="modelID", + provider_id="providerID", + ) + + assert response.is_closed is True + assert response.http_request.headers.get("X-Stainless-Lang") == "python" + session = await response.parse() + assert_matches_type(SessionSummarizeResponse, session, path=["response"]) + + @pytest.mark.skip(reason="Prism tests are disabled") + @parametrize + async def test_streaming_response_summarize(self, async_client: AsyncOpencode) -> None: + async with async_client.session.with_streaming_response.summarize( + id="id", + model_id="modelID", + provider_id="providerID", + ) as response: + assert not response.is_closed + assert response.http_request.headers.get("X-Stainless-Lang") == "python" + + session = await response.parse() + assert_matches_type(SessionSummarizeResponse, session, path=["response"]) + + assert cast(Any, response.is_closed) is True + + @pytest.mark.skip(reason="Prism tests are disabled") + @parametrize + async def test_path_params_summarize(self, async_client: AsyncOpencode) -> None: + with pytest.raises(ValueError, match=r"Expected a non-empty value for `id` but received ''"): + await async_client.session.with_raw_response.summarize( + id="", + model_id="modelID", + provider_id="providerID", + ) + + @pytest.mark.skip(reason="Prism tests are disabled") + @parametrize + async def test_method_unrevert(self, async_client: AsyncOpencode) -> None: + session = await async_client.session.unrevert( + id="id", + ) + assert_matches_type(Session, session, path=["response"]) + + @pytest.mark.skip(reason="Prism tests are disabled") + @parametrize + async def test_method_unrevert_with_all_params(self, async_client: AsyncOpencode) -> None: + session = await async_client.session.unrevert( + id="id", + directory="directory", + ) + assert_matches_type(Session, session, path=["response"]) + + @pytest.mark.skip(reason="Prism tests are disabled") + @parametrize + async def test_raw_response_unrevert(self, async_client: AsyncOpencode) -> None: + response = await async_client.session.with_raw_response.unrevert( + id="id", + ) + + assert response.is_closed is True + assert response.http_request.headers.get("X-Stainless-Lang") == "python" + session = await response.parse() + assert_matches_type(Session, session, path=["response"]) + + @pytest.mark.skip(reason="Prism tests are disabled") + @parametrize + async def test_streaming_response_unrevert(self, async_client: AsyncOpencode) -> None: + async with async_client.session.with_streaming_response.unrevert( + id="id", + ) as response: + assert not response.is_closed + assert response.http_request.headers.get("X-Stainless-Lang") == "python" + + session = await response.parse() + assert_matches_type(Session, session, path=["response"]) + + assert cast(Any, response.is_closed) is True + + @pytest.mark.skip(reason="Prism tests are disabled") + @parametrize + async def test_path_params_unrevert(self, async_client: AsyncOpencode) -> None: + with pytest.raises(ValueError, match=r"Expected a non-empty value for `id` but received ''"): + await async_client.session.with_raw_response.unrevert( + id="", + ) + + @pytest.mark.skip(reason="Prism tests are disabled") + @parametrize + async def test_method_unshare(self, async_client: AsyncOpencode) -> None: + session = await async_client.session.unshare( + id="id", + ) + assert_matches_type(Session, session, path=["response"]) + + @pytest.mark.skip(reason="Prism tests are disabled") + @parametrize + async def test_method_unshare_with_all_params(self, async_client: AsyncOpencode) -> None: + session = await async_client.session.unshare( + id="id", + directory="directory", + ) + assert_matches_type(Session, session, path=["response"]) + + @pytest.mark.skip(reason="Prism tests are disabled") + @parametrize + async def test_raw_response_unshare(self, async_client: AsyncOpencode) -> None: + response = await async_client.session.with_raw_response.unshare( + id="id", + ) + + assert response.is_closed is True + assert response.http_request.headers.get("X-Stainless-Lang") == "python" + session = await response.parse() + assert_matches_type(Session, session, path=["response"]) + + @pytest.mark.skip(reason="Prism tests are disabled") + @parametrize + async def test_streaming_response_unshare(self, async_client: AsyncOpencode) -> None: + async with async_client.session.with_streaming_response.unshare( + id="id", + ) as response: + assert not response.is_closed + assert response.http_request.headers.get("X-Stainless-Lang") == "python" + + session = await response.parse() + assert_matches_type(Session, session, path=["response"]) + + assert cast(Any, response.is_closed) is True + + @pytest.mark.skip(reason="Prism tests are disabled") + @parametrize + async def test_path_params_unshare(self, async_client: AsyncOpencode) -> None: + with pytest.raises(ValueError, match=r"Expected a non-empty value for `id` but received ''"): + await async_client.session.with_raw_response.unshare( + id="", + ) diff --git a/tests/api_resources/test_tui.py b/tests/api_resources/test_tui.py new file mode 100644 index 0000000..20ee299 --- /dev/null +++ b/tests/api_resources/test_tui.py @@ -0,0 +1,734 @@ +# File generated from our OpenAPI spec by Stainless. See CONTRIBUTING.md for details. + +from __future__ import annotations + +import os +from typing import Any, cast + +import pytest + +from opencode_ai import Opencode, AsyncOpencode +from tests.utils import assert_matches_type +from opencode_ai.types import ( + TuiOpenHelpResponse, + TuiShowToastResponse, + TuiOpenModelsResponse, + TuiOpenThemesResponse, + TuiClearPromptResponse, + TuiAppendPromptResponse, + TuiOpenSessionsResponse, + TuiSubmitPromptResponse, + TuiExecuteCommandResponse, +) + +base_url = os.environ.get("TEST_API_BASE_URL", "http://127.0.0.1:4010") + + +class TestTui: + parametrize = pytest.mark.parametrize("client", [False, True], indirect=True, ids=["loose", "strict"]) + + @pytest.mark.skip(reason="Prism tests are disabled") + @parametrize + def test_method_append_prompt(self, client: Opencode) -> None: + tui = client.tui.append_prompt( + text="text", + ) + assert_matches_type(TuiAppendPromptResponse, tui, path=["response"]) + + @pytest.mark.skip(reason="Prism tests are disabled") + @parametrize + def test_method_append_prompt_with_all_params(self, client: Opencode) -> None: + tui = client.tui.append_prompt( + text="text", + directory="directory", + ) + assert_matches_type(TuiAppendPromptResponse, tui, path=["response"]) + + @pytest.mark.skip(reason="Prism tests are disabled") + @parametrize + def test_raw_response_append_prompt(self, client: Opencode) -> None: + response = client.tui.with_raw_response.append_prompt( + text="text", + ) + + assert response.is_closed is True + assert response.http_request.headers.get("X-Stainless-Lang") == "python" + tui = response.parse() + assert_matches_type(TuiAppendPromptResponse, tui, path=["response"]) + + @pytest.mark.skip(reason="Prism tests are disabled") + @parametrize + def test_streaming_response_append_prompt(self, client: Opencode) -> None: + with client.tui.with_streaming_response.append_prompt( + text="text", + ) as response: + assert not response.is_closed + assert response.http_request.headers.get("X-Stainless-Lang") == "python" + + tui = response.parse() + assert_matches_type(TuiAppendPromptResponse, tui, path=["response"]) + + assert cast(Any, response.is_closed) is True + + @pytest.mark.skip(reason="Prism tests are disabled") + @parametrize + def test_method_clear_prompt(self, client: Opencode) -> None: + tui = client.tui.clear_prompt() + assert_matches_type(TuiClearPromptResponse, tui, path=["response"]) + + @pytest.mark.skip(reason="Prism tests are disabled") + @parametrize + def test_method_clear_prompt_with_all_params(self, client: Opencode) -> None: + tui = client.tui.clear_prompt( + directory="directory", + ) + assert_matches_type(TuiClearPromptResponse, tui, path=["response"]) + + @pytest.mark.skip(reason="Prism tests are disabled") + @parametrize + def test_raw_response_clear_prompt(self, client: Opencode) -> None: + response = client.tui.with_raw_response.clear_prompt() + + assert response.is_closed is True + assert response.http_request.headers.get("X-Stainless-Lang") == "python" + tui = response.parse() + assert_matches_type(TuiClearPromptResponse, tui, path=["response"]) + + @pytest.mark.skip(reason="Prism tests are disabled") + @parametrize + def test_streaming_response_clear_prompt(self, client: Opencode) -> None: + with client.tui.with_streaming_response.clear_prompt() as response: + assert not response.is_closed + assert response.http_request.headers.get("X-Stainless-Lang") == "python" + + tui = response.parse() + assert_matches_type(TuiClearPromptResponse, tui, path=["response"]) + + assert cast(Any, response.is_closed) is True + + @pytest.mark.skip(reason="Prism tests are disabled") + @parametrize + def test_method_execute_command(self, client: Opencode) -> None: + tui = client.tui.execute_command( + command="command", + ) + assert_matches_type(TuiExecuteCommandResponse, tui, path=["response"]) + + @pytest.mark.skip(reason="Prism tests are disabled") + @parametrize + def test_method_execute_command_with_all_params(self, client: Opencode) -> None: + tui = client.tui.execute_command( + command="command", + directory="directory", + ) + assert_matches_type(TuiExecuteCommandResponse, tui, path=["response"]) + + @pytest.mark.skip(reason="Prism tests are disabled") + @parametrize + def test_raw_response_execute_command(self, client: Opencode) -> None: + response = client.tui.with_raw_response.execute_command( + command="command", + ) + + assert response.is_closed is True + assert response.http_request.headers.get("X-Stainless-Lang") == "python" + tui = response.parse() + assert_matches_type(TuiExecuteCommandResponse, tui, path=["response"]) + + @pytest.mark.skip(reason="Prism tests are disabled") + @parametrize + def test_streaming_response_execute_command(self, client: Opencode) -> None: + with client.tui.with_streaming_response.execute_command( + command="command", + ) as response: + assert not response.is_closed + assert response.http_request.headers.get("X-Stainless-Lang") == "python" + + tui = response.parse() + assert_matches_type(TuiExecuteCommandResponse, tui, path=["response"]) + + assert cast(Any, response.is_closed) is True + + @pytest.mark.skip(reason="Prism tests are disabled") + @parametrize + def test_method_open_help(self, client: Opencode) -> None: + tui = client.tui.open_help() + assert_matches_type(TuiOpenHelpResponse, tui, path=["response"]) + + @pytest.mark.skip(reason="Prism tests are disabled") + @parametrize + def test_method_open_help_with_all_params(self, client: Opencode) -> None: + tui = client.tui.open_help( + directory="directory", + ) + assert_matches_type(TuiOpenHelpResponse, tui, path=["response"]) + + @pytest.mark.skip(reason="Prism tests are disabled") + @parametrize + def test_raw_response_open_help(self, client: Opencode) -> None: + response = client.tui.with_raw_response.open_help() + + assert response.is_closed is True + assert response.http_request.headers.get("X-Stainless-Lang") == "python" + tui = response.parse() + assert_matches_type(TuiOpenHelpResponse, tui, path=["response"]) + + @pytest.mark.skip(reason="Prism tests are disabled") + @parametrize + def test_streaming_response_open_help(self, client: Opencode) -> None: + with client.tui.with_streaming_response.open_help() as response: + assert not response.is_closed + assert response.http_request.headers.get("X-Stainless-Lang") == "python" + + tui = response.parse() + assert_matches_type(TuiOpenHelpResponse, tui, path=["response"]) + + assert cast(Any, response.is_closed) is True + + @pytest.mark.skip(reason="Prism tests are disabled") + @parametrize + def test_method_open_models(self, client: Opencode) -> None: + tui = client.tui.open_models() + assert_matches_type(TuiOpenModelsResponse, tui, path=["response"]) + + @pytest.mark.skip(reason="Prism tests are disabled") + @parametrize + def test_method_open_models_with_all_params(self, client: Opencode) -> None: + tui = client.tui.open_models( + directory="directory", + ) + assert_matches_type(TuiOpenModelsResponse, tui, path=["response"]) + + @pytest.mark.skip(reason="Prism tests are disabled") + @parametrize + def test_raw_response_open_models(self, client: Opencode) -> None: + response = client.tui.with_raw_response.open_models() + + assert response.is_closed is True + assert response.http_request.headers.get("X-Stainless-Lang") == "python" + tui = response.parse() + assert_matches_type(TuiOpenModelsResponse, tui, path=["response"]) + + @pytest.mark.skip(reason="Prism tests are disabled") + @parametrize + def test_streaming_response_open_models(self, client: Opencode) -> None: + with client.tui.with_streaming_response.open_models() as response: + assert not response.is_closed + assert response.http_request.headers.get("X-Stainless-Lang") == "python" + + tui = response.parse() + assert_matches_type(TuiOpenModelsResponse, tui, path=["response"]) + + assert cast(Any, response.is_closed) is True + + @pytest.mark.skip(reason="Prism tests are disabled") + @parametrize + def test_method_open_sessions(self, client: Opencode) -> None: + tui = client.tui.open_sessions() + assert_matches_type(TuiOpenSessionsResponse, tui, path=["response"]) + + @pytest.mark.skip(reason="Prism tests are disabled") + @parametrize + def test_method_open_sessions_with_all_params(self, client: Opencode) -> None: + tui = client.tui.open_sessions( + directory="directory", + ) + assert_matches_type(TuiOpenSessionsResponse, tui, path=["response"]) + + @pytest.mark.skip(reason="Prism tests are disabled") + @parametrize + def test_raw_response_open_sessions(self, client: Opencode) -> None: + response = client.tui.with_raw_response.open_sessions() + + assert response.is_closed is True + assert response.http_request.headers.get("X-Stainless-Lang") == "python" + tui = response.parse() + assert_matches_type(TuiOpenSessionsResponse, tui, path=["response"]) + + @pytest.mark.skip(reason="Prism tests are disabled") + @parametrize + def test_streaming_response_open_sessions(self, client: Opencode) -> None: + with client.tui.with_streaming_response.open_sessions() as response: + assert not response.is_closed + assert response.http_request.headers.get("X-Stainless-Lang") == "python" + + tui = response.parse() + assert_matches_type(TuiOpenSessionsResponse, tui, path=["response"]) + + assert cast(Any, response.is_closed) is True + + @pytest.mark.skip(reason="Prism tests are disabled") + @parametrize + def test_method_open_themes(self, client: Opencode) -> None: + tui = client.tui.open_themes() + assert_matches_type(TuiOpenThemesResponse, tui, path=["response"]) + + @pytest.mark.skip(reason="Prism tests are disabled") + @parametrize + def test_method_open_themes_with_all_params(self, client: Opencode) -> None: + tui = client.tui.open_themes( + directory="directory", + ) + assert_matches_type(TuiOpenThemesResponse, tui, path=["response"]) + + @pytest.mark.skip(reason="Prism tests are disabled") + @parametrize + def test_raw_response_open_themes(self, client: Opencode) -> None: + response = client.tui.with_raw_response.open_themes() + + assert response.is_closed is True + assert response.http_request.headers.get("X-Stainless-Lang") == "python" + tui = response.parse() + assert_matches_type(TuiOpenThemesResponse, tui, path=["response"]) + + @pytest.mark.skip(reason="Prism tests are disabled") + @parametrize + def test_streaming_response_open_themes(self, client: Opencode) -> None: + with client.tui.with_streaming_response.open_themes() as response: + assert not response.is_closed + assert response.http_request.headers.get("X-Stainless-Lang") == "python" + + tui = response.parse() + assert_matches_type(TuiOpenThemesResponse, tui, path=["response"]) + + assert cast(Any, response.is_closed) is True + + @pytest.mark.skip(reason="Prism tests are disabled") + @parametrize + def test_method_show_toast(self, client: Opencode) -> None: + tui = client.tui.show_toast( + message="message", + variant="info", + ) + assert_matches_type(TuiShowToastResponse, tui, path=["response"]) + + @pytest.mark.skip(reason="Prism tests are disabled") + @parametrize + def test_method_show_toast_with_all_params(self, client: Opencode) -> None: + tui = client.tui.show_toast( + message="message", + variant="info", + directory="directory", + title="title", + ) + assert_matches_type(TuiShowToastResponse, tui, path=["response"]) + + @pytest.mark.skip(reason="Prism tests are disabled") + @parametrize + def test_raw_response_show_toast(self, client: Opencode) -> None: + response = client.tui.with_raw_response.show_toast( + message="message", + variant="info", + ) + + assert response.is_closed is True + assert response.http_request.headers.get("X-Stainless-Lang") == "python" + tui = response.parse() + assert_matches_type(TuiShowToastResponse, tui, path=["response"]) + + @pytest.mark.skip(reason="Prism tests are disabled") + @parametrize + def test_streaming_response_show_toast(self, client: Opencode) -> None: + with client.tui.with_streaming_response.show_toast( + message="message", + variant="info", + ) as response: + assert not response.is_closed + assert response.http_request.headers.get("X-Stainless-Lang") == "python" + + tui = response.parse() + assert_matches_type(TuiShowToastResponse, tui, path=["response"]) + + assert cast(Any, response.is_closed) is True + + @pytest.mark.skip(reason="Prism tests are disabled") + @parametrize + def test_method_submit_prompt(self, client: Opencode) -> None: + tui = client.tui.submit_prompt() + assert_matches_type(TuiSubmitPromptResponse, tui, path=["response"]) + + @pytest.mark.skip(reason="Prism tests are disabled") + @parametrize + def test_method_submit_prompt_with_all_params(self, client: Opencode) -> None: + tui = client.tui.submit_prompt( + directory="directory", + ) + assert_matches_type(TuiSubmitPromptResponse, tui, path=["response"]) + + @pytest.mark.skip(reason="Prism tests are disabled") + @parametrize + def test_raw_response_submit_prompt(self, client: Opencode) -> None: + response = client.tui.with_raw_response.submit_prompt() + + assert response.is_closed is True + assert response.http_request.headers.get("X-Stainless-Lang") == "python" + tui = response.parse() + assert_matches_type(TuiSubmitPromptResponse, tui, path=["response"]) + + @pytest.mark.skip(reason="Prism tests are disabled") + @parametrize + def test_streaming_response_submit_prompt(self, client: Opencode) -> None: + with client.tui.with_streaming_response.submit_prompt() as response: + assert not response.is_closed + assert response.http_request.headers.get("X-Stainless-Lang") == "python" + + tui = response.parse() + assert_matches_type(TuiSubmitPromptResponse, tui, path=["response"]) + + assert cast(Any, response.is_closed) is True + + +class TestAsyncTui: + parametrize = pytest.mark.parametrize( + "async_client", [False, True, {"http_client": "aiohttp"}], indirect=True, ids=["loose", "strict", "aiohttp"] + ) + + @pytest.mark.skip(reason="Prism tests are disabled") + @parametrize + async def test_method_append_prompt(self, async_client: AsyncOpencode) -> None: + tui = await async_client.tui.append_prompt( + text="text", + ) + assert_matches_type(TuiAppendPromptResponse, tui, path=["response"]) + + @pytest.mark.skip(reason="Prism tests are disabled") + @parametrize + async def test_method_append_prompt_with_all_params(self, async_client: AsyncOpencode) -> None: + tui = await async_client.tui.append_prompt( + text="text", + directory="directory", + ) + assert_matches_type(TuiAppendPromptResponse, tui, path=["response"]) + + @pytest.mark.skip(reason="Prism tests are disabled") + @parametrize + async def test_raw_response_append_prompt(self, async_client: AsyncOpencode) -> None: + response = await async_client.tui.with_raw_response.append_prompt( + text="text", + ) + + assert response.is_closed is True + assert response.http_request.headers.get("X-Stainless-Lang") == "python" + tui = await response.parse() + assert_matches_type(TuiAppendPromptResponse, tui, path=["response"]) + + @pytest.mark.skip(reason="Prism tests are disabled") + @parametrize + async def test_streaming_response_append_prompt(self, async_client: AsyncOpencode) -> None: + async with async_client.tui.with_streaming_response.append_prompt( + text="text", + ) as response: + assert not response.is_closed + assert response.http_request.headers.get("X-Stainless-Lang") == "python" + + tui = await response.parse() + assert_matches_type(TuiAppendPromptResponse, tui, path=["response"]) + + assert cast(Any, response.is_closed) is True + + @pytest.mark.skip(reason="Prism tests are disabled") + @parametrize + async def test_method_clear_prompt(self, async_client: AsyncOpencode) -> None: + tui = await async_client.tui.clear_prompt() + assert_matches_type(TuiClearPromptResponse, tui, path=["response"]) + + @pytest.mark.skip(reason="Prism tests are disabled") + @parametrize + async def test_method_clear_prompt_with_all_params(self, async_client: AsyncOpencode) -> None: + tui = await async_client.tui.clear_prompt( + directory="directory", + ) + assert_matches_type(TuiClearPromptResponse, tui, path=["response"]) + + @pytest.mark.skip(reason="Prism tests are disabled") + @parametrize + async def test_raw_response_clear_prompt(self, async_client: AsyncOpencode) -> None: + response = await async_client.tui.with_raw_response.clear_prompt() + + assert response.is_closed is True + assert response.http_request.headers.get("X-Stainless-Lang") == "python" + tui = await response.parse() + assert_matches_type(TuiClearPromptResponse, tui, path=["response"]) + + @pytest.mark.skip(reason="Prism tests are disabled") + @parametrize + async def test_streaming_response_clear_prompt(self, async_client: AsyncOpencode) -> None: + async with async_client.tui.with_streaming_response.clear_prompt() as response: + assert not response.is_closed + assert response.http_request.headers.get("X-Stainless-Lang") == "python" + + tui = await response.parse() + assert_matches_type(TuiClearPromptResponse, tui, path=["response"]) + + assert cast(Any, response.is_closed) is True + + @pytest.mark.skip(reason="Prism tests are disabled") + @parametrize + async def test_method_execute_command(self, async_client: AsyncOpencode) -> None: + tui = await async_client.tui.execute_command( + command="command", + ) + assert_matches_type(TuiExecuteCommandResponse, tui, path=["response"]) + + @pytest.mark.skip(reason="Prism tests are disabled") + @parametrize + async def test_method_execute_command_with_all_params(self, async_client: AsyncOpencode) -> None: + tui = await async_client.tui.execute_command( + command="command", + directory="directory", + ) + assert_matches_type(TuiExecuteCommandResponse, tui, path=["response"]) + + @pytest.mark.skip(reason="Prism tests are disabled") + @parametrize + async def test_raw_response_execute_command(self, async_client: AsyncOpencode) -> None: + response = await async_client.tui.with_raw_response.execute_command( + command="command", + ) + + assert response.is_closed is True + assert response.http_request.headers.get("X-Stainless-Lang") == "python" + tui = await response.parse() + assert_matches_type(TuiExecuteCommandResponse, tui, path=["response"]) + + @pytest.mark.skip(reason="Prism tests are disabled") + @parametrize + async def test_streaming_response_execute_command(self, async_client: AsyncOpencode) -> None: + async with async_client.tui.with_streaming_response.execute_command( + command="command", + ) as response: + assert not response.is_closed + assert response.http_request.headers.get("X-Stainless-Lang") == "python" + + tui = await response.parse() + assert_matches_type(TuiExecuteCommandResponse, tui, path=["response"]) + + assert cast(Any, response.is_closed) is True + + @pytest.mark.skip(reason="Prism tests are disabled") + @parametrize + async def test_method_open_help(self, async_client: AsyncOpencode) -> None: + tui = await async_client.tui.open_help() + assert_matches_type(TuiOpenHelpResponse, tui, path=["response"]) + + @pytest.mark.skip(reason="Prism tests are disabled") + @parametrize + async def test_method_open_help_with_all_params(self, async_client: AsyncOpencode) -> None: + tui = await async_client.tui.open_help( + directory="directory", + ) + assert_matches_type(TuiOpenHelpResponse, tui, path=["response"]) + + @pytest.mark.skip(reason="Prism tests are disabled") + @parametrize + async def test_raw_response_open_help(self, async_client: AsyncOpencode) -> None: + response = await async_client.tui.with_raw_response.open_help() + + assert response.is_closed is True + assert response.http_request.headers.get("X-Stainless-Lang") == "python" + tui = await response.parse() + assert_matches_type(TuiOpenHelpResponse, tui, path=["response"]) + + @pytest.mark.skip(reason="Prism tests are disabled") + @parametrize + async def test_streaming_response_open_help(self, async_client: AsyncOpencode) -> None: + async with async_client.tui.with_streaming_response.open_help() as response: + assert not response.is_closed + assert response.http_request.headers.get("X-Stainless-Lang") == "python" + + tui = await response.parse() + assert_matches_type(TuiOpenHelpResponse, tui, path=["response"]) + + assert cast(Any, response.is_closed) is True + + @pytest.mark.skip(reason="Prism tests are disabled") + @parametrize + async def test_method_open_models(self, async_client: AsyncOpencode) -> None: + tui = await async_client.tui.open_models() + assert_matches_type(TuiOpenModelsResponse, tui, path=["response"]) + + @pytest.mark.skip(reason="Prism tests are disabled") + @parametrize + async def test_method_open_models_with_all_params(self, async_client: AsyncOpencode) -> None: + tui = await async_client.tui.open_models( + directory="directory", + ) + assert_matches_type(TuiOpenModelsResponse, tui, path=["response"]) + + @pytest.mark.skip(reason="Prism tests are disabled") + @parametrize + async def test_raw_response_open_models(self, async_client: AsyncOpencode) -> None: + response = await async_client.tui.with_raw_response.open_models() + + assert response.is_closed is True + assert response.http_request.headers.get("X-Stainless-Lang") == "python" + tui = await response.parse() + assert_matches_type(TuiOpenModelsResponse, tui, path=["response"]) + + @pytest.mark.skip(reason="Prism tests are disabled") + @parametrize + async def test_streaming_response_open_models(self, async_client: AsyncOpencode) -> None: + async with async_client.tui.with_streaming_response.open_models() as response: + assert not response.is_closed + assert response.http_request.headers.get("X-Stainless-Lang") == "python" + + tui = await response.parse() + assert_matches_type(TuiOpenModelsResponse, tui, path=["response"]) + + assert cast(Any, response.is_closed) is True + + @pytest.mark.skip(reason="Prism tests are disabled") + @parametrize + async def test_method_open_sessions(self, async_client: AsyncOpencode) -> None: + tui = await async_client.tui.open_sessions() + assert_matches_type(TuiOpenSessionsResponse, tui, path=["response"]) + + @pytest.mark.skip(reason="Prism tests are disabled") + @parametrize + async def test_method_open_sessions_with_all_params(self, async_client: AsyncOpencode) -> None: + tui = await async_client.tui.open_sessions( + directory="directory", + ) + assert_matches_type(TuiOpenSessionsResponse, tui, path=["response"]) + + @pytest.mark.skip(reason="Prism tests are disabled") + @parametrize + async def test_raw_response_open_sessions(self, async_client: AsyncOpencode) -> None: + response = await async_client.tui.with_raw_response.open_sessions() + + assert response.is_closed is True + assert response.http_request.headers.get("X-Stainless-Lang") == "python" + tui = await response.parse() + assert_matches_type(TuiOpenSessionsResponse, tui, path=["response"]) + + @pytest.mark.skip(reason="Prism tests are disabled") + @parametrize + async def test_streaming_response_open_sessions(self, async_client: AsyncOpencode) -> None: + async with async_client.tui.with_streaming_response.open_sessions() as response: + assert not response.is_closed + assert response.http_request.headers.get("X-Stainless-Lang") == "python" + + tui = await response.parse() + assert_matches_type(TuiOpenSessionsResponse, tui, path=["response"]) + + assert cast(Any, response.is_closed) is True + + @pytest.mark.skip(reason="Prism tests are disabled") + @parametrize + async def test_method_open_themes(self, async_client: AsyncOpencode) -> None: + tui = await async_client.tui.open_themes() + assert_matches_type(TuiOpenThemesResponse, tui, path=["response"]) + + @pytest.mark.skip(reason="Prism tests are disabled") + @parametrize + async def test_method_open_themes_with_all_params(self, async_client: AsyncOpencode) -> None: + tui = await async_client.tui.open_themes( + directory="directory", + ) + assert_matches_type(TuiOpenThemesResponse, tui, path=["response"]) + + @pytest.mark.skip(reason="Prism tests are disabled") + @parametrize + async def test_raw_response_open_themes(self, async_client: AsyncOpencode) -> None: + response = await async_client.tui.with_raw_response.open_themes() + + assert response.is_closed is True + assert response.http_request.headers.get("X-Stainless-Lang") == "python" + tui = await response.parse() + assert_matches_type(TuiOpenThemesResponse, tui, path=["response"]) + + @pytest.mark.skip(reason="Prism tests are disabled") + @parametrize + async def test_streaming_response_open_themes(self, async_client: AsyncOpencode) -> None: + async with async_client.tui.with_streaming_response.open_themes() as response: + assert not response.is_closed + assert response.http_request.headers.get("X-Stainless-Lang") == "python" + + tui = await response.parse() + assert_matches_type(TuiOpenThemesResponse, tui, path=["response"]) + + assert cast(Any, response.is_closed) is True + + @pytest.mark.skip(reason="Prism tests are disabled") + @parametrize + async def test_method_show_toast(self, async_client: AsyncOpencode) -> None: + tui = await async_client.tui.show_toast( + message="message", + variant="info", + ) + assert_matches_type(TuiShowToastResponse, tui, path=["response"]) + + @pytest.mark.skip(reason="Prism tests are disabled") + @parametrize + async def test_method_show_toast_with_all_params(self, async_client: AsyncOpencode) -> None: + tui = await async_client.tui.show_toast( + message="message", + variant="info", + directory="directory", + title="title", + ) + assert_matches_type(TuiShowToastResponse, tui, path=["response"]) + + @pytest.mark.skip(reason="Prism tests are disabled") + @parametrize + async def test_raw_response_show_toast(self, async_client: AsyncOpencode) -> None: + response = await async_client.tui.with_raw_response.show_toast( + message="message", + variant="info", + ) + + assert response.is_closed is True + assert response.http_request.headers.get("X-Stainless-Lang") == "python" + tui = await response.parse() + assert_matches_type(TuiShowToastResponse, tui, path=["response"]) + + @pytest.mark.skip(reason="Prism tests are disabled") + @parametrize + async def test_streaming_response_show_toast(self, async_client: AsyncOpencode) -> None: + async with async_client.tui.with_streaming_response.show_toast( + message="message", + variant="info", + ) as response: + assert not response.is_closed + assert response.http_request.headers.get("X-Stainless-Lang") == "python" + + tui = await response.parse() + assert_matches_type(TuiShowToastResponse, tui, path=["response"]) + + assert cast(Any, response.is_closed) is True + + @pytest.mark.skip(reason="Prism tests are disabled") + @parametrize + async def test_method_submit_prompt(self, async_client: AsyncOpencode) -> None: + tui = await async_client.tui.submit_prompt() + assert_matches_type(TuiSubmitPromptResponse, tui, path=["response"]) + + @pytest.mark.skip(reason="Prism tests are disabled") + @parametrize + async def test_method_submit_prompt_with_all_params(self, async_client: AsyncOpencode) -> None: + tui = await async_client.tui.submit_prompt( + directory="directory", + ) + assert_matches_type(TuiSubmitPromptResponse, tui, path=["response"]) + + @pytest.mark.skip(reason="Prism tests are disabled") + @parametrize + async def test_raw_response_submit_prompt(self, async_client: AsyncOpencode) -> None: + response = await async_client.tui.with_raw_response.submit_prompt() + + assert response.is_closed is True + assert response.http_request.headers.get("X-Stainless-Lang") == "python" + tui = await response.parse() + assert_matches_type(TuiSubmitPromptResponse, tui, path=["response"]) + + @pytest.mark.skip(reason="Prism tests are disabled") + @parametrize + async def test_streaming_response_submit_prompt(self, async_client: AsyncOpencode) -> None: + async with async_client.tui.with_streaming_response.submit_prompt() as response: + assert not response.is_closed + assert response.http_request.headers.get("X-Stainless-Lang") == "python" + + tui = await response.parse() + assert_matches_type(TuiSubmitPromptResponse, tui, path=["response"]) + + assert cast(Any, response.is_closed) is True diff --git a/tests/conftest.py b/tests/conftest.py new file mode 100644 index 0000000..aa47bd6 --- /dev/null +++ b/tests/conftest.py @@ -0,0 +1,80 @@ +# File generated from our OpenAPI spec by Stainless. See CONTRIBUTING.md for details. + +from __future__ import annotations + +import os +import logging +from typing import TYPE_CHECKING, Iterator, AsyncIterator + +import httpx +import pytest +from pytest_asyncio import is_async_test + +from opencode_ai import Opencode, AsyncOpencode, DefaultAioHttpClient +from opencode_ai._utils import is_dict + +if TYPE_CHECKING: + from _pytest.fixtures import FixtureRequest # pyright: ignore[reportPrivateImportUsage] + +pytest.register_assert_rewrite("tests.utils") + +logging.getLogger("opencode_ai").setLevel(logging.DEBUG) + + +# automatically add `pytest.mark.asyncio()` to all of our async tests +# so we don't have to add that boilerplate everywhere +def pytest_collection_modifyitems(items: list[pytest.Function]) -> None: + pytest_asyncio_tests = (item for item in items if is_async_test(item)) + session_scope_marker = pytest.mark.asyncio(loop_scope="session") + for async_test in pytest_asyncio_tests: + async_test.add_marker(session_scope_marker, append=False) + + # We skip tests that use both the aiohttp client and respx_mock as respx_mock + # doesn't support custom transports. + for item in items: + if "async_client" not in item.fixturenames or "respx_mock" not in item.fixturenames: + continue + + if not hasattr(item, "callspec"): + continue + + async_client_param = item.callspec.params.get("async_client") + if is_dict(async_client_param) and async_client_param.get("http_client") == "aiohttp": + item.add_marker(pytest.mark.skip(reason="aiohttp client is not compatible with respx_mock")) + + +base_url = os.environ.get("TEST_API_BASE_URL", "http://127.0.0.1:4010") + + +@pytest.fixture(scope="session") +def client(request: FixtureRequest) -> Iterator[Opencode]: + strict = getattr(request, "param", True) + if not isinstance(strict, bool): + raise TypeError(f"Unexpected fixture parameter type {type(strict)}, expected {bool}") + + with Opencode(base_url=base_url, _strict_response_validation=strict) as client: + yield client + + +@pytest.fixture(scope="session") +async def async_client(request: FixtureRequest) -> AsyncIterator[AsyncOpencode]: + param = getattr(request, "param", True) + + # defaults + strict = True + http_client: None | httpx.AsyncClient = None + + if isinstance(param, bool): + strict = param + elif is_dict(param): + strict = param.get("strict", True) + assert isinstance(strict, bool) + + http_client_type = param.get("http_client", "httpx") + if http_client_type == "aiohttp": + http_client = DefaultAioHttpClient() + else: + raise TypeError(f"Unexpected fixture parameter type {type(param)}, expected bool or dict") + + async with AsyncOpencode(base_url=base_url, _strict_response_validation=strict, http_client=http_client) as client: + yield client diff --git a/tests/sample_file.txt b/tests/sample_file.txt new file mode 100644 index 0000000..af5626b --- /dev/null +++ b/tests/sample_file.txt @@ -0,0 +1 @@ +Hello, world! diff --git a/tests/test_client.py b/tests/test_client.py new file mode 100644 index 0000000..f8225c7 --- /dev/null +++ b/tests/test_client.py @@ -0,0 +1,1661 @@ +# File generated from our OpenAPI spec by Stainless. See CONTRIBUTING.md for details. + +from __future__ import annotations + +import gc +import os +import sys +import json +import time +import asyncio +import inspect +import subprocess +import tracemalloc +from typing import Any, Union, cast +from textwrap import dedent +from unittest import mock +from typing_extensions import Literal + +import httpx +import pytest +from respx import MockRouter +from pydantic import ValidationError + +from opencode_ai import Opencode, AsyncOpencode, APIResponseValidationError +from opencode_ai._types import Omit +from opencode_ai._models import BaseModel, FinalRequestOptions +from opencode_ai._streaming import Stream, AsyncStream +from opencode_ai._exceptions import APIStatusError, APITimeoutError, APIResponseValidationError +from opencode_ai._base_client import ( + DEFAULT_TIMEOUT, + HTTPX_DEFAULT_TIMEOUT, + BaseClient, + DefaultHttpxClient, + DefaultAsyncHttpxClient, + make_request_options, +) + +from .utils import update_env + +base_url = os.environ.get("TEST_API_BASE_URL", "http://127.0.0.1:4010") + + +def _get_params(client: BaseClient[Any, Any]) -> dict[str, str]: + request = client._build_request(FinalRequestOptions(method="get", url="/foo")) + url = httpx.URL(request.url) + return dict(url.params) + + +def _low_retry_timeout(*_args: Any, **_kwargs: Any) -> float: + return 0.1 + + +def _get_open_connections(client: Opencode | AsyncOpencode) -> int: + transport = client._client._transport + assert isinstance(transport, httpx.HTTPTransport) or isinstance(transport, httpx.AsyncHTTPTransport) + + pool = transport._pool + return len(pool._requests) + + +class TestOpencode: + client = Opencode(base_url=base_url, _strict_response_validation=True) + + @pytest.mark.respx(base_url=base_url) + def test_raw_response(self, respx_mock: MockRouter) -> None: + respx_mock.post("/foo").mock(return_value=httpx.Response(200, json={"foo": "bar"})) + + response = self.client.post("/foo", cast_to=httpx.Response) + assert response.status_code == 200 + assert isinstance(response, httpx.Response) + assert response.json() == {"foo": "bar"} + + @pytest.mark.respx(base_url=base_url) + def test_raw_response_for_binary(self, respx_mock: MockRouter) -> None: + respx_mock.post("/foo").mock( + return_value=httpx.Response(200, headers={"Content-Type": "application/binary"}, content='{"foo": "bar"}') + ) + + response = self.client.post("/foo", cast_to=httpx.Response) + assert response.status_code == 200 + assert isinstance(response, httpx.Response) + assert response.json() == {"foo": "bar"} + + def test_copy(self) -> None: + copied = self.client.copy() + assert id(copied) != id(self.client) + + def test_copy_default_options(self) -> None: + # options that have a default are overridden correctly + copied = self.client.copy(max_retries=7) + assert copied.max_retries == 7 + assert self.client.max_retries == 2 + + copied2 = copied.copy(max_retries=6) + assert copied2.max_retries == 6 + assert copied.max_retries == 7 + + # timeout + assert isinstance(self.client.timeout, httpx.Timeout) + copied = self.client.copy(timeout=None) + assert copied.timeout is None + assert isinstance(self.client.timeout, httpx.Timeout) + + def test_copy_default_headers(self) -> None: + client = Opencode(base_url=base_url, _strict_response_validation=True, default_headers={"X-Foo": "bar"}) + assert client.default_headers["X-Foo"] == "bar" + + # does not override the already given value when not specified + copied = client.copy() + assert copied.default_headers["X-Foo"] == "bar" + + # merges already given headers + copied = client.copy(default_headers={"X-Bar": "stainless"}) + assert copied.default_headers["X-Foo"] == "bar" + assert copied.default_headers["X-Bar"] == "stainless" + + # uses new values for any already given headers + copied = client.copy(default_headers={"X-Foo": "stainless"}) + assert copied.default_headers["X-Foo"] == "stainless" + + # set_default_headers + + # completely overrides already set values + copied = client.copy(set_default_headers={}) + assert copied.default_headers.get("X-Foo") is None + + copied = client.copy(set_default_headers={"X-Bar": "Robert"}) + assert copied.default_headers["X-Bar"] == "Robert" + + with pytest.raises( + ValueError, + match="`default_headers` and `set_default_headers` arguments are mutually exclusive", + ): + client.copy(set_default_headers={}, default_headers={"X-Foo": "Bar"}) + + def test_copy_default_query(self) -> None: + client = Opencode(base_url=base_url, _strict_response_validation=True, default_query={"foo": "bar"}) + assert _get_params(client)["foo"] == "bar" + + # does not override the already given value when not specified + copied = client.copy() + assert _get_params(copied)["foo"] == "bar" + + # merges already given params + copied = client.copy(default_query={"bar": "stainless"}) + params = _get_params(copied) + assert params["foo"] == "bar" + assert params["bar"] == "stainless" + + # uses new values for any already given headers + copied = client.copy(default_query={"foo": "stainless"}) + assert _get_params(copied)["foo"] == "stainless" + + # set_default_query + + # completely overrides already set values + copied = client.copy(set_default_query={}) + assert _get_params(copied) == {} + + copied = client.copy(set_default_query={"bar": "Robert"}) + assert _get_params(copied)["bar"] == "Robert" + + with pytest.raises( + ValueError, + # TODO: update + match="`default_query` and `set_default_query` arguments are mutually exclusive", + ): + client.copy(set_default_query={}, default_query={"foo": "Bar"}) + + def test_copy_signature(self) -> None: + # ensure the same parameters that can be passed to the client are defined in the `.copy()` method + init_signature = inspect.signature( + # mypy doesn't like that we access the `__init__` property. + self.client.__init__, # type: ignore[misc] + ) + copy_signature = inspect.signature(self.client.copy) + exclude_params = {"transport", "proxies", "_strict_response_validation"} + + for name in init_signature.parameters.keys(): + if name in exclude_params: + continue + + copy_param = copy_signature.parameters.get(name) + assert copy_param is not None, f"copy() signature is missing the {name} param" + + @pytest.mark.skipif(sys.version_info >= (3, 10), reason="fails because of a memory leak that started from 3.12") + def test_copy_build_request(self) -> None: + options = FinalRequestOptions(method="get", url="/foo") + + def build_request(options: FinalRequestOptions) -> None: + client = self.client.copy() + client._build_request(options) + + # ensure that the machinery is warmed up before tracing starts. + build_request(options) + gc.collect() + + tracemalloc.start(1000) + + snapshot_before = tracemalloc.take_snapshot() + + ITERATIONS = 10 + for _ in range(ITERATIONS): + build_request(options) + + gc.collect() + snapshot_after = tracemalloc.take_snapshot() + + tracemalloc.stop() + + def add_leak(leaks: list[tracemalloc.StatisticDiff], diff: tracemalloc.StatisticDiff) -> None: + if diff.count == 0: + # Avoid false positives by considering only leaks (i.e. allocations that persist). + return + + if diff.count % ITERATIONS != 0: + # Avoid false positives by considering only leaks that appear per iteration. + return + + for frame in diff.traceback: + if any( + frame.filename.endswith(fragment) + for fragment in [ + # to_raw_response_wrapper leaks through the @functools.wraps() decorator. + # + # removing the decorator fixes the leak for reasons we don't understand. + "opencode_ai/_legacy_response.py", + "opencode_ai/_response.py", + # pydantic.BaseModel.model_dump || pydantic.BaseModel.dict leak memory for some reason. + "opencode_ai/_compat.py", + # Standard library leaks we don't care about. + "/logging/__init__.py", + ] + ): + return + + leaks.append(diff) + + leaks: list[tracemalloc.StatisticDiff] = [] + for diff in snapshot_after.compare_to(snapshot_before, "traceback"): + add_leak(leaks, diff) + if leaks: + for leak in leaks: + print("MEMORY LEAK:", leak) + for frame in leak.traceback: + print(frame) + raise AssertionError() + + def test_request_timeout(self) -> None: + request = self.client._build_request(FinalRequestOptions(method="get", url="/foo")) + timeout = httpx.Timeout(**request.extensions["timeout"]) # type: ignore + assert timeout == DEFAULT_TIMEOUT + + request = self.client._build_request( + FinalRequestOptions(method="get", url="/foo", timeout=httpx.Timeout(100.0)) + ) + timeout = httpx.Timeout(**request.extensions["timeout"]) # type: ignore + assert timeout == httpx.Timeout(100.0) + + def test_client_timeout_option(self) -> None: + client = Opencode(base_url=base_url, _strict_response_validation=True, timeout=httpx.Timeout(0)) + + request = client._build_request(FinalRequestOptions(method="get", url="/foo")) + timeout = httpx.Timeout(**request.extensions["timeout"]) # type: ignore + assert timeout == httpx.Timeout(0) + + def test_http_client_timeout_option(self) -> None: + # custom timeout given to the httpx client should be used + with httpx.Client(timeout=None) as http_client: + client = Opencode(base_url=base_url, _strict_response_validation=True, http_client=http_client) + + request = client._build_request(FinalRequestOptions(method="get", url="/foo")) + timeout = httpx.Timeout(**request.extensions["timeout"]) # type: ignore + assert timeout == httpx.Timeout(None) + + # no timeout given to the httpx client should not use the httpx default + with httpx.Client() as http_client: + client = Opencode(base_url=base_url, _strict_response_validation=True, http_client=http_client) + + request = client._build_request(FinalRequestOptions(method="get", url="/foo")) + timeout = httpx.Timeout(**request.extensions["timeout"]) # type: ignore + assert timeout == DEFAULT_TIMEOUT + + # explicitly passing the default timeout currently results in it being ignored + with httpx.Client(timeout=HTTPX_DEFAULT_TIMEOUT) as http_client: + client = Opencode(base_url=base_url, _strict_response_validation=True, http_client=http_client) + + request = client._build_request(FinalRequestOptions(method="get", url="/foo")) + timeout = httpx.Timeout(**request.extensions["timeout"]) # type: ignore + assert timeout == DEFAULT_TIMEOUT # our default + + async def test_invalid_http_client(self) -> None: + with pytest.raises(TypeError, match="Invalid `http_client` arg"): + async with httpx.AsyncClient() as http_client: + Opencode(base_url=base_url, _strict_response_validation=True, http_client=cast(Any, http_client)) + + def test_default_headers_option(self) -> None: + client = Opencode(base_url=base_url, _strict_response_validation=True, default_headers={"X-Foo": "bar"}) + request = client._build_request(FinalRequestOptions(method="get", url="/foo")) + assert request.headers.get("x-foo") == "bar" + assert request.headers.get("x-stainless-lang") == "python" + + client2 = Opencode( + base_url=base_url, + _strict_response_validation=True, + default_headers={ + "X-Foo": "stainless", + "X-Stainless-Lang": "my-overriding-header", + }, + ) + request = client2._build_request(FinalRequestOptions(method="get", url="/foo")) + assert request.headers.get("x-foo") == "stainless" + assert request.headers.get("x-stainless-lang") == "my-overriding-header" + + def test_default_query_option(self) -> None: + client = Opencode(base_url=base_url, _strict_response_validation=True, default_query={"query_param": "bar"}) + request = client._build_request(FinalRequestOptions(method="get", url="/foo")) + url = httpx.URL(request.url) + assert dict(url.params) == {"query_param": "bar"} + + request = client._build_request( + FinalRequestOptions( + method="get", + url="/foo", + params={"foo": "baz", "query_param": "overridden"}, + ) + ) + url = httpx.URL(request.url) + assert dict(url.params) == {"foo": "baz", "query_param": "overridden"} + + def test_request_extra_json(self) -> None: + request = self.client._build_request( + FinalRequestOptions( + method="post", + url="/foo", + json_data={"foo": "bar"}, + extra_json={"baz": False}, + ), + ) + data = json.loads(request.content.decode("utf-8")) + assert data == {"foo": "bar", "baz": False} + + request = self.client._build_request( + FinalRequestOptions( + method="post", + url="/foo", + extra_json={"baz": False}, + ), + ) + data = json.loads(request.content.decode("utf-8")) + assert data == {"baz": False} + + # `extra_json` takes priority over `json_data` when keys clash + request = self.client._build_request( + FinalRequestOptions( + method="post", + url="/foo", + json_data={"foo": "bar", "baz": True}, + extra_json={"baz": None}, + ), + ) + data = json.loads(request.content.decode("utf-8")) + assert data == {"foo": "bar", "baz": None} + + def test_request_extra_headers(self) -> None: + request = self.client._build_request( + FinalRequestOptions( + method="post", + url="/foo", + **make_request_options(extra_headers={"X-Foo": "Foo"}), + ), + ) + assert request.headers.get("X-Foo") == "Foo" + + # `extra_headers` takes priority over `default_headers` when keys clash + request = self.client.with_options(default_headers={"X-Bar": "true"})._build_request( + FinalRequestOptions( + method="post", + url="/foo", + **make_request_options( + extra_headers={"X-Bar": "false"}, + ), + ), + ) + assert request.headers.get("X-Bar") == "false" + + def test_request_extra_query(self) -> None: + request = self.client._build_request( + FinalRequestOptions( + method="post", + url="/foo", + **make_request_options( + extra_query={"my_query_param": "Foo"}, + ), + ), + ) + params = dict(request.url.params) + assert params == {"my_query_param": "Foo"} + + # if both `query` and `extra_query` are given, they are merged + request = self.client._build_request( + FinalRequestOptions( + method="post", + url="/foo", + **make_request_options( + query={"bar": "1"}, + extra_query={"foo": "2"}, + ), + ), + ) + params = dict(request.url.params) + assert params == {"bar": "1", "foo": "2"} + + # `extra_query` takes priority over `query` when keys clash + request = self.client._build_request( + FinalRequestOptions( + method="post", + url="/foo", + **make_request_options( + query={"foo": "1"}, + extra_query={"foo": "2"}, + ), + ), + ) + params = dict(request.url.params) + assert params == {"foo": "2"} + + def test_multipart_repeating_array(self, client: Opencode) -> None: + request = client._build_request( + FinalRequestOptions.construct( + method="post", + url="/foo", + headers={"Content-Type": "multipart/form-data; boundary=6b7ba517decee4a450543ea6ae821c82"}, + json_data={"array": ["foo", "bar"]}, + files=[("foo.txt", b"hello world")], + ) + ) + + assert request.read().split(b"\r\n") == [ + b"--6b7ba517decee4a450543ea6ae821c82", + b'Content-Disposition: form-data; name="array[]"', + b"", + b"foo", + b"--6b7ba517decee4a450543ea6ae821c82", + b'Content-Disposition: form-data; name="array[]"', + b"", + b"bar", + b"--6b7ba517decee4a450543ea6ae821c82", + b'Content-Disposition: form-data; name="foo.txt"; filename="upload"', + b"Content-Type: application/octet-stream", + b"", + b"hello world", + b"--6b7ba517decee4a450543ea6ae821c82--", + b"", + ] + + @pytest.mark.respx(base_url=base_url) + def test_basic_union_response(self, respx_mock: MockRouter) -> None: + class Model1(BaseModel): + name: str + + class Model2(BaseModel): + foo: str + + respx_mock.get("/foo").mock(return_value=httpx.Response(200, json={"foo": "bar"})) + + response = self.client.get("/foo", cast_to=cast(Any, Union[Model1, Model2])) + assert isinstance(response, Model2) + assert response.foo == "bar" + + @pytest.mark.respx(base_url=base_url) + def test_union_response_different_types(self, respx_mock: MockRouter) -> None: + """Union of objects with the same field name using a different type""" + + class Model1(BaseModel): + foo: int + + class Model2(BaseModel): + foo: str + + respx_mock.get("/foo").mock(return_value=httpx.Response(200, json={"foo": "bar"})) + + response = self.client.get("/foo", cast_to=cast(Any, Union[Model1, Model2])) + assert isinstance(response, Model2) + assert response.foo == "bar" + + respx_mock.get("/foo").mock(return_value=httpx.Response(200, json={"foo": 1})) + + response = self.client.get("/foo", cast_to=cast(Any, Union[Model1, Model2])) + assert isinstance(response, Model1) + assert response.foo == 1 + + @pytest.mark.respx(base_url=base_url) + def test_non_application_json_content_type_for_json_data(self, respx_mock: MockRouter) -> None: + """ + Response that sets Content-Type to something other than application/json but returns json data + """ + + class Model(BaseModel): + foo: int + + respx_mock.get("/foo").mock( + return_value=httpx.Response( + 200, + content=json.dumps({"foo": 2}), + headers={"Content-Type": "application/text"}, + ) + ) + + response = self.client.get("/foo", cast_to=Model) + assert isinstance(response, Model) + assert response.foo == 2 + + def test_base_url_setter(self) -> None: + client = Opencode(base_url="https://example.com/from_init", _strict_response_validation=True) + assert client.base_url == "https://example.com/from_init/" + + client.base_url = "https://example.com/from_setter" # type: ignore[assignment] + + assert client.base_url == "https://example.com/from_setter/" + + def test_base_url_env(self) -> None: + with update_env(OPENCODE_BASE_URL="http://localhost:5000/from/env"): + client = Opencode(_strict_response_validation=True) + assert client.base_url == "http://localhost:5000/from/env/" + + @pytest.mark.parametrize( + "client", + [ + Opencode(base_url="http://localhost:5000/custom/path/", _strict_response_validation=True), + Opencode( + base_url="http://localhost:5000/custom/path/", + _strict_response_validation=True, + http_client=httpx.Client(), + ), + ], + ids=["standard", "custom http client"], + ) + def test_base_url_trailing_slash(self, client: Opencode) -> None: + request = client._build_request( + FinalRequestOptions( + method="post", + url="/foo", + json_data={"foo": "bar"}, + ), + ) + assert request.url == "http://localhost:5000/custom/path/foo" + + @pytest.mark.parametrize( + "client", + [ + Opencode(base_url="http://localhost:5000/custom/path/", _strict_response_validation=True), + Opencode( + base_url="http://localhost:5000/custom/path/", + _strict_response_validation=True, + http_client=httpx.Client(), + ), + ], + ids=["standard", "custom http client"], + ) + def test_base_url_no_trailing_slash(self, client: Opencode) -> None: + request = client._build_request( + FinalRequestOptions( + method="post", + url="/foo", + json_data={"foo": "bar"}, + ), + ) + assert request.url == "http://localhost:5000/custom/path/foo" + + @pytest.mark.parametrize( + "client", + [ + Opencode(base_url="http://localhost:5000/custom/path/", _strict_response_validation=True), + Opencode( + base_url="http://localhost:5000/custom/path/", + _strict_response_validation=True, + http_client=httpx.Client(), + ), + ], + ids=["standard", "custom http client"], + ) + def test_absolute_request_url(self, client: Opencode) -> None: + request = client._build_request( + FinalRequestOptions( + method="post", + url="https://myapi.com/foo", + json_data={"foo": "bar"}, + ), + ) + assert request.url == "https://myapi.com/foo" + + def test_copied_client_does_not_close_http(self) -> None: + client = Opencode(base_url=base_url, _strict_response_validation=True) + assert not client.is_closed() + + copied = client.copy() + assert copied is not client + + del copied + + assert not client.is_closed() + + def test_client_context_manager(self) -> None: + client = Opencode(base_url=base_url, _strict_response_validation=True) + with client as c2: + assert c2 is client + assert not c2.is_closed() + assert not client.is_closed() + assert client.is_closed() + + @pytest.mark.respx(base_url=base_url) + def test_client_response_validation_error(self, respx_mock: MockRouter) -> None: + class Model(BaseModel): + foo: str + + respx_mock.get("/foo").mock(return_value=httpx.Response(200, json={"foo": {"invalid": True}})) + + with pytest.raises(APIResponseValidationError) as exc: + self.client.get("/foo", cast_to=Model) + + assert isinstance(exc.value.__cause__, ValidationError) + + def test_client_max_retries_validation(self) -> None: + with pytest.raises(TypeError, match=r"max_retries cannot be None"): + Opencode(base_url=base_url, _strict_response_validation=True, max_retries=cast(Any, None)) + + @pytest.mark.respx(base_url=base_url) + def test_default_stream_cls(self, respx_mock: MockRouter) -> None: + class Model(BaseModel): + name: str + + respx_mock.post("/foo").mock(return_value=httpx.Response(200, json={"foo": "bar"})) + + stream = self.client.post("/foo", cast_to=Model, stream=True, stream_cls=Stream[Model]) + assert isinstance(stream, Stream) + stream.response.close() + + @pytest.mark.respx(base_url=base_url) + def test_received_text_for_expected_json(self, respx_mock: MockRouter) -> None: + class Model(BaseModel): + name: str + + respx_mock.get("/foo").mock(return_value=httpx.Response(200, text="my-custom-format")) + + strict_client = Opencode(base_url=base_url, _strict_response_validation=True) + + with pytest.raises(APIResponseValidationError): + strict_client.get("/foo", cast_to=Model) + + client = Opencode(base_url=base_url, _strict_response_validation=False) + + response = client.get("/foo", cast_to=Model) + assert isinstance(response, str) # type: ignore[unreachable] + + @pytest.mark.parametrize( + "remaining_retries,retry_after,timeout", + [ + [3, "20", 20], + [3, "0", 0.5], + [3, "-10", 0.5], + [3, "60", 60], + [3, "61", 0.5], + [3, "Fri, 29 Sep 2023 16:26:57 GMT", 20], + [3, "Fri, 29 Sep 2023 16:26:37 GMT", 0.5], + [3, "Fri, 29 Sep 2023 16:26:27 GMT", 0.5], + [3, "Fri, 29 Sep 2023 16:27:37 GMT", 60], + [3, "Fri, 29 Sep 2023 16:27:38 GMT", 0.5], + [3, "99999999999999999999999999999999999", 0.5], + [3, "Zun, 29 Sep 2023 16:26:27 GMT", 0.5], + [3, "", 0.5], + [2, "", 0.5 * 2.0], + [1, "", 0.5 * 4.0], + [-1100, "", 8], # test large number potentially overflowing + ], + ) + @mock.patch("time.time", mock.MagicMock(return_value=1696004797)) + def test_parse_retry_after_header(self, remaining_retries: int, retry_after: str, timeout: float) -> None: + client = Opencode(base_url=base_url, _strict_response_validation=True) + + headers = httpx.Headers({"retry-after": retry_after}) + options = FinalRequestOptions(method="get", url="/foo", max_retries=3) + calculated = client._calculate_retry_timeout(remaining_retries, options, headers) + assert calculated == pytest.approx(timeout, 0.5 * 0.875) # pyright: ignore[reportUnknownMemberType] + + @mock.patch("opencode_ai._base_client.BaseClient._calculate_retry_timeout", _low_retry_timeout) + @pytest.mark.respx(base_url=base_url) + def test_retrying_timeout_errors_doesnt_leak(self, respx_mock: MockRouter, client: Opencode) -> None: + respx_mock.get("/session").mock(side_effect=httpx.TimeoutException("Test timeout error")) + + with pytest.raises(APITimeoutError): + client.session.with_streaming_response.list().__enter__() + + assert _get_open_connections(self.client) == 0 + + @mock.patch("opencode_ai._base_client.BaseClient._calculate_retry_timeout", _low_retry_timeout) + @pytest.mark.respx(base_url=base_url) + def test_retrying_status_errors_doesnt_leak(self, respx_mock: MockRouter, client: Opencode) -> None: + respx_mock.get("/session").mock(return_value=httpx.Response(500)) + + with pytest.raises(APIStatusError): + client.session.with_streaming_response.list().__enter__() + assert _get_open_connections(self.client) == 0 + + @pytest.mark.parametrize("failures_before_success", [0, 2, 4]) + @mock.patch("opencode_ai._base_client.BaseClient._calculate_retry_timeout", _low_retry_timeout) + @pytest.mark.respx(base_url=base_url) + @pytest.mark.parametrize("failure_mode", ["status", "exception"]) + def test_retries_taken( + self, + client: Opencode, + failures_before_success: int, + failure_mode: Literal["status", "exception"], + respx_mock: MockRouter, + ) -> None: + client = client.with_options(max_retries=4) + + nb_retries = 0 + + def retry_handler(_request: httpx.Request) -> httpx.Response: + nonlocal nb_retries + if nb_retries < failures_before_success: + nb_retries += 1 + if failure_mode == "exception": + raise RuntimeError("oops") + return httpx.Response(500) + return httpx.Response(200) + + respx_mock.get("/session").mock(side_effect=retry_handler) + + response = client.session.with_raw_response.list() + + assert response.retries_taken == failures_before_success + assert int(response.http_request.headers.get("x-stainless-retry-count")) == failures_before_success + + @pytest.mark.parametrize("failures_before_success", [0, 2, 4]) + @mock.patch("opencode_ai._base_client.BaseClient._calculate_retry_timeout", _low_retry_timeout) + @pytest.mark.respx(base_url=base_url) + def test_omit_retry_count_header( + self, client: Opencode, failures_before_success: int, respx_mock: MockRouter + ) -> None: + client = client.with_options(max_retries=4) + + nb_retries = 0 + + def retry_handler(_request: httpx.Request) -> httpx.Response: + nonlocal nb_retries + if nb_retries < failures_before_success: + nb_retries += 1 + return httpx.Response(500) + return httpx.Response(200) + + respx_mock.get("/session").mock(side_effect=retry_handler) + + response = client.session.with_raw_response.list(extra_headers={"x-stainless-retry-count": Omit()}) + + assert len(response.http_request.headers.get_list("x-stainless-retry-count")) == 0 + + @pytest.mark.parametrize("failures_before_success", [0, 2, 4]) + @mock.patch("opencode_ai._base_client.BaseClient._calculate_retry_timeout", _low_retry_timeout) + @pytest.mark.respx(base_url=base_url) + def test_overwrite_retry_count_header( + self, client: Opencode, failures_before_success: int, respx_mock: MockRouter + ) -> None: + client = client.with_options(max_retries=4) + + nb_retries = 0 + + def retry_handler(_request: httpx.Request) -> httpx.Response: + nonlocal nb_retries + if nb_retries < failures_before_success: + nb_retries += 1 + return httpx.Response(500) + return httpx.Response(200) + + respx_mock.get("/session").mock(side_effect=retry_handler) + + response = client.session.with_raw_response.list(extra_headers={"x-stainless-retry-count": "42"}) + + assert response.http_request.headers.get("x-stainless-retry-count") == "42" + + def test_proxy_environment_variables(self, monkeypatch: pytest.MonkeyPatch) -> None: + # Test that the proxy environment variables are set correctly + monkeypatch.setenv("HTTPS_PROXY", "https://example.org") + + client = DefaultHttpxClient() + + mounts = tuple(client._mounts.items()) + assert len(mounts) == 1 + assert mounts[0][0].pattern == "https://" + + @pytest.mark.filterwarnings("ignore:.*deprecated.*:DeprecationWarning") + def test_default_client_creation(self) -> None: + # Ensure that the client can be initialized without any exceptions + DefaultHttpxClient( + verify=True, + cert=None, + trust_env=True, + http1=True, + http2=False, + limits=httpx.Limits(max_connections=100, max_keepalive_connections=20), + ) + + @pytest.mark.respx(base_url=base_url) + def test_follow_redirects(self, respx_mock: MockRouter) -> None: + # Test that the default follow_redirects=True allows following redirects + respx_mock.post("/redirect").mock( + return_value=httpx.Response(302, headers={"Location": f"{base_url}/redirected"}) + ) + respx_mock.get("/redirected").mock(return_value=httpx.Response(200, json={"status": "ok"})) + + response = self.client.post("/redirect", body={"key": "value"}, cast_to=httpx.Response) + assert response.status_code == 200 + assert response.json() == {"status": "ok"} + + @pytest.mark.respx(base_url=base_url) + def test_follow_redirects_disabled(self, respx_mock: MockRouter) -> None: + # Test that follow_redirects=False prevents following redirects + respx_mock.post("/redirect").mock( + return_value=httpx.Response(302, headers={"Location": f"{base_url}/redirected"}) + ) + + with pytest.raises(APIStatusError) as exc_info: + self.client.post( + "/redirect", body={"key": "value"}, options={"follow_redirects": False}, cast_to=httpx.Response + ) + + assert exc_info.value.response.status_code == 302 + assert exc_info.value.response.headers["Location"] == f"{base_url}/redirected" + + +class TestAsyncOpencode: + client = AsyncOpencode(base_url=base_url, _strict_response_validation=True) + + @pytest.mark.respx(base_url=base_url) + @pytest.mark.asyncio + async def test_raw_response(self, respx_mock: MockRouter) -> None: + respx_mock.post("/foo").mock(return_value=httpx.Response(200, json={"foo": "bar"})) + + response = await self.client.post("/foo", cast_to=httpx.Response) + assert response.status_code == 200 + assert isinstance(response, httpx.Response) + assert response.json() == {"foo": "bar"} + + @pytest.mark.respx(base_url=base_url) + @pytest.mark.asyncio + async def test_raw_response_for_binary(self, respx_mock: MockRouter) -> None: + respx_mock.post("/foo").mock( + return_value=httpx.Response(200, headers={"Content-Type": "application/binary"}, content='{"foo": "bar"}') + ) + + response = await self.client.post("/foo", cast_to=httpx.Response) + assert response.status_code == 200 + assert isinstance(response, httpx.Response) + assert response.json() == {"foo": "bar"} + + def test_copy(self) -> None: + copied = self.client.copy() + assert id(copied) != id(self.client) + + def test_copy_default_options(self) -> None: + # options that have a default are overridden correctly + copied = self.client.copy(max_retries=7) + assert copied.max_retries == 7 + assert self.client.max_retries == 2 + + copied2 = copied.copy(max_retries=6) + assert copied2.max_retries == 6 + assert copied.max_retries == 7 + + # timeout + assert isinstance(self.client.timeout, httpx.Timeout) + copied = self.client.copy(timeout=None) + assert copied.timeout is None + assert isinstance(self.client.timeout, httpx.Timeout) + + def test_copy_default_headers(self) -> None: + client = AsyncOpencode(base_url=base_url, _strict_response_validation=True, default_headers={"X-Foo": "bar"}) + assert client.default_headers["X-Foo"] == "bar" + + # does not override the already given value when not specified + copied = client.copy() + assert copied.default_headers["X-Foo"] == "bar" + + # merges already given headers + copied = client.copy(default_headers={"X-Bar": "stainless"}) + assert copied.default_headers["X-Foo"] == "bar" + assert copied.default_headers["X-Bar"] == "stainless" + + # uses new values for any already given headers + copied = client.copy(default_headers={"X-Foo": "stainless"}) + assert copied.default_headers["X-Foo"] == "stainless" + + # set_default_headers + + # completely overrides already set values + copied = client.copy(set_default_headers={}) + assert copied.default_headers.get("X-Foo") is None + + copied = client.copy(set_default_headers={"X-Bar": "Robert"}) + assert copied.default_headers["X-Bar"] == "Robert" + + with pytest.raises( + ValueError, + match="`default_headers` and `set_default_headers` arguments are mutually exclusive", + ): + client.copy(set_default_headers={}, default_headers={"X-Foo": "Bar"}) + + def test_copy_default_query(self) -> None: + client = AsyncOpencode(base_url=base_url, _strict_response_validation=True, default_query={"foo": "bar"}) + assert _get_params(client)["foo"] == "bar" + + # does not override the already given value when not specified + copied = client.copy() + assert _get_params(copied)["foo"] == "bar" + + # merges already given params + copied = client.copy(default_query={"bar": "stainless"}) + params = _get_params(copied) + assert params["foo"] == "bar" + assert params["bar"] == "stainless" + + # uses new values for any already given headers + copied = client.copy(default_query={"foo": "stainless"}) + assert _get_params(copied)["foo"] == "stainless" + + # set_default_query + + # completely overrides already set values + copied = client.copy(set_default_query={}) + assert _get_params(copied) == {} + + copied = client.copy(set_default_query={"bar": "Robert"}) + assert _get_params(copied)["bar"] == "Robert" + + with pytest.raises( + ValueError, + # TODO: update + match="`default_query` and `set_default_query` arguments are mutually exclusive", + ): + client.copy(set_default_query={}, default_query={"foo": "Bar"}) + + def test_copy_signature(self) -> None: + # ensure the same parameters that can be passed to the client are defined in the `.copy()` method + init_signature = inspect.signature( + # mypy doesn't like that we access the `__init__` property. + self.client.__init__, # type: ignore[misc] + ) + copy_signature = inspect.signature(self.client.copy) + exclude_params = {"transport", "proxies", "_strict_response_validation"} + + for name in init_signature.parameters.keys(): + if name in exclude_params: + continue + + copy_param = copy_signature.parameters.get(name) + assert copy_param is not None, f"copy() signature is missing the {name} param" + + @pytest.mark.skipif(sys.version_info >= (3, 10), reason="fails because of a memory leak that started from 3.12") + def test_copy_build_request(self) -> None: + options = FinalRequestOptions(method="get", url="/foo") + + def build_request(options: FinalRequestOptions) -> None: + client = self.client.copy() + client._build_request(options) + + # ensure that the machinery is warmed up before tracing starts. + build_request(options) + gc.collect() + + tracemalloc.start(1000) + + snapshot_before = tracemalloc.take_snapshot() + + ITERATIONS = 10 + for _ in range(ITERATIONS): + build_request(options) + + gc.collect() + snapshot_after = tracemalloc.take_snapshot() + + tracemalloc.stop() + + def add_leak(leaks: list[tracemalloc.StatisticDiff], diff: tracemalloc.StatisticDiff) -> None: + if diff.count == 0: + # Avoid false positives by considering only leaks (i.e. allocations that persist). + return + + if diff.count % ITERATIONS != 0: + # Avoid false positives by considering only leaks that appear per iteration. + return + + for frame in diff.traceback: + if any( + frame.filename.endswith(fragment) + for fragment in [ + # to_raw_response_wrapper leaks through the @functools.wraps() decorator. + # + # removing the decorator fixes the leak for reasons we don't understand. + "opencode_ai/_legacy_response.py", + "opencode_ai/_response.py", + # pydantic.BaseModel.model_dump || pydantic.BaseModel.dict leak memory for some reason. + "opencode_ai/_compat.py", + # Standard library leaks we don't care about. + "/logging/__init__.py", + ] + ): + return + + leaks.append(diff) + + leaks: list[tracemalloc.StatisticDiff] = [] + for diff in snapshot_after.compare_to(snapshot_before, "traceback"): + add_leak(leaks, diff) + if leaks: + for leak in leaks: + print("MEMORY LEAK:", leak) + for frame in leak.traceback: + print(frame) + raise AssertionError() + + async def test_request_timeout(self) -> None: + request = self.client._build_request(FinalRequestOptions(method="get", url="/foo")) + timeout = httpx.Timeout(**request.extensions["timeout"]) # type: ignore + assert timeout == DEFAULT_TIMEOUT + + request = self.client._build_request( + FinalRequestOptions(method="get", url="/foo", timeout=httpx.Timeout(100.0)) + ) + timeout = httpx.Timeout(**request.extensions["timeout"]) # type: ignore + assert timeout == httpx.Timeout(100.0) + + async def test_client_timeout_option(self) -> None: + client = AsyncOpencode(base_url=base_url, _strict_response_validation=True, timeout=httpx.Timeout(0)) + + request = client._build_request(FinalRequestOptions(method="get", url="/foo")) + timeout = httpx.Timeout(**request.extensions["timeout"]) # type: ignore + assert timeout == httpx.Timeout(0) + + async def test_http_client_timeout_option(self) -> None: + # custom timeout given to the httpx client should be used + async with httpx.AsyncClient(timeout=None) as http_client: + client = AsyncOpencode(base_url=base_url, _strict_response_validation=True, http_client=http_client) + + request = client._build_request(FinalRequestOptions(method="get", url="/foo")) + timeout = httpx.Timeout(**request.extensions["timeout"]) # type: ignore + assert timeout == httpx.Timeout(None) + + # no timeout given to the httpx client should not use the httpx default + async with httpx.AsyncClient() as http_client: + client = AsyncOpencode(base_url=base_url, _strict_response_validation=True, http_client=http_client) + + request = client._build_request(FinalRequestOptions(method="get", url="/foo")) + timeout = httpx.Timeout(**request.extensions["timeout"]) # type: ignore + assert timeout == DEFAULT_TIMEOUT + + # explicitly passing the default timeout currently results in it being ignored + async with httpx.AsyncClient(timeout=HTTPX_DEFAULT_TIMEOUT) as http_client: + client = AsyncOpencode(base_url=base_url, _strict_response_validation=True, http_client=http_client) + + request = client._build_request(FinalRequestOptions(method="get", url="/foo")) + timeout = httpx.Timeout(**request.extensions["timeout"]) # type: ignore + assert timeout == DEFAULT_TIMEOUT # our default + + def test_invalid_http_client(self) -> None: + with pytest.raises(TypeError, match="Invalid `http_client` arg"): + with httpx.Client() as http_client: + AsyncOpencode(base_url=base_url, _strict_response_validation=True, http_client=cast(Any, http_client)) + + def test_default_headers_option(self) -> None: + client = AsyncOpencode(base_url=base_url, _strict_response_validation=True, default_headers={"X-Foo": "bar"}) + request = client._build_request(FinalRequestOptions(method="get", url="/foo")) + assert request.headers.get("x-foo") == "bar" + assert request.headers.get("x-stainless-lang") == "python" + + client2 = AsyncOpencode( + base_url=base_url, + _strict_response_validation=True, + default_headers={ + "X-Foo": "stainless", + "X-Stainless-Lang": "my-overriding-header", + }, + ) + request = client2._build_request(FinalRequestOptions(method="get", url="/foo")) + assert request.headers.get("x-foo") == "stainless" + assert request.headers.get("x-stainless-lang") == "my-overriding-header" + + def test_default_query_option(self) -> None: + client = AsyncOpencode( + base_url=base_url, _strict_response_validation=True, default_query={"query_param": "bar"} + ) + request = client._build_request(FinalRequestOptions(method="get", url="/foo")) + url = httpx.URL(request.url) + assert dict(url.params) == {"query_param": "bar"} + + request = client._build_request( + FinalRequestOptions( + method="get", + url="/foo", + params={"foo": "baz", "query_param": "overridden"}, + ) + ) + url = httpx.URL(request.url) + assert dict(url.params) == {"foo": "baz", "query_param": "overridden"} + + def test_request_extra_json(self) -> None: + request = self.client._build_request( + FinalRequestOptions( + method="post", + url="/foo", + json_data={"foo": "bar"}, + extra_json={"baz": False}, + ), + ) + data = json.loads(request.content.decode("utf-8")) + assert data == {"foo": "bar", "baz": False} + + request = self.client._build_request( + FinalRequestOptions( + method="post", + url="/foo", + extra_json={"baz": False}, + ), + ) + data = json.loads(request.content.decode("utf-8")) + assert data == {"baz": False} + + # `extra_json` takes priority over `json_data` when keys clash + request = self.client._build_request( + FinalRequestOptions( + method="post", + url="/foo", + json_data={"foo": "bar", "baz": True}, + extra_json={"baz": None}, + ), + ) + data = json.loads(request.content.decode("utf-8")) + assert data == {"foo": "bar", "baz": None} + + def test_request_extra_headers(self) -> None: + request = self.client._build_request( + FinalRequestOptions( + method="post", + url="/foo", + **make_request_options(extra_headers={"X-Foo": "Foo"}), + ), + ) + assert request.headers.get("X-Foo") == "Foo" + + # `extra_headers` takes priority over `default_headers` when keys clash + request = self.client.with_options(default_headers={"X-Bar": "true"})._build_request( + FinalRequestOptions( + method="post", + url="/foo", + **make_request_options( + extra_headers={"X-Bar": "false"}, + ), + ), + ) + assert request.headers.get("X-Bar") == "false" + + def test_request_extra_query(self) -> None: + request = self.client._build_request( + FinalRequestOptions( + method="post", + url="/foo", + **make_request_options( + extra_query={"my_query_param": "Foo"}, + ), + ), + ) + params = dict(request.url.params) + assert params == {"my_query_param": "Foo"} + + # if both `query` and `extra_query` are given, they are merged + request = self.client._build_request( + FinalRequestOptions( + method="post", + url="/foo", + **make_request_options( + query={"bar": "1"}, + extra_query={"foo": "2"}, + ), + ), + ) + params = dict(request.url.params) + assert params == {"bar": "1", "foo": "2"} + + # `extra_query` takes priority over `query` when keys clash + request = self.client._build_request( + FinalRequestOptions( + method="post", + url="/foo", + **make_request_options( + query={"foo": "1"}, + extra_query={"foo": "2"}, + ), + ), + ) + params = dict(request.url.params) + assert params == {"foo": "2"} + + def test_multipart_repeating_array(self, async_client: AsyncOpencode) -> None: + request = async_client._build_request( + FinalRequestOptions.construct( + method="post", + url="/foo", + headers={"Content-Type": "multipart/form-data; boundary=6b7ba517decee4a450543ea6ae821c82"}, + json_data={"array": ["foo", "bar"]}, + files=[("foo.txt", b"hello world")], + ) + ) + + assert request.read().split(b"\r\n") == [ + b"--6b7ba517decee4a450543ea6ae821c82", + b'Content-Disposition: form-data; name="array[]"', + b"", + b"foo", + b"--6b7ba517decee4a450543ea6ae821c82", + b'Content-Disposition: form-data; name="array[]"', + b"", + b"bar", + b"--6b7ba517decee4a450543ea6ae821c82", + b'Content-Disposition: form-data; name="foo.txt"; filename="upload"', + b"Content-Type: application/octet-stream", + b"", + b"hello world", + b"--6b7ba517decee4a450543ea6ae821c82--", + b"", + ] + + @pytest.mark.respx(base_url=base_url) + async def test_basic_union_response(self, respx_mock: MockRouter) -> None: + class Model1(BaseModel): + name: str + + class Model2(BaseModel): + foo: str + + respx_mock.get("/foo").mock(return_value=httpx.Response(200, json={"foo": "bar"})) + + response = await self.client.get("/foo", cast_to=cast(Any, Union[Model1, Model2])) + assert isinstance(response, Model2) + assert response.foo == "bar" + + @pytest.mark.respx(base_url=base_url) + async def test_union_response_different_types(self, respx_mock: MockRouter) -> None: + """Union of objects with the same field name using a different type""" + + class Model1(BaseModel): + foo: int + + class Model2(BaseModel): + foo: str + + respx_mock.get("/foo").mock(return_value=httpx.Response(200, json={"foo": "bar"})) + + response = await self.client.get("/foo", cast_to=cast(Any, Union[Model1, Model2])) + assert isinstance(response, Model2) + assert response.foo == "bar" + + respx_mock.get("/foo").mock(return_value=httpx.Response(200, json={"foo": 1})) + + response = await self.client.get("/foo", cast_to=cast(Any, Union[Model1, Model2])) + assert isinstance(response, Model1) + assert response.foo == 1 + + @pytest.mark.respx(base_url=base_url) + async def test_non_application_json_content_type_for_json_data(self, respx_mock: MockRouter) -> None: + """ + Response that sets Content-Type to something other than application/json but returns json data + """ + + class Model(BaseModel): + foo: int + + respx_mock.get("/foo").mock( + return_value=httpx.Response( + 200, + content=json.dumps({"foo": 2}), + headers={"Content-Type": "application/text"}, + ) + ) + + response = await self.client.get("/foo", cast_to=Model) + assert isinstance(response, Model) + assert response.foo == 2 + + def test_base_url_setter(self) -> None: + client = AsyncOpencode(base_url="https://example.com/from_init", _strict_response_validation=True) + assert client.base_url == "https://example.com/from_init/" + + client.base_url = "https://example.com/from_setter" # type: ignore[assignment] + + assert client.base_url == "https://example.com/from_setter/" + + def test_base_url_env(self) -> None: + with update_env(OPENCODE_BASE_URL="http://localhost:5000/from/env"): + client = AsyncOpencode(_strict_response_validation=True) + assert client.base_url == "http://localhost:5000/from/env/" + + @pytest.mark.parametrize( + "client", + [ + AsyncOpencode(base_url="http://localhost:5000/custom/path/", _strict_response_validation=True), + AsyncOpencode( + base_url="http://localhost:5000/custom/path/", + _strict_response_validation=True, + http_client=httpx.AsyncClient(), + ), + ], + ids=["standard", "custom http client"], + ) + def test_base_url_trailing_slash(self, client: AsyncOpencode) -> None: + request = client._build_request( + FinalRequestOptions( + method="post", + url="/foo", + json_data={"foo": "bar"}, + ), + ) + assert request.url == "http://localhost:5000/custom/path/foo" + + @pytest.mark.parametrize( + "client", + [ + AsyncOpencode(base_url="http://localhost:5000/custom/path/", _strict_response_validation=True), + AsyncOpencode( + base_url="http://localhost:5000/custom/path/", + _strict_response_validation=True, + http_client=httpx.AsyncClient(), + ), + ], + ids=["standard", "custom http client"], + ) + def test_base_url_no_trailing_slash(self, client: AsyncOpencode) -> None: + request = client._build_request( + FinalRequestOptions( + method="post", + url="/foo", + json_data={"foo": "bar"}, + ), + ) + assert request.url == "http://localhost:5000/custom/path/foo" + + @pytest.mark.parametrize( + "client", + [ + AsyncOpencode(base_url="http://localhost:5000/custom/path/", _strict_response_validation=True), + AsyncOpencode( + base_url="http://localhost:5000/custom/path/", + _strict_response_validation=True, + http_client=httpx.AsyncClient(), + ), + ], + ids=["standard", "custom http client"], + ) + def test_absolute_request_url(self, client: AsyncOpencode) -> None: + request = client._build_request( + FinalRequestOptions( + method="post", + url="https://myapi.com/foo", + json_data={"foo": "bar"}, + ), + ) + assert request.url == "https://myapi.com/foo" + + async def test_copied_client_does_not_close_http(self) -> None: + client = AsyncOpencode(base_url=base_url, _strict_response_validation=True) + assert not client.is_closed() + + copied = client.copy() + assert copied is not client + + del copied + + await asyncio.sleep(0.2) + assert not client.is_closed() + + async def test_client_context_manager(self) -> None: + client = AsyncOpencode(base_url=base_url, _strict_response_validation=True) + async with client as c2: + assert c2 is client + assert not c2.is_closed() + assert not client.is_closed() + assert client.is_closed() + + @pytest.mark.respx(base_url=base_url) + @pytest.mark.asyncio + async def test_client_response_validation_error(self, respx_mock: MockRouter) -> None: + class Model(BaseModel): + foo: str + + respx_mock.get("/foo").mock(return_value=httpx.Response(200, json={"foo": {"invalid": True}})) + + with pytest.raises(APIResponseValidationError) as exc: + await self.client.get("/foo", cast_to=Model) + + assert isinstance(exc.value.__cause__, ValidationError) + + async def test_client_max_retries_validation(self) -> None: + with pytest.raises(TypeError, match=r"max_retries cannot be None"): + AsyncOpencode(base_url=base_url, _strict_response_validation=True, max_retries=cast(Any, None)) + + @pytest.mark.respx(base_url=base_url) + @pytest.mark.asyncio + async def test_default_stream_cls(self, respx_mock: MockRouter) -> None: + class Model(BaseModel): + name: str + + respx_mock.post("/foo").mock(return_value=httpx.Response(200, json={"foo": "bar"})) + + stream = await self.client.post("/foo", cast_to=Model, stream=True, stream_cls=AsyncStream[Model]) + assert isinstance(stream, AsyncStream) + await stream.response.aclose() + + @pytest.mark.respx(base_url=base_url) + @pytest.mark.asyncio + async def test_received_text_for_expected_json(self, respx_mock: MockRouter) -> None: + class Model(BaseModel): + name: str + + respx_mock.get("/foo").mock(return_value=httpx.Response(200, text="my-custom-format")) + + strict_client = AsyncOpencode(base_url=base_url, _strict_response_validation=True) + + with pytest.raises(APIResponseValidationError): + await strict_client.get("/foo", cast_to=Model) + + client = AsyncOpencode(base_url=base_url, _strict_response_validation=False) + + response = await client.get("/foo", cast_to=Model) + assert isinstance(response, str) # type: ignore[unreachable] + + @pytest.mark.parametrize( + "remaining_retries,retry_after,timeout", + [ + [3, "20", 20], + [3, "0", 0.5], + [3, "-10", 0.5], + [3, "60", 60], + [3, "61", 0.5], + [3, "Fri, 29 Sep 2023 16:26:57 GMT", 20], + [3, "Fri, 29 Sep 2023 16:26:37 GMT", 0.5], + [3, "Fri, 29 Sep 2023 16:26:27 GMT", 0.5], + [3, "Fri, 29 Sep 2023 16:27:37 GMT", 60], + [3, "Fri, 29 Sep 2023 16:27:38 GMT", 0.5], + [3, "99999999999999999999999999999999999", 0.5], + [3, "Zun, 29 Sep 2023 16:26:27 GMT", 0.5], + [3, "", 0.5], + [2, "", 0.5 * 2.0], + [1, "", 0.5 * 4.0], + [-1100, "", 8], # test large number potentially overflowing + ], + ) + @mock.patch("time.time", mock.MagicMock(return_value=1696004797)) + @pytest.mark.asyncio + async def test_parse_retry_after_header(self, remaining_retries: int, retry_after: str, timeout: float) -> None: + client = AsyncOpencode(base_url=base_url, _strict_response_validation=True) + + headers = httpx.Headers({"retry-after": retry_after}) + options = FinalRequestOptions(method="get", url="/foo", max_retries=3) + calculated = client._calculate_retry_timeout(remaining_retries, options, headers) + assert calculated == pytest.approx(timeout, 0.5 * 0.875) # pyright: ignore[reportUnknownMemberType] + + @mock.patch("opencode_ai._base_client.BaseClient._calculate_retry_timeout", _low_retry_timeout) + @pytest.mark.respx(base_url=base_url) + async def test_retrying_timeout_errors_doesnt_leak( + self, respx_mock: MockRouter, async_client: AsyncOpencode + ) -> None: + respx_mock.get("/session").mock(side_effect=httpx.TimeoutException("Test timeout error")) + + with pytest.raises(APITimeoutError): + await async_client.session.with_streaming_response.list().__aenter__() + + assert _get_open_connections(self.client) == 0 + + @mock.patch("opencode_ai._base_client.BaseClient._calculate_retry_timeout", _low_retry_timeout) + @pytest.mark.respx(base_url=base_url) + async def test_retrying_status_errors_doesnt_leak( + self, respx_mock: MockRouter, async_client: AsyncOpencode + ) -> None: + respx_mock.get("/session").mock(return_value=httpx.Response(500)) + + with pytest.raises(APIStatusError): + await async_client.session.with_streaming_response.list().__aenter__() + assert _get_open_connections(self.client) == 0 + + @pytest.mark.parametrize("failures_before_success", [0, 2, 4]) + @mock.patch("opencode_ai._base_client.BaseClient._calculate_retry_timeout", _low_retry_timeout) + @pytest.mark.respx(base_url=base_url) + @pytest.mark.asyncio + @pytest.mark.parametrize("failure_mode", ["status", "exception"]) + async def test_retries_taken( + self, + async_client: AsyncOpencode, + failures_before_success: int, + failure_mode: Literal["status", "exception"], + respx_mock: MockRouter, + ) -> None: + client = async_client.with_options(max_retries=4) + + nb_retries = 0 + + def retry_handler(_request: httpx.Request) -> httpx.Response: + nonlocal nb_retries + if nb_retries < failures_before_success: + nb_retries += 1 + if failure_mode == "exception": + raise RuntimeError("oops") + return httpx.Response(500) + return httpx.Response(200) + + respx_mock.get("/session").mock(side_effect=retry_handler) + + response = await client.session.with_raw_response.list() + + assert response.retries_taken == failures_before_success + assert int(response.http_request.headers.get("x-stainless-retry-count")) == failures_before_success + + @pytest.mark.parametrize("failures_before_success", [0, 2, 4]) + @mock.patch("opencode_ai._base_client.BaseClient._calculate_retry_timeout", _low_retry_timeout) + @pytest.mark.respx(base_url=base_url) + @pytest.mark.asyncio + async def test_omit_retry_count_header( + self, async_client: AsyncOpencode, failures_before_success: int, respx_mock: MockRouter + ) -> None: + client = async_client.with_options(max_retries=4) + + nb_retries = 0 + + def retry_handler(_request: httpx.Request) -> httpx.Response: + nonlocal nb_retries + if nb_retries < failures_before_success: + nb_retries += 1 + return httpx.Response(500) + return httpx.Response(200) + + respx_mock.get("/session").mock(side_effect=retry_handler) + + response = await client.session.with_raw_response.list(extra_headers={"x-stainless-retry-count": Omit()}) + + assert len(response.http_request.headers.get_list("x-stainless-retry-count")) == 0 + + @pytest.mark.parametrize("failures_before_success", [0, 2, 4]) + @mock.patch("opencode_ai._base_client.BaseClient._calculate_retry_timeout", _low_retry_timeout) + @pytest.mark.respx(base_url=base_url) + @pytest.mark.asyncio + async def test_overwrite_retry_count_header( + self, async_client: AsyncOpencode, failures_before_success: int, respx_mock: MockRouter + ) -> None: + client = async_client.with_options(max_retries=4) + + nb_retries = 0 + + def retry_handler(_request: httpx.Request) -> httpx.Response: + nonlocal nb_retries + if nb_retries < failures_before_success: + nb_retries += 1 + return httpx.Response(500) + return httpx.Response(200) + + respx_mock.get("/session").mock(side_effect=retry_handler) + + response = await client.session.with_raw_response.list(extra_headers={"x-stainless-retry-count": "42"}) + + assert response.http_request.headers.get("x-stainless-retry-count") == "42" + + def test_get_platform(self) -> None: + # A previous implementation of asyncify could leave threads unterminated when + # used with nest_asyncio. + # + # Since nest_asyncio.apply() is global and cannot be un-applied, this + # test is run in a separate process to avoid affecting other tests. + test_code = dedent(""" + import asyncio + import nest_asyncio + import threading + + from opencode_ai._utils import asyncify + from opencode_ai._base_client import get_platform + + async def test_main() -> None: + result = await asyncify(get_platform)() + print(result) + for thread in threading.enumerate(): + print(thread.name) + + nest_asyncio.apply() + asyncio.run(test_main()) + """) + with subprocess.Popen( + [sys.executable, "-c", test_code], + text=True, + ) as process: + timeout = 10 # seconds + + start_time = time.monotonic() + while True: + return_code = process.poll() + if return_code is not None: + if return_code != 0: + raise AssertionError("calling get_platform using asyncify resulted in a non-zero exit code") + + # success + break + + if time.monotonic() - start_time > timeout: + process.kill() + raise AssertionError("calling get_platform using asyncify resulted in a hung process") + + time.sleep(0.1) + + async def test_proxy_environment_variables(self, monkeypatch: pytest.MonkeyPatch) -> None: + # Test that the proxy environment variables are set correctly + monkeypatch.setenv("HTTPS_PROXY", "https://example.org") + + client = DefaultAsyncHttpxClient() + + mounts = tuple(client._mounts.items()) + assert len(mounts) == 1 + assert mounts[0][0].pattern == "https://" + + @pytest.mark.filterwarnings("ignore:.*deprecated.*:DeprecationWarning") + async def test_default_client_creation(self) -> None: + # Ensure that the client can be initialized without any exceptions + DefaultAsyncHttpxClient( + verify=True, + cert=None, + trust_env=True, + http1=True, + http2=False, + limits=httpx.Limits(max_connections=100, max_keepalive_connections=20), + ) + + @pytest.mark.respx(base_url=base_url) + async def test_follow_redirects(self, respx_mock: MockRouter) -> None: + # Test that the default follow_redirects=True allows following redirects + respx_mock.post("/redirect").mock( + return_value=httpx.Response(302, headers={"Location": f"{base_url}/redirected"}) + ) + respx_mock.get("/redirected").mock(return_value=httpx.Response(200, json={"status": "ok"})) + + response = await self.client.post("/redirect", body={"key": "value"}, cast_to=httpx.Response) + assert response.status_code == 200 + assert response.json() == {"status": "ok"} + + @pytest.mark.respx(base_url=base_url) + async def test_follow_redirects_disabled(self, respx_mock: MockRouter) -> None: + # Test that follow_redirects=False prevents following redirects + respx_mock.post("/redirect").mock( + return_value=httpx.Response(302, headers={"Location": f"{base_url}/redirected"}) + ) + + with pytest.raises(APIStatusError) as exc_info: + await self.client.post( + "/redirect", body={"key": "value"}, options={"follow_redirects": False}, cast_to=httpx.Response + ) + + assert exc_info.value.response.status_code == 302 + assert exc_info.value.response.headers["Location"] == f"{base_url}/redirected" diff --git a/tests/test_deepcopy.py b/tests/test_deepcopy.py new file mode 100644 index 0000000..f43fa4f --- /dev/null +++ b/tests/test_deepcopy.py @@ -0,0 +1,58 @@ +from opencode_ai._utils import deepcopy_minimal + + +def assert_different_identities(obj1: object, obj2: object) -> None: + assert obj1 == obj2 + assert id(obj1) != id(obj2) + + +def test_simple_dict() -> None: + obj1 = {"foo": "bar"} + obj2 = deepcopy_minimal(obj1) + assert_different_identities(obj1, obj2) + + +def test_nested_dict() -> None: + obj1 = {"foo": {"bar": True}} + obj2 = deepcopy_minimal(obj1) + assert_different_identities(obj1, obj2) + assert_different_identities(obj1["foo"], obj2["foo"]) + + +def test_complex_nested_dict() -> None: + obj1 = {"foo": {"bar": [{"hello": "world"}]}} + obj2 = deepcopy_minimal(obj1) + assert_different_identities(obj1, obj2) + assert_different_identities(obj1["foo"], obj2["foo"]) + assert_different_identities(obj1["foo"]["bar"], obj2["foo"]["bar"]) + assert_different_identities(obj1["foo"]["bar"][0], obj2["foo"]["bar"][0]) + + +def test_simple_list() -> None: + obj1 = ["a", "b", "c"] + obj2 = deepcopy_minimal(obj1) + assert_different_identities(obj1, obj2) + + +def test_nested_list() -> None: + obj1 = ["a", [1, 2, 3]] + obj2 = deepcopy_minimal(obj1) + assert_different_identities(obj1, obj2) + assert_different_identities(obj1[1], obj2[1]) + + +class MyObject: ... + + +def test_ignores_other_types() -> None: + # custom classes + my_obj = MyObject() + obj1 = {"foo": my_obj} + obj2 = deepcopy_minimal(obj1) + assert_different_identities(obj1, obj2) + assert obj1["foo"] is my_obj + + # tuples + obj3 = ("a", "b") + obj4 = deepcopy_minimal(obj3) + assert obj3 is obj4 diff --git a/tests/test_extract_files.py b/tests/test_extract_files.py new file mode 100644 index 0000000..1fc490b --- /dev/null +++ b/tests/test_extract_files.py @@ -0,0 +1,64 @@ +from __future__ import annotations + +from typing import Sequence + +import pytest + +from opencode_ai._types import FileTypes +from opencode_ai._utils import extract_files + + +def test_removes_files_from_input() -> None: + query = {"foo": "bar"} + assert extract_files(query, paths=[]) == [] + assert query == {"foo": "bar"} + + query2 = {"foo": b"Bar", "hello": "world"} + assert extract_files(query2, paths=[["foo"]]) == [("foo", b"Bar")] + assert query2 == {"hello": "world"} + + query3 = {"foo": {"foo": {"bar": b"Bar"}}, "hello": "world"} + assert extract_files(query3, paths=[["foo", "foo", "bar"]]) == [("foo[foo][bar]", b"Bar")] + assert query3 == {"foo": {"foo": {}}, "hello": "world"} + + query4 = {"foo": {"bar": b"Bar", "baz": "foo"}, "hello": "world"} + assert extract_files(query4, paths=[["foo", "bar"]]) == [("foo[bar]", b"Bar")] + assert query4 == {"hello": "world", "foo": {"baz": "foo"}} + + +def test_multiple_files() -> None: + query = {"documents": [{"file": b"My first file"}, {"file": b"My second file"}]} + assert extract_files(query, paths=[["documents", "", "file"]]) == [ + ("documents[][file]", b"My first file"), + ("documents[][file]", b"My second file"), + ] + assert query == {"documents": [{}, {}]} + + +@pytest.mark.parametrize( + "query,paths,expected", + [ + [ + {"foo": {"bar": "baz"}}, + [["foo", "", "bar"]], + [], + ], + [ + {"foo": ["bar", "baz"]}, + [["foo", "bar"]], + [], + ], + [ + {"foo": {"bar": "baz"}}, + [["foo", "foo"]], + [], + ], + ], + ids=["dict expecting array", "array expecting dict", "unknown keys"], +) +def test_ignores_incorrect_paths( + query: dict[str, object], + paths: Sequence[Sequence[str]], + expected: list[tuple[str, FileTypes]], +) -> None: + assert extract_files(query, paths=paths) == expected diff --git a/tests/test_files.py b/tests/test_files.py new file mode 100644 index 0000000..6aec2c3 --- /dev/null +++ b/tests/test_files.py @@ -0,0 +1,51 @@ +from pathlib import Path + +import anyio +import pytest +from dirty_equals import IsDict, IsList, IsBytes, IsTuple + +from opencode_ai._files import to_httpx_files, async_to_httpx_files + +readme_path = Path(__file__).parent.parent.joinpath("README.md") + + +def test_pathlib_includes_file_name() -> None: + result = to_httpx_files({"file": readme_path}) + print(result) + assert result == IsDict({"file": IsTuple("README.md", IsBytes())}) + + +def test_tuple_input() -> None: + result = to_httpx_files([("file", readme_path)]) + print(result) + assert result == IsList(IsTuple("file", IsTuple("README.md", IsBytes()))) + + +@pytest.mark.asyncio +async def test_async_pathlib_includes_file_name() -> None: + result = await async_to_httpx_files({"file": readme_path}) + print(result) + assert result == IsDict({"file": IsTuple("README.md", IsBytes())}) + + +@pytest.mark.asyncio +async def test_async_supports_anyio_path() -> None: + result = await async_to_httpx_files({"file": anyio.Path(readme_path)}) + print(result) + assert result == IsDict({"file": IsTuple("README.md", IsBytes())}) + + +@pytest.mark.asyncio +async def test_async_tuple_input() -> None: + result = await async_to_httpx_files([("file", readme_path)]) + print(result) + assert result == IsList(IsTuple("file", IsTuple("README.md", IsBytes()))) + + +def test_string_not_allowed() -> None: + with pytest.raises(TypeError, match="Expected file types input to be a FileContent type or to be a tuple"): + to_httpx_files( + { + "file": "foo", # type: ignore + } + ) diff --git a/tests/test_models.py b/tests/test_models.py new file mode 100644 index 0000000..94fa22d --- /dev/null +++ b/tests/test_models.py @@ -0,0 +1,963 @@ +import json +from typing import TYPE_CHECKING, Any, Dict, List, Union, Optional, cast +from datetime import datetime, timezone +from typing_extensions import Literal, Annotated, TypeAliasType + +import pytest +import pydantic +from pydantic import Field + +from opencode_ai._utils import PropertyInfo +from opencode_ai._compat import PYDANTIC_V2, parse_obj, model_dump, model_json +from opencode_ai._models import BaseModel, construct_type + + +class BasicModel(BaseModel): + foo: str + + +@pytest.mark.parametrize("value", ["hello", 1], ids=["correct type", "mismatched"]) +def test_basic(value: object) -> None: + m = BasicModel.construct(foo=value) + assert m.foo == value + + +def test_directly_nested_model() -> None: + class NestedModel(BaseModel): + nested: BasicModel + + m = NestedModel.construct(nested={"foo": "Foo!"}) + assert m.nested.foo == "Foo!" + + # mismatched types + m = NestedModel.construct(nested="hello!") + assert cast(Any, m.nested) == "hello!" + + +def test_optional_nested_model() -> None: + class NestedModel(BaseModel): + nested: Optional[BasicModel] + + m1 = NestedModel.construct(nested=None) + assert m1.nested is None + + m2 = NestedModel.construct(nested={"foo": "bar"}) + assert m2.nested is not None + assert m2.nested.foo == "bar" + + # mismatched types + m3 = NestedModel.construct(nested={"foo"}) + assert isinstance(cast(Any, m3.nested), set) + assert cast(Any, m3.nested) == {"foo"} + + +def test_list_nested_model() -> None: + class NestedModel(BaseModel): + nested: List[BasicModel] + + m = NestedModel.construct(nested=[{"foo": "bar"}, {"foo": "2"}]) + assert m.nested is not None + assert isinstance(m.nested, list) + assert len(m.nested) == 2 + assert m.nested[0].foo == "bar" + assert m.nested[1].foo == "2" + + # mismatched types + m = NestedModel.construct(nested=True) + assert cast(Any, m.nested) is True + + m = NestedModel.construct(nested=[False]) + assert cast(Any, m.nested) == [False] + + +def test_optional_list_nested_model() -> None: + class NestedModel(BaseModel): + nested: Optional[List[BasicModel]] + + m1 = NestedModel.construct(nested=[{"foo": "bar"}, {"foo": "2"}]) + assert m1.nested is not None + assert isinstance(m1.nested, list) + assert len(m1.nested) == 2 + assert m1.nested[0].foo == "bar" + assert m1.nested[1].foo == "2" + + m2 = NestedModel.construct(nested=None) + assert m2.nested is None + + # mismatched types + m3 = NestedModel.construct(nested={1}) + assert cast(Any, m3.nested) == {1} + + m4 = NestedModel.construct(nested=[False]) + assert cast(Any, m4.nested) == [False] + + +def test_list_optional_items_nested_model() -> None: + class NestedModel(BaseModel): + nested: List[Optional[BasicModel]] + + m = NestedModel.construct(nested=[None, {"foo": "bar"}]) + assert m.nested is not None + assert isinstance(m.nested, list) + assert len(m.nested) == 2 + assert m.nested[0] is None + assert m.nested[1] is not None + assert m.nested[1].foo == "bar" + + # mismatched types + m3 = NestedModel.construct(nested="foo") + assert cast(Any, m3.nested) == "foo" + + m4 = NestedModel.construct(nested=[False]) + assert cast(Any, m4.nested) == [False] + + +def test_list_mismatched_type() -> None: + class NestedModel(BaseModel): + nested: List[str] + + m = NestedModel.construct(nested=False) + assert cast(Any, m.nested) is False + + +def test_raw_dictionary() -> None: + class NestedModel(BaseModel): + nested: Dict[str, str] + + m = NestedModel.construct(nested={"hello": "world"}) + assert m.nested == {"hello": "world"} + + # mismatched types + m = NestedModel.construct(nested=False) + assert cast(Any, m.nested) is False + + +def test_nested_dictionary_model() -> None: + class NestedModel(BaseModel): + nested: Dict[str, BasicModel] + + m = NestedModel.construct(nested={"hello": {"foo": "bar"}}) + assert isinstance(m.nested, dict) + assert m.nested["hello"].foo == "bar" + + # mismatched types + m = NestedModel.construct(nested={"hello": False}) + assert cast(Any, m.nested["hello"]) is False + + +def test_unknown_fields() -> None: + m1 = BasicModel.construct(foo="foo", unknown=1) + assert m1.foo == "foo" + assert cast(Any, m1).unknown == 1 + + m2 = BasicModel.construct(foo="foo", unknown={"foo_bar": True}) + assert m2.foo == "foo" + assert cast(Any, m2).unknown == {"foo_bar": True} + + assert model_dump(m2) == {"foo": "foo", "unknown": {"foo_bar": True}} + + +def test_strict_validation_unknown_fields() -> None: + class Model(BaseModel): + foo: str + + model = parse_obj(Model, dict(foo="hello!", user="Robert")) + assert model.foo == "hello!" + assert cast(Any, model).user == "Robert" + + assert model_dump(model) == {"foo": "hello!", "user": "Robert"} + + +def test_aliases() -> None: + class Model(BaseModel): + my_field: int = Field(alias="myField") + + m = Model.construct(myField=1) + assert m.my_field == 1 + + # mismatched types + m = Model.construct(myField={"hello": False}) + assert cast(Any, m.my_field) == {"hello": False} + + +def test_repr() -> None: + model = BasicModel(foo="bar") + assert str(model) == "BasicModel(foo='bar')" + assert repr(model) == "BasicModel(foo='bar')" + + +def test_repr_nested_model() -> None: + class Child(BaseModel): + name: str + age: int + + class Parent(BaseModel): + name: str + child: Child + + model = Parent(name="Robert", child=Child(name="Foo", age=5)) + assert str(model) == "Parent(name='Robert', child=Child(name='Foo', age=5))" + assert repr(model) == "Parent(name='Robert', child=Child(name='Foo', age=5))" + + +def test_optional_list() -> None: + class Submodel(BaseModel): + name: str + + class Model(BaseModel): + items: Optional[List[Submodel]] + + m = Model.construct(items=None) + assert m.items is None + + m = Model.construct(items=[]) + assert m.items == [] + + m = Model.construct(items=[{"name": "Robert"}]) + assert m.items is not None + assert len(m.items) == 1 + assert m.items[0].name == "Robert" + + +def test_nested_union_of_models() -> None: + class Submodel1(BaseModel): + bar: bool + + class Submodel2(BaseModel): + thing: str + + class Model(BaseModel): + foo: Union[Submodel1, Submodel2] + + m = Model.construct(foo={"thing": "hello"}) + assert isinstance(m.foo, Submodel2) + assert m.foo.thing == "hello" + + +def test_nested_union_of_mixed_types() -> None: + class Submodel1(BaseModel): + bar: bool + + class Model(BaseModel): + foo: Union[Submodel1, Literal[True], Literal["CARD_HOLDER"]] + + m = Model.construct(foo=True) + assert m.foo is True + + m = Model.construct(foo="CARD_HOLDER") + assert m.foo == "CARD_HOLDER" + + m = Model.construct(foo={"bar": False}) + assert isinstance(m.foo, Submodel1) + assert m.foo.bar is False + + +def test_nested_union_multiple_variants() -> None: + class Submodel1(BaseModel): + bar: bool + + class Submodel2(BaseModel): + thing: str + + class Submodel3(BaseModel): + foo: int + + class Model(BaseModel): + foo: Union[Submodel1, Submodel2, None, Submodel3] + + m = Model.construct(foo={"thing": "hello"}) + assert isinstance(m.foo, Submodel2) + assert m.foo.thing == "hello" + + m = Model.construct(foo=None) + assert m.foo is None + + m = Model.construct() + assert m.foo is None + + m = Model.construct(foo={"foo": "1"}) + assert isinstance(m.foo, Submodel3) + assert m.foo.foo == 1 + + +def test_nested_union_invalid_data() -> None: + class Submodel1(BaseModel): + level: int + + class Submodel2(BaseModel): + name: str + + class Model(BaseModel): + foo: Union[Submodel1, Submodel2] + + m = Model.construct(foo=True) + assert cast(bool, m.foo) is True + + m = Model.construct(foo={"name": 3}) + if PYDANTIC_V2: + assert isinstance(m.foo, Submodel1) + assert m.foo.name == 3 # type: ignore + else: + assert isinstance(m.foo, Submodel2) + assert m.foo.name == "3" + + +def test_list_of_unions() -> None: + class Submodel1(BaseModel): + level: int + + class Submodel2(BaseModel): + name: str + + class Model(BaseModel): + items: List[Union[Submodel1, Submodel2]] + + m = Model.construct(items=[{"level": 1}, {"name": "Robert"}]) + assert len(m.items) == 2 + assert isinstance(m.items[0], Submodel1) + assert m.items[0].level == 1 + assert isinstance(m.items[1], Submodel2) + assert m.items[1].name == "Robert" + + m = Model.construct(items=[{"level": -1}, 156]) + assert len(m.items) == 2 + assert isinstance(m.items[0], Submodel1) + assert m.items[0].level == -1 + assert cast(Any, m.items[1]) == 156 + + +def test_union_of_lists() -> None: + class SubModel1(BaseModel): + level: int + + class SubModel2(BaseModel): + name: str + + class Model(BaseModel): + items: Union[List[SubModel1], List[SubModel2]] + + # with one valid entry + m = Model.construct(items=[{"name": "Robert"}]) + assert len(m.items) == 1 + assert isinstance(m.items[0], SubModel2) + assert m.items[0].name == "Robert" + + # with two entries pointing to different types + m = Model.construct(items=[{"level": 1}, {"name": "Robert"}]) + assert len(m.items) == 2 + assert isinstance(m.items[0], SubModel1) + assert m.items[0].level == 1 + assert isinstance(m.items[1], SubModel1) + assert cast(Any, m.items[1]).name == "Robert" + + # with two entries pointing to *completely* different types + m = Model.construct(items=[{"level": -1}, 156]) + assert len(m.items) == 2 + assert isinstance(m.items[0], SubModel1) + assert m.items[0].level == -1 + assert cast(Any, m.items[1]) == 156 + + +def test_dict_of_union() -> None: + class SubModel1(BaseModel): + name: str + + class SubModel2(BaseModel): + foo: str + + class Model(BaseModel): + data: Dict[str, Union[SubModel1, SubModel2]] + + m = Model.construct(data={"hello": {"name": "there"}, "foo": {"foo": "bar"}}) + assert len(list(m.data.keys())) == 2 + assert isinstance(m.data["hello"], SubModel1) + assert m.data["hello"].name == "there" + assert isinstance(m.data["foo"], SubModel2) + assert m.data["foo"].foo == "bar" + + # TODO: test mismatched type + + +def test_double_nested_union() -> None: + class SubModel1(BaseModel): + name: str + + class SubModel2(BaseModel): + bar: str + + class Model(BaseModel): + data: Dict[str, List[Union[SubModel1, SubModel2]]] + + m = Model.construct(data={"foo": [{"bar": "baz"}, {"name": "Robert"}]}) + assert len(m.data["foo"]) == 2 + + entry1 = m.data["foo"][0] + assert isinstance(entry1, SubModel2) + assert entry1.bar == "baz" + + entry2 = m.data["foo"][1] + assert isinstance(entry2, SubModel1) + assert entry2.name == "Robert" + + # TODO: test mismatched type + + +def test_union_of_dict() -> None: + class SubModel1(BaseModel): + name: str + + class SubModel2(BaseModel): + foo: str + + class Model(BaseModel): + data: Union[Dict[str, SubModel1], Dict[str, SubModel2]] + + m = Model.construct(data={"hello": {"name": "there"}, "foo": {"foo": "bar"}}) + assert len(list(m.data.keys())) == 2 + assert isinstance(m.data["hello"], SubModel1) + assert m.data["hello"].name == "there" + assert isinstance(m.data["foo"], SubModel1) + assert cast(Any, m.data["foo"]).foo == "bar" + + +def test_iso8601_datetime() -> None: + class Model(BaseModel): + created_at: datetime + + expected = datetime(2019, 12, 27, 18, 11, 19, 117000, tzinfo=timezone.utc) + + if PYDANTIC_V2: + expected_json = '{"created_at":"2019-12-27T18:11:19.117000Z"}' + else: + expected_json = '{"created_at": "2019-12-27T18:11:19.117000+00:00"}' + + model = Model.construct(created_at="2019-12-27T18:11:19.117Z") + assert model.created_at == expected + assert model_json(model) == expected_json + + model = parse_obj(Model, dict(created_at="2019-12-27T18:11:19.117Z")) + assert model.created_at == expected + assert model_json(model) == expected_json + + +def test_does_not_coerce_int() -> None: + class Model(BaseModel): + bar: int + + assert Model.construct(bar=1).bar == 1 + assert Model.construct(bar=10.9).bar == 10.9 + assert Model.construct(bar="19").bar == "19" # type: ignore[comparison-overlap] + assert Model.construct(bar=False).bar is False + + +def test_int_to_float_safe_conversion() -> None: + class Model(BaseModel): + float_field: float + + m = Model.construct(float_field=10) + assert m.float_field == 10.0 + assert isinstance(m.float_field, float) + + m = Model.construct(float_field=10.12) + assert m.float_field == 10.12 + assert isinstance(m.float_field, float) + + # number too big + m = Model.construct(float_field=2**53 + 1) + assert m.float_field == 2**53 + 1 + assert isinstance(m.float_field, int) + + +def test_deprecated_alias() -> None: + class Model(BaseModel): + resource_id: str = Field(alias="model_id") + + @property + def model_id(self) -> str: + return self.resource_id + + m = Model.construct(model_id="id") + assert m.model_id == "id" + assert m.resource_id == "id" + assert m.resource_id is m.model_id + + m = parse_obj(Model, {"model_id": "id"}) + assert m.model_id == "id" + assert m.resource_id == "id" + assert m.resource_id is m.model_id + + +def test_omitted_fields() -> None: + class Model(BaseModel): + resource_id: Optional[str] = None + + m = Model.construct() + assert m.resource_id is None + assert "resource_id" not in m.model_fields_set + + m = Model.construct(resource_id=None) + assert m.resource_id is None + assert "resource_id" in m.model_fields_set + + m = Model.construct(resource_id="foo") + assert m.resource_id == "foo" + assert "resource_id" in m.model_fields_set + + +def test_to_dict() -> None: + class Model(BaseModel): + foo: Optional[str] = Field(alias="FOO", default=None) + + m = Model(FOO="hello") + assert m.to_dict() == {"FOO": "hello"} + assert m.to_dict(use_api_names=False) == {"foo": "hello"} + + m2 = Model() + assert m2.to_dict() == {} + assert m2.to_dict(exclude_unset=False) == {"FOO": None} + assert m2.to_dict(exclude_unset=False, exclude_none=True) == {} + assert m2.to_dict(exclude_unset=False, exclude_defaults=True) == {} + + m3 = Model(FOO=None) + assert m3.to_dict() == {"FOO": None} + assert m3.to_dict(exclude_none=True) == {} + assert m3.to_dict(exclude_defaults=True) == {} + + class Model2(BaseModel): + created_at: datetime + + time_str = "2024-03-21T11:39:01.275859" + m4 = Model2.construct(created_at=time_str) + assert m4.to_dict(mode="python") == {"created_at": datetime.fromisoformat(time_str)} + assert m4.to_dict(mode="json") == {"created_at": time_str} + + if not PYDANTIC_V2: + with pytest.raises(ValueError, match="warnings is only supported in Pydantic v2"): + m.to_dict(warnings=False) + + +def test_forwards_compat_model_dump_method() -> None: + class Model(BaseModel): + foo: Optional[str] = Field(alias="FOO", default=None) + + m = Model(FOO="hello") + assert m.model_dump() == {"foo": "hello"} + assert m.model_dump(include={"bar"}) == {} + assert m.model_dump(exclude={"foo"}) == {} + assert m.model_dump(by_alias=True) == {"FOO": "hello"} + + m2 = Model() + assert m2.model_dump() == {"foo": None} + assert m2.model_dump(exclude_unset=True) == {} + assert m2.model_dump(exclude_none=True) == {} + assert m2.model_dump(exclude_defaults=True) == {} + + m3 = Model(FOO=None) + assert m3.model_dump() == {"foo": None} + assert m3.model_dump(exclude_none=True) == {} + + if not PYDANTIC_V2: + with pytest.raises(ValueError, match="round_trip is only supported in Pydantic v2"): + m.model_dump(round_trip=True) + + with pytest.raises(ValueError, match="warnings is only supported in Pydantic v2"): + m.model_dump(warnings=False) + + +def test_compat_method_no_error_for_warnings() -> None: + class Model(BaseModel): + foo: Optional[str] + + m = Model(foo="hello") + assert isinstance(model_dump(m, warnings=False), dict) + + +def test_to_json() -> None: + class Model(BaseModel): + foo: Optional[str] = Field(alias="FOO", default=None) + + m = Model(FOO="hello") + assert json.loads(m.to_json()) == {"FOO": "hello"} + assert json.loads(m.to_json(use_api_names=False)) == {"foo": "hello"} + + if PYDANTIC_V2: + assert m.to_json(indent=None) == '{"FOO":"hello"}' + else: + assert m.to_json(indent=None) == '{"FOO": "hello"}' + + m2 = Model() + assert json.loads(m2.to_json()) == {} + assert json.loads(m2.to_json(exclude_unset=False)) == {"FOO": None} + assert json.loads(m2.to_json(exclude_unset=False, exclude_none=True)) == {} + assert json.loads(m2.to_json(exclude_unset=False, exclude_defaults=True)) == {} + + m3 = Model(FOO=None) + assert json.loads(m3.to_json()) == {"FOO": None} + assert json.loads(m3.to_json(exclude_none=True)) == {} + + if not PYDANTIC_V2: + with pytest.raises(ValueError, match="warnings is only supported in Pydantic v2"): + m.to_json(warnings=False) + + +def test_forwards_compat_model_dump_json_method() -> None: + class Model(BaseModel): + foo: Optional[str] = Field(alias="FOO", default=None) + + m = Model(FOO="hello") + assert json.loads(m.model_dump_json()) == {"foo": "hello"} + assert json.loads(m.model_dump_json(include={"bar"})) == {} + assert json.loads(m.model_dump_json(include={"foo"})) == {"foo": "hello"} + assert json.loads(m.model_dump_json(by_alias=True)) == {"FOO": "hello"} + + assert m.model_dump_json(indent=2) == '{\n "foo": "hello"\n}' + + m2 = Model() + assert json.loads(m2.model_dump_json()) == {"foo": None} + assert json.loads(m2.model_dump_json(exclude_unset=True)) == {} + assert json.loads(m2.model_dump_json(exclude_none=True)) == {} + assert json.loads(m2.model_dump_json(exclude_defaults=True)) == {} + + m3 = Model(FOO=None) + assert json.loads(m3.model_dump_json()) == {"foo": None} + assert json.loads(m3.model_dump_json(exclude_none=True)) == {} + + if not PYDANTIC_V2: + with pytest.raises(ValueError, match="round_trip is only supported in Pydantic v2"): + m.model_dump_json(round_trip=True) + + with pytest.raises(ValueError, match="warnings is only supported in Pydantic v2"): + m.model_dump_json(warnings=False) + + +def test_type_compat() -> None: + # our model type can be assigned to Pydantic's model type + + def takes_pydantic(model: pydantic.BaseModel) -> None: # noqa: ARG001 + ... + + class OurModel(BaseModel): + foo: Optional[str] = None + + takes_pydantic(OurModel()) + + +def test_annotated_types() -> None: + class Model(BaseModel): + value: str + + m = construct_type( + value={"value": "foo"}, + type_=cast(Any, Annotated[Model, "random metadata"]), + ) + assert isinstance(m, Model) + assert m.value == "foo" + + +def test_discriminated_unions_invalid_data() -> None: + class A(BaseModel): + type: Literal["a"] + + data: str + + class B(BaseModel): + type: Literal["b"] + + data: int + + m = construct_type( + value={"type": "b", "data": "foo"}, + type_=cast(Any, Annotated[Union[A, B], PropertyInfo(discriminator="type")]), + ) + assert isinstance(m, B) + assert m.type == "b" + assert m.data == "foo" # type: ignore[comparison-overlap] + + m = construct_type( + value={"type": "a", "data": 100}, + type_=cast(Any, Annotated[Union[A, B], PropertyInfo(discriminator="type")]), + ) + assert isinstance(m, A) + assert m.type == "a" + if PYDANTIC_V2: + assert m.data == 100 # type: ignore[comparison-overlap] + else: + # pydantic v1 automatically converts inputs to strings + # if the expected type is a str + assert m.data == "100" + + +def test_discriminated_unions_unknown_variant() -> None: + class A(BaseModel): + type: Literal["a"] + + data: str + + class B(BaseModel): + type: Literal["b"] + + data: int + + m = construct_type( + value={"type": "c", "data": None, "new_thing": "bar"}, + type_=cast(Any, Annotated[Union[A, B], PropertyInfo(discriminator="type")]), + ) + + # just chooses the first variant + assert isinstance(m, A) + assert m.type == "c" # type: ignore[comparison-overlap] + assert m.data == None # type: ignore[unreachable] + assert m.new_thing == "bar" + + +def test_discriminated_unions_invalid_data_nested_unions() -> None: + class A(BaseModel): + type: Literal["a"] + + data: str + + class B(BaseModel): + type: Literal["b"] + + data: int + + class C(BaseModel): + type: Literal["c"] + + data: bool + + m = construct_type( + value={"type": "b", "data": "foo"}, + type_=cast(Any, Annotated[Union[Union[A, B], C], PropertyInfo(discriminator="type")]), + ) + assert isinstance(m, B) + assert m.type == "b" + assert m.data == "foo" # type: ignore[comparison-overlap] + + m = construct_type( + value={"type": "c", "data": "foo"}, + type_=cast(Any, Annotated[Union[Union[A, B], C], PropertyInfo(discriminator="type")]), + ) + assert isinstance(m, C) + assert m.type == "c" + assert m.data == "foo" # type: ignore[comparison-overlap] + + +def test_discriminated_unions_with_aliases_invalid_data() -> None: + class A(BaseModel): + foo_type: Literal["a"] = Field(alias="type") + + data: str + + class B(BaseModel): + foo_type: Literal["b"] = Field(alias="type") + + data: int + + m = construct_type( + value={"type": "b", "data": "foo"}, + type_=cast(Any, Annotated[Union[A, B], PropertyInfo(discriminator="foo_type")]), + ) + assert isinstance(m, B) + assert m.foo_type == "b" + assert m.data == "foo" # type: ignore[comparison-overlap] + + m = construct_type( + value={"type": "a", "data": 100}, + type_=cast(Any, Annotated[Union[A, B], PropertyInfo(discriminator="foo_type")]), + ) + assert isinstance(m, A) + assert m.foo_type == "a" + if PYDANTIC_V2: + assert m.data == 100 # type: ignore[comparison-overlap] + else: + # pydantic v1 automatically converts inputs to strings + # if the expected type is a str + assert m.data == "100" + + +def test_discriminated_unions_overlapping_discriminators_invalid_data() -> None: + class A(BaseModel): + type: Literal["a"] + + data: bool + + class B(BaseModel): + type: Literal["a"] + + data: int + + m = construct_type( + value={"type": "a", "data": "foo"}, + type_=cast(Any, Annotated[Union[A, B], PropertyInfo(discriminator="type")]), + ) + assert isinstance(m, B) + assert m.type == "a" + assert m.data == "foo" # type: ignore[comparison-overlap] + + +def test_discriminated_unions_invalid_data_uses_cache() -> None: + class A(BaseModel): + type: Literal["a"] + + data: str + + class B(BaseModel): + type: Literal["b"] + + data: int + + UnionType = cast(Any, Union[A, B]) + + assert not hasattr(UnionType, "__discriminator__") + + m = construct_type( + value={"type": "b", "data": "foo"}, type_=cast(Any, Annotated[UnionType, PropertyInfo(discriminator="type")]) + ) + assert isinstance(m, B) + assert m.type == "b" + assert m.data == "foo" # type: ignore[comparison-overlap] + + discriminator = UnionType.__discriminator__ + assert discriminator is not None + + m = construct_type( + value={"type": "b", "data": "foo"}, type_=cast(Any, Annotated[UnionType, PropertyInfo(discriminator="type")]) + ) + assert isinstance(m, B) + assert m.type == "b" + assert m.data == "foo" # type: ignore[comparison-overlap] + + # if the discriminator details object stays the same between invocations then + # we hit the cache + assert UnionType.__discriminator__ is discriminator + + +@pytest.mark.skipif(not PYDANTIC_V2, reason="TypeAliasType is not supported in Pydantic v1") +def test_type_alias_type() -> None: + Alias = TypeAliasType("Alias", str) # pyright: ignore + + class Model(BaseModel): + alias: Alias + union: Union[int, Alias] + + m = construct_type(value={"alias": "foo", "union": "bar"}, type_=Model) + assert isinstance(m, Model) + assert isinstance(m.alias, str) + assert m.alias == "foo" + assert isinstance(m.union, str) + assert m.union == "bar" + + +@pytest.mark.skipif(not PYDANTIC_V2, reason="TypeAliasType is not supported in Pydantic v1") +def test_field_named_cls() -> None: + class Model(BaseModel): + cls: str + + m = construct_type(value={"cls": "foo"}, type_=Model) + assert isinstance(m, Model) + assert isinstance(m.cls, str) + + +def test_discriminated_union_case() -> None: + class A(BaseModel): + type: Literal["a"] + + data: bool + + class B(BaseModel): + type: Literal["b"] + + data: List[Union[A, object]] + + class ModelA(BaseModel): + type: Literal["modelA"] + + data: int + + class ModelB(BaseModel): + type: Literal["modelB"] + + required: str + + data: Union[A, B] + + # when constructing ModelA | ModelB, value data doesn't match ModelB exactly - missing `required` + m = construct_type( + value={"type": "modelB", "data": {"type": "a", "data": True}}, + type_=cast(Any, Annotated[Union[ModelA, ModelB], PropertyInfo(discriminator="type")]), + ) + + assert isinstance(m, ModelB) + + +def test_nested_discriminated_union() -> None: + class InnerType1(BaseModel): + type: Literal["type_1"] + + class InnerModel(BaseModel): + inner_value: str + + class InnerType2(BaseModel): + type: Literal["type_2"] + some_inner_model: InnerModel + + class Type1(BaseModel): + base_type: Literal["base_type_1"] + value: Annotated[ + Union[ + InnerType1, + InnerType2, + ], + PropertyInfo(discriminator="type"), + ] + + class Type2(BaseModel): + base_type: Literal["base_type_2"] + + T = Annotated[ + Union[ + Type1, + Type2, + ], + PropertyInfo(discriminator="base_type"), + ] + + model = construct_type( + type_=T, + value={ + "base_type": "base_type_1", + "value": { + "type": "type_2", + }, + }, + ) + assert isinstance(model, Type1) + assert isinstance(model.value, InnerType2) + + +@pytest.mark.skipif(not PYDANTIC_V2, reason="this is only supported in pydantic v2 for now") +def test_extra_properties() -> None: + class Item(BaseModel): + prop: int + + class Model(BaseModel): + __pydantic_extra__: Dict[str, Item] = Field(init=False) # pyright: ignore[reportIncompatibleVariableOverride] + + other: str + + if TYPE_CHECKING: + + def __getattr__(self, attr: str) -> Item: ... + + model = construct_type( + type_=Model, + value={ + "a": {"prop": 1}, + "other": "foo", + }, + ) + assert isinstance(model, Model) + assert model.a.prop == 1 + assert isinstance(model.a, Item) + assert model.other == "foo" diff --git a/tests/test_qs.py b/tests/test_qs.py new file mode 100644 index 0000000..876a71f --- /dev/null +++ b/tests/test_qs.py @@ -0,0 +1,78 @@ +from typing import Any, cast +from functools import partial +from urllib.parse import unquote + +import pytest + +from opencode_ai._qs import Querystring, stringify + + +def test_empty() -> None: + assert stringify({}) == "" + assert stringify({"a": {}}) == "" + assert stringify({"a": {"b": {"c": {}}}}) == "" + + +def test_basic() -> None: + assert stringify({"a": 1}) == "a=1" + assert stringify({"a": "b"}) == "a=b" + assert stringify({"a": True}) == "a=true" + assert stringify({"a": False}) == "a=false" + assert stringify({"a": 1.23456}) == "a=1.23456" + assert stringify({"a": None}) == "" + + +@pytest.mark.parametrize("method", ["class", "function"]) +def test_nested_dotted(method: str) -> None: + if method == "class": + serialise = Querystring(nested_format="dots").stringify + else: + serialise = partial(stringify, nested_format="dots") + + assert unquote(serialise({"a": {"b": "c"}})) == "a.b=c" + assert unquote(serialise({"a": {"b": "c", "d": "e", "f": "g"}})) == "a.b=c&a.d=e&a.f=g" + assert unquote(serialise({"a": {"b": {"c": {"d": "e"}}}})) == "a.b.c.d=e" + assert unquote(serialise({"a": {"b": True}})) == "a.b=true" + + +def test_nested_brackets() -> None: + assert unquote(stringify({"a": {"b": "c"}})) == "a[b]=c" + assert unquote(stringify({"a": {"b": "c", "d": "e", "f": "g"}})) == "a[b]=c&a[d]=e&a[f]=g" + assert unquote(stringify({"a": {"b": {"c": {"d": "e"}}}})) == "a[b][c][d]=e" + assert unquote(stringify({"a": {"b": True}})) == "a[b]=true" + + +@pytest.mark.parametrize("method", ["class", "function"]) +def test_array_comma(method: str) -> None: + if method == "class": + serialise = Querystring(array_format="comma").stringify + else: + serialise = partial(stringify, array_format="comma") + + assert unquote(serialise({"in": ["foo", "bar"]})) == "in=foo,bar" + assert unquote(serialise({"a": {"b": [True, False]}})) == "a[b]=true,false" + assert unquote(serialise({"a": {"b": [True, False, None, True]}})) == "a[b]=true,false,true" + + +def test_array_repeat() -> None: + assert unquote(stringify({"in": ["foo", "bar"]})) == "in=foo&in=bar" + assert unquote(stringify({"a": {"b": [True, False]}})) == "a[b]=true&a[b]=false" + assert unquote(stringify({"a": {"b": [True, False, None, True]}})) == "a[b]=true&a[b]=false&a[b]=true" + assert unquote(stringify({"in": ["foo", {"b": {"c": ["d", "e"]}}]})) == "in=foo&in[b][c]=d&in[b][c]=e" + + +@pytest.mark.parametrize("method", ["class", "function"]) +def test_array_brackets(method: str) -> None: + if method == "class": + serialise = Querystring(array_format="brackets").stringify + else: + serialise = partial(stringify, array_format="brackets") + + assert unquote(serialise({"in": ["foo", "bar"]})) == "in[]=foo&in[]=bar" + assert unquote(serialise({"a": {"b": [True, False]}})) == "a[b][]=true&a[b][]=false" + assert unquote(serialise({"a": {"b": [True, False, None, True]}})) == "a[b][]=true&a[b][]=false&a[b][]=true" + + +def test_unknown_array_format() -> None: + with pytest.raises(NotImplementedError, match="Unknown array_format value: foo, choose from comma, repeat"): + stringify({"a": ["foo", "bar"]}, array_format=cast(Any, "foo")) diff --git a/tests/test_required_args.py b/tests/test_required_args.py new file mode 100644 index 0000000..d4b6a7e --- /dev/null +++ b/tests/test_required_args.py @@ -0,0 +1,111 @@ +from __future__ import annotations + +import pytest + +from opencode_ai._utils import required_args + + +def test_too_many_positional_params() -> None: + @required_args(["a"]) + def foo(a: str | None = None) -> str | None: + return a + + with pytest.raises(TypeError, match=r"foo\(\) takes 1 argument\(s\) but 2 were given"): + foo("a", "b") # type: ignore + + +def test_positional_param() -> None: + @required_args(["a"]) + def foo(a: str | None = None) -> str | None: + return a + + assert foo("a") == "a" + assert foo(None) is None + assert foo(a="b") == "b" + + with pytest.raises(TypeError, match="Missing required argument: 'a'"): + foo() + + +def test_keyword_only_param() -> None: + @required_args(["a"]) + def foo(*, a: str | None = None) -> str | None: + return a + + assert foo(a="a") == "a" + assert foo(a=None) is None + assert foo(a="b") == "b" + + with pytest.raises(TypeError, match="Missing required argument: 'a'"): + foo() + + +def test_multiple_params() -> None: + @required_args(["a", "b", "c"]) + def foo(a: str = "", *, b: str = "", c: str = "") -> str | None: + return f"{a} {b} {c}" + + assert foo(a="a", b="b", c="c") == "a b c" + + error_message = r"Missing required arguments.*" + + with pytest.raises(TypeError, match=error_message): + foo() + + with pytest.raises(TypeError, match=error_message): + foo(a="a") + + with pytest.raises(TypeError, match=error_message): + foo(b="b") + + with pytest.raises(TypeError, match=error_message): + foo(c="c") + + with pytest.raises(TypeError, match=r"Missing required argument: 'a'"): + foo(b="a", c="c") + + with pytest.raises(TypeError, match=r"Missing required argument: 'b'"): + foo("a", c="c") + + +def test_multiple_variants() -> None: + @required_args(["a"], ["b"]) + def foo(*, a: str | None = None, b: str | None = None) -> str | None: + return a if a is not None else b + + assert foo(a="foo") == "foo" + assert foo(b="bar") == "bar" + assert foo(a=None) is None + assert foo(b=None) is None + + # TODO: this error message could probably be improved + with pytest.raises( + TypeError, + match=r"Missing required arguments; Expected either \('a'\) or \('b'\) arguments to be given", + ): + foo() + + +def test_multiple_params_multiple_variants() -> None: + @required_args(["a", "b"], ["c"]) + def foo(*, a: str | None = None, b: str | None = None, c: str | None = None) -> str | None: + if a is not None: + return a + if b is not None: + return b + return c + + error_message = r"Missing required arguments; Expected either \('a' and 'b'\) or \('c'\) arguments to be given" + + with pytest.raises(TypeError, match=error_message): + foo(a="foo") + + with pytest.raises(TypeError, match=error_message): + foo(b="bar") + + with pytest.raises(TypeError, match=error_message): + foo() + + assert foo(a=None, b="bar") == "bar" + assert foo(c=None) is None + assert foo(c="foo") == "foo" diff --git a/tests/test_response.py b/tests/test_response.py new file mode 100644 index 0000000..24cd168 --- /dev/null +++ b/tests/test_response.py @@ -0,0 +1,277 @@ +import json +from typing import Any, List, Union, cast +from typing_extensions import Annotated + +import httpx +import pytest +import pydantic + +from opencode_ai import Opencode, BaseModel, AsyncOpencode +from opencode_ai._response import ( + APIResponse, + BaseAPIResponse, + AsyncAPIResponse, + BinaryAPIResponse, + AsyncBinaryAPIResponse, + extract_response_type, +) +from opencode_ai._streaming import Stream +from opencode_ai._base_client import FinalRequestOptions + + +class ConcreteBaseAPIResponse(APIResponse[bytes]): ... + + +class ConcreteAPIResponse(APIResponse[List[str]]): ... + + +class ConcreteAsyncAPIResponse(APIResponse[httpx.Response]): ... + + +def test_extract_response_type_direct_classes() -> None: + assert extract_response_type(BaseAPIResponse[str]) == str + assert extract_response_type(APIResponse[str]) == str + assert extract_response_type(AsyncAPIResponse[str]) == str + + +def test_extract_response_type_direct_class_missing_type_arg() -> None: + with pytest.raises( + RuntimeError, + match="Expected type to have a type argument at index 0 but it did not", + ): + extract_response_type(AsyncAPIResponse) + + +def test_extract_response_type_concrete_subclasses() -> None: + assert extract_response_type(ConcreteBaseAPIResponse) == bytes + assert extract_response_type(ConcreteAPIResponse) == List[str] + assert extract_response_type(ConcreteAsyncAPIResponse) == httpx.Response + + +def test_extract_response_type_binary_response() -> None: + assert extract_response_type(BinaryAPIResponse) == bytes + assert extract_response_type(AsyncBinaryAPIResponse) == bytes + + +class PydanticModel(pydantic.BaseModel): ... + + +def test_response_parse_mismatched_basemodel(client: Opencode) -> None: + response = APIResponse( + raw=httpx.Response(200, content=b"foo"), + client=client, + stream=False, + stream_cls=None, + cast_to=str, + options=FinalRequestOptions.construct(method="get", url="/foo"), + ) + + with pytest.raises( + TypeError, + match="Pydantic models must subclass our base model type, e.g. `from opencode_ai import BaseModel`", + ): + response.parse(to=PydanticModel) + + +@pytest.mark.asyncio +async def test_async_response_parse_mismatched_basemodel(async_client: AsyncOpencode) -> None: + response = AsyncAPIResponse( + raw=httpx.Response(200, content=b"foo"), + client=async_client, + stream=False, + stream_cls=None, + cast_to=str, + options=FinalRequestOptions.construct(method="get", url="/foo"), + ) + + with pytest.raises( + TypeError, + match="Pydantic models must subclass our base model type, e.g. `from opencode_ai import BaseModel`", + ): + await response.parse(to=PydanticModel) + + +def test_response_parse_custom_stream(client: Opencode) -> None: + response = APIResponse( + raw=httpx.Response(200, content=b"foo"), + client=client, + stream=True, + stream_cls=None, + cast_to=str, + options=FinalRequestOptions.construct(method="get", url="/foo"), + ) + + stream = response.parse(to=Stream[int]) + assert stream._cast_to == int + + +@pytest.mark.asyncio +async def test_async_response_parse_custom_stream(async_client: AsyncOpencode) -> None: + response = AsyncAPIResponse( + raw=httpx.Response(200, content=b"foo"), + client=async_client, + stream=True, + stream_cls=None, + cast_to=str, + options=FinalRequestOptions.construct(method="get", url="/foo"), + ) + + stream = await response.parse(to=Stream[int]) + assert stream._cast_to == int + + +class CustomModel(BaseModel): + foo: str + bar: int + + +def test_response_parse_custom_model(client: Opencode) -> None: + response = APIResponse( + raw=httpx.Response(200, content=json.dumps({"foo": "hello!", "bar": 2})), + client=client, + stream=False, + stream_cls=None, + cast_to=str, + options=FinalRequestOptions.construct(method="get", url="/foo"), + ) + + obj = response.parse(to=CustomModel) + assert obj.foo == "hello!" + assert obj.bar == 2 + + +@pytest.mark.asyncio +async def test_async_response_parse_custom_model(async_client: AsyncOpencode) -> None: + response = AsyncAPIResponse( + raw=httpx.Response(200, content=json.dumps({"foo": "hello!", "bar": 2})), + client=async_client, + stream=False, + stream_cls=None, + cast_to=str, + options=FinalRequestOptions.construct(method="get", url="/foo"), + ) + + obj = await response.parse(to=CustomModel) + assert obj.foo == "hello!" + assert obj.bar == 2 + + +def test_response_parse_annotated_type(client: Opencode) -> None: + response = APIResponse( + raw=httpx.Response(200, content=json.dumps({"foo": "hello!", "bar": 2})), + client=client, + stream=False, + stream_cls=None, + cast_to=str, + options=FinalRequestOptions.construct(method="get", url="/foo"), + ) + + obj = response.parse( + to=cast("type[CustomModel]", Annotated[CustomModel, "random metadata"]), + ) + assert obj.foo == "hello!" + assert obj.bar == 2 + + +async def test_async_response_parse_annotated_type(async_client: AsyncOpencode) -> None: + response = AsyncAPIResponse( + raw=httpx.Response(200, content=json.dumps({"foo": "hello!", "bar": 2})), + client=async_client, + stream=False, + stream_cls=None, + cast_to=str, + options=FinalRequestOptions.construct(method="get", url="/foo"), + ) + + obj = await response.parse( + to=cast("type[CustomModel]", Annotated[CustomModel, "random metadata"]), + ) + assert obj.foo == "hello!" + assert obj.bar == 2 + + +@pytest.mark.parametrize( + "content, expected", + [ + ("false", False), + ("true", True), + ("False", False), + ("True", True), + ("TrUe", True), + ("FalSe", False), + ], +) +def test_response_parse_bool(client: Opencode, content: str, expected: bool) -> None: + response = APIResponse( + raw=httpx.Response(200, content=content), + client=client, + stream=False, + stream_cls=None, + cast_to=str, + options=FinalRequestOptions.construct(method="get", url="/foo"), + ) + + result = response.parse(to=bool) + assert result is expected + + +@pytest.mark.parametrize( + "content, expected", + [ + ("false", False), + ("true", True), + ("False", False), + ("True", True), + ("TrUe", True), + ("FalSe", False), + ], +) +async def test_async_response_parse_bool(client: AsyncOpencode, content: str, expected: bool) -> None: + response = AsyncAPIResponse( + raw=httpx.Response(200, content=content), + client=client, + stream=False, + stream_cls=None, + cast_to=str, + options=FinalRequestOptions.construct(method="get", url="/foo"), + ) + + result = await response.parse(to=bool) + assert result is expected + + +class OtherModel(BaseModel): + a: str + + +@pytest.mark.parametrize("client", [False], indirect=True) # loose validation +def test_response_parse_expect_model_union_non_json_content(client: Opencode) -> None: + response = APIResponse( + raw=httpx.Response(200, content=b"foo", headers={"Content-Type": "application/text"}), + client=client, + stream=False, + stream_cls=None, + cast_to=str, + options=FinalRequestOptions.construct(method="get", url="/foo"), + ) + + obj = response.parse(to=cast(Any, Union[CustomModel, OtherModel])) + assert isinstance(obj, str) + assert obj == "foo" + + +@pytest.mark.asyncio +@pytest.mark.parametrize("async_client", [False], indirect=True) # loose validation +async def test_async_response_parse_expect_model_union_non_json_content(async_client: AsyncOpencode) -> None: + response = AsyncAPIResponse( + raw=httpx.Response(200, content=b"foo", headers={"Content-Type": "application/text"}), + client=async_client, + stream=False, + stream_cls=None, + cast_to=str, + options=FinalRequestOptions.construct(method="get", url="/foo"), + ) + + obj = await response.parse(to=cast(Any, Union[CustomModel, OtherModel])) + assert isinstance(obj, str) + assert obj == "foo" diff --git a/tests/test_streaming.py b/tests/test_streaming.py new file mode 100644 index 0000000..a3e8d4f --- /dev/null +++ b/tests/test_streaming.py @@ -0,0 +1,248 @@ +from __future__ import annotations + +from typing import Iterator, AsyncIterator + +import httpx +import pytest + +from opencode_ai import Opencode, AsyncOpencode +from opencode_ai._streaming import Stream, AsyncStream, ServerSentEvent + + +@pytest.mark.asyncio +@pytest.mark.parametrize("sync", [True, False], ids=["sync", "async"]) +async def test_basic(sync: bool, client: Opencode, async_client: AsyncOpencode) -> None: + def body() -> Iterator[bytes]: + yield b"event: completion\n" + yield b'data: {"foo":true}\n' + yield b"\n" + + iterator = make_event_iterator(content=body(), sync=sync, client=client, async_client=async_client) + + sse = await iter_next(iterator) + assert sse.event == "completion" + assert sse.json() == {"foo": True} + + await assert_empty_iter(iterator) + + +@pytest.mark.asyncio +@pytest.mark.parametrize("sync", [True, False], ids=["sync", "async"]) +async def test_data_missing_event(sync: bool, client: Opencode, async_client: AsyncOpencode) -> None: + def body() -> Iterator[bytes]: + yield b'data: {"foo":true}\n' + yield b"\n" + + iterator = make_event_iterator(content=body(), sync=sync, client=client, async_client=async_client) + + sse = await iter_next(iterator) + assert sse.event is None + assert sse.json() == {"foo": True} + + await assert_empty_iter(iterator) + + +@pytest.mark.asyncio +@pytest.mark.parametrize("sync", [True, False], ids=["sync", "async"]) +async def test_event_missing_data(sync: bool, client: Opencode, async_client: AsyncOpencode) -> None: + def body() -> Iterator[bytes]: + yield b"event: ping\n" + yield b"\n" + + iterator = make_event_iterator(content=body(), sync=sync, client=client, async_client=async_client) + + sse = await iter_next(iterator) + assert sse.event == "ping" + assert sse.data == "" + + await assert_empty_iter(iterator) + + +@pytest.mark.asyncio +@pytest.mark.parametrize("sync", [True, False], ids=["sync", "async"]) +async def test_multiple_events(sync: bool, client: Opencode, async_client: AsyncOpencode) -> None: + def body() -> Iterator[bytes]: + yield b"event: ping\n" + yield b"\n" + yield b"event: completion\n" + yield b"\n" + + iterator = make_event_iterator(content=body(), sync=sync, client=client, async_client=async_client) + + sse = await iter_next(iterator) + assert sse.event == "ping" + assert sse.data == "" + + sse = await iter_next(iterator) + assert sse.event == "completion" + assert sse.data == "" + + await assert_empty_iter(iterator) + + +@pytest.mark.asyncio +@pytest.mark.parametrize("sync", [True, False], ids=["sync", "async"]) +async def test_multiple_events_with_data(sync: bool, client: Opencode, async_client: AsyncOpencode) -> None: + def body() -> Iterator[bytes]: + yield b"event: ping\n" + yield b'data: {"foo":true}\n' + yield b"\n" + yield b"event: completion\n" + yield b'data: {"bar":false}\n' + yield b"\n" + + iterator = make_event_iterator(content=body(), sync=sync, client=client, async_client=async_client) + + sse = await iter_next(iterator) + assert sse.event == "ping" + assert sse.json() == {"foo": True} + + sse = await iter_next(iterator) + assert sse.event == "completion" + assert sse.json() == {"bar": False} + + await assert_empty_iter(iterator) + + +@pytest.mark.asyncio +@pytest.mark.parametrize("sync", [True, False], ids=["sync", "async"]) +async def test_multiple_data_lines_with_empty_line(sync: bool, client: Opencode, async_client: AsyncOpencode) -> None: + def body() -> Iterator[bytes]: + yield b"event: ping\n" + yield b"data: {\n" + yield b'data: "foo":\n' + yield b"data: \n" + yield b"data:\n" + yield b"data: true}\n" + yield b"\n\n" + + iterator = make_event_iterator(content=body(), sync=sync, client=client, async_client=async_client) + + sse = await iter_next(iterator) + assert sse.event == "ping" + assert sse.json() == {"foo": True} + assert sse.data == '{\n"foo":\n\n\ntrue}' + + await assert_empty_iter(iterator) + + +@pytest.mark.asyncio +@pytest.mark.parametrize("sync", [True, False], ids=["sync", "async"]) +async def test_data_json_escaped_double_new_line(sync: bool, client: Opencode, async_client: AsyncOpencode) -> None: + def body() -> Iterator[bytes]: + yield b"event: ping\n" + yield b'data: {"foo": "my long\\n\\ncontent"}' + yield b"\n\n" + + iterator = make_event_iterator(content=body(), sync=sync, client=client, async_client=async_client) + + sse = await iter_next(iterator) + assert sse.event == "ping" + assert sse.json() == {"foo": "my long\n\ncontent"} + + await assert_empty_iter(iterator) + + +@pytest.mark.asyncio +@pytest.mark.parametrize("sync", [True, False], ids=["sync", "async"]) +async def test_multiple_data_lines(sync: bool, client: Opencode, async_client: AsyncOpencode) -> None: + def body() -> Iterator[bytes]: + yield b"event: ping\n" + yield b"data: {\n" + yield b'data: "foo":\n' + yield b"data: true}\n" + yield b"\n\n" + + iterator = make_event_iterator(content=body(), sync=sync, client=client, async_client=async_client) + + sse = await iter_next(iterator) + assert sse.event == "ping" + assert sse.json() == {"foo": True} + + await assert_empty_iter(iterator) + + +@pytest.mark.parametrize("sync", [True, False], ids=["sync", "async"]) +async def test_special_new_line_character( + sync: bool, + client: Opencode, + async_client: AsyncOpencode, +) -> None: + def body() -> Iterator[bytes]: + yield b'data: {"content":" culpa"}\n' + yield b"\n" + yield b'data: {"content":" \xe2\x80\xa8"}\n' + yield b"\n" + yield b'data: {"content":"foo"}\n' + yield b"\n" + + iterator = make_event_iterator(content=body(), sync=sync, client=client, async_client=async_client) + + sse = await iter_next(iterator) + assert sse.event is None + assert sse.json() == {"content": " culpa"} + + sse = await iter_next(iterator) + assert sse.event is None + assert sse.json() == {"content": " 
"} + + sse = await iter_next(iterator) + assert sse.event is None + assert sse.json() == {"content": "foo"} + + await assert_empty_iter(iterator) + + +@pytest.mark.parametrize("sync", [True, False], ids=["sync", "async"]) +async def test_multi_byte_character_multiple_chunks( + sync: bool, + client: Opencode, + async_client: AsyncOpencode, +) -> None: + def body() -> Iterator[bytes]: + yield b'data: {"content":"' + # bytes taken from the string 'известни' and arbitrarily split + # so that some multi-byte characters span multiple chunks + yield b"\xd0" + yield b"\xb8\xd0\xb7\xd0" + yield b"\xb2\xd0\xb5\xd1\x81\xd1\x82\xd0\xbd\xd0\xb8" + yield b'"}\n' + yield b"\n" + + iterator = make_event_iterator(content=body(), sync=sync, client=client, async_client=async_client) + + sse = await iter_next(iterator) + assert sse.event is None + assert sse.json() == {"content": "известни"} + + +async def to_aiter(iter: Iterator[bytes]) -> AsyncIterator[bytes]: + for chunk in iter: + yield chunk + + +async def iter_next(iter: Iterator[ServerSentEvent] | AsyncIterator[ServerSentEvent]) -> ServerSentEvent: + if isinstance(iter, AsyncIterator): + return await iter.__anext__() + + return next(iter) + + +async def assert_empty_iter(iter: Iterator[ServerSentEvent] | AsyncIterator[ServerSentEvent]) -> None: + with pytest.raises((StopAsyncIteration, RuntimeError)): + await iter_next(iter) + + +def make_event_iterator( + content: Iterator[bytes], + *, + sync: bool, + client: Opencode, + async_client: AsyncOpencode, +) -> Iterator[ServerSentEvent] | AsyncIterator[ServerSentEvent]: + if sync: + return Stream(cast_to=object, client=client, response=httpx.Response(200, content=content))._iter_events() + + return AsyncStream( + cast_to=object, client=async_client, response=httpx.Response(200, content=to_aiter(content)) + )._iter_events() diff --git a/tests/test_transform.py b/tests/test_transform.py new file mode 100644 index 0000000..14a6ae1 --- /dev/null +++ b/tests/test_transform.py @@ -0,0 +1,453 @@ +from __future__ import annotations + +import io +import pathlib +from typing import Any, Dict, List, Union, TypeVar, Iterable, Optional, cast +from datetime import date, datetime +from typing_extensions import Required, Annotated, TypedDict + +import pytest + +from opencode_ai._types import NOT_GIVEN, Base64FileInput +from opencode_ai._utils import ( + PropertyInfo, + transform as _transform, + parse_datetime, + async_transform as _async_transform, +) +from opencode_ai._compat import PYDANTIC_V2 +from opencode_ai._models import BaseModel + +_T = TypeVar("_T") + +SAMPLE_FILE_PATH = pathlib.Path(__file__).parent.joinpath("sample_file.txt") + + +async def transform( + data: _T, + expected_type: object, + use_async: bool, +) -> _T: + if use_async: + return await _async_transform(data, expected_type=expected_type) + + return _transform(data, expected_type=expected_type) + + +parametrize = pytest.mark.parametrize("use_async", [False, True], ids=["sync", "async"]) + + +class Foo1(TypedDict): + foo_bar: Annotated[str, PropertyInfo(alias="fooBar")] + + +@parametrize +@pytest.mark.asyncio +async def test_top_level_alias(use_async: bool) -> None: + assert await transform({"foo_bar": "hello"}, expected_type=Foo1, use_async=use_async) == {"fooBar": "hello"} + + +class Foo2(TypedDict): + bar: Bar2 + + +class Bar2(TypedDict): + this_thing: Annotated[int, PropertyInfo(alias="this__thing")] + baz: Annotated[Baz2, PropertyInfo(alias="Baz")] + + +class Baz2(TypedDict): + my_baz: Annotated[str, PropertyInfo(alias="myBaz")] + + +@parametrize +@pytest.mark.asyncio +async def test_recursive_typeddict(use_async: bool) -> None: + assert await transform({"bar": {"this_thing": 1}}, Foo2, use_async) == {"bar": {"this__thing": 1}} + assert await transform({"bar": {"baz": {"my_baz": "foo"}}}, Foo2, use_async) == {"bar": {"Baz": {"myBaz": "foo"}}} + + +class Foo3(TypedDict): + things: List[Bar3] + + +class Bar3(TypedDict): + my_field: Annotated[str, PropertyInfo(alias="myField")] + + +@parametrize +@pytest.mark.asyncio +async def test_list_of_typeddict(use_async: bool) -> None: + result = await transform({"things": [{"my_field": "foo"}, {"my_field": "foo2"}]}, Foo3, use_async) + assert result == {"things": [{"myField": "foo"}, {"myField": "foo2"}]} + + +class Foo4(TypedDict): + foo: Union[Bar4, Baz4] + + +class Bar4(TypedDict): + foo_bar: Annotated[str, PropertyInfo(alias="fooBar")] + + +class Baz4(TypedDict): + foo_baz: Annotated[str, PropertyInfo(alias="fooBaz")] + + +@parametrize +@pytest.mark.asyncio +async def test_union_of_typeddict(use_async: bool) -> None: + assert await transform({"foo": {"foo_bar": "bar"}}, Foo4, use_async) == {"foo": {"fooBar": "bar"}} + assert await transform({"foo": {"foo_baz": "baz"}}, Foo4, use_async) == {"foo": {"fooBaz": "baz"}} + assert await transform({"foo": {"foo_baz": "baz", "foo_bar": "bar"}}, Foo4, use_async) == { + "foo": {"fooBaz": "baz", "fooBar": "bar"} + } + + +class Foo5(TypedDict): + foo: Annotated[Union[Bar4, List[Baz4]], PropertyInfo(alias="FOO")] + + +class Bar5(TypedDict): + foo_bar: Annotated[str, PropertyInfo(alias="fooBar")] + + +class Baz5(TypedDict): + foo_baz: Annotated[str, PropertyInfo(alias="fooBaz")] + + +@parametrize +@pytest.mark.asyncio +async def test_union_of_list(use_async: bool) -> None: + assert await transform({"foo": {"foo_bar": "bar"}}, Foo5, use_async) == {"FOO": {"fooBar": "bar"}} + assert await transform( + { + "foo": [ + {"foo_baz": "baz"}, + {"foo_baz": "baz"}, + ] + }, + Foo5, + use_async, + ) == {"FOO": [{"fooBaz": "baz"}, {"fooBaz": "baz"}]} + + +class Foo6(TypedDict): + bar: Annotated[str, PropertyInfo(alias="Bar")] + + +@parametrize +@pytest.mark.asyncio +async def test_includes_unknown_keys(use_async: bool) -> None: + assert await transform({"bar": "bar", "baz_": {"FOO": 1}}, Foo6, use_async) == { + "Bar": "bar", + "baz_": {"FOO": 1}, + } + + +class Foo7(TypedDict): + bar: Annotated[List[Bar7], PropertyInfo(alias="bAr")] + foo: Bar7 + + +class Bar7(TypedDict): + foo: str + + +@parametrize +@pytest.mark.asyncio +async def test_ignores_invalid_input(use_async: bool) -> None: + assert await transform({"bar": ""}, Foo7, use_async) == {"bAr": ""} + assert await transform({"foo": ""}, Foo7, use_async) == {"foo": ""} + + +class DatetimeDict(TypedDict, total=False): + foo: Annotated[datetime, PropertyInfo(format="iso8601")] + + bar: Annotated[Optional[datetime], PropertyInfo(format="iso8601")] + + required: Required[Annotated[Optional[datetime], PropertyInfo(format="iso8601")]] + + list_: Required[Annotated[Optional[List[datetime]], PropertyInfo(format="iso8601")]] + + union: Annotated[Union[int, datetime], PropertyInfo(format="iso8601")] + + +class DateDict(TypedDict, total=False): + foo: Annotated[date, PropertyInfo(format="iso8601")] + + +class DatetimeModel(BaseModel): + foo: datetime + + +class DateModel(BaseModel): + foo: Optional[date] + + +@parametrize +@pytest.mark.asyncio +async def test_iso8601_format(use_async: bool) -> None: + dt = datetime.fromisoformat("2023-02-23T14:16:36.337692+00:00") + tz = "Z" if PYDANTIC_V2 else "+00:00" + assert await transform({"foo": dt}, DatetimeDict, use_async) == {"foo": "2023-02-23T14:16:36.337692+00:00"} # type: ignore[comparison-overlap] + assert await transform(DatetimeModel(foo=dt), Any, use_async) == {"foo": "2023-02-23T14:16:36.337692" + tz} # type: ignore[comparison-overlap] + + dt = dt.replace(tzinfo=None) + assert await transform({"foo": dt}, DatetimeDict, use_async) == {"foo": "2023-02-23T14:16:36.337692"} # type: ignore[comparison-overlap] + assert await transform(DatetimeModel(foo=dt), Any, use_async) == {"foo": "2023-02-23T14:16:36.337692"} # type: ignore[comparison-overlap] + + assert await transform({"foo": None}, DateDict, use_async) == {"foo": None} # type: ignore[comparison-overlap] + assert await transform(DateModel(foo=None), Any, use_async) == {"foo": None} # type: ignore + assert await transform({"foo": date.fromisoformat("2023-02-23")}, DateDict, use_async) == {"foo": "2023-02-23"} # type: ignore[comparison-overlap] + assert await transform(DateModel(foo=date.fromisoformat("2023-02-23")), DateDict, use_async) == { + "foo": "2023-02-23" + } # type: ignore[comparison-overlap] + + +@parametrize +@pytest.mark.asyncio +async def test_optional_iso8601_format(use_async: bool) -> None: + dt = datetime.fromisoformat("2023-02-23T14:16:36.337692+00:00") + assert await transform({"bar": dt}, DatetimeDict, use_async) == {"bar": "2023-02-23T14:16:36.337692+00:00"} # type: ignore[comparison-overlap] + + assert await transform({"bar": None}, DatetimeDict, use_async) == {"bar": None} + + +@parametrize +@pytest.mark.asyncio +async def test_required_iso8601_format(use_async: bool) -> None: + dt = datetime.fromisoformat("2023-02-23T14:16:36.337692+00:00") + assert await transform({"required": dt}, DatetimeDict, use_async) == { + "required": "2023-02-23T14:16:36.337692+00:00" + } # type: ignore[comparison-overlap] + + assert await transform({"required": None}, DatetimeDict, use_async) == {"required": None} + + +@parametrize +@pytest.mark.asyncio +async def test_union_datetime(use_async: bool) -> None: + dt = datetime.fromisoformat("2023-02-23T14:16:36.337692+00:00") + assert await transform({"union": dt}, DatetimeDict, use_async) == { # type: ignore[comparison-overlap] + "union": "2023-02-23T14:16:36.337692+00:00" + } + + assert await transform({"union": "foo"}, DatetimeDict, use_async) == {"union": "foo"} + + +@parametrize +@pytest.mark.asyncio +async def test_nested_list_iso6801_format(use_async: bool) -> None: + dt1 = datetime.fromisoformat("2023-02-23T14:16:36.337692+00:00") + dt2 = parse_datetime("2022-01-15T06:34:23Z") + assert await transform({"list_": [dt1, dt2]}, DatetimeDict, use_async) == { # type: ignore[comparison-overlap] + "list_": ["2023-02-23T14:16:36.337692+00:00", "2022-01-15T06:34:23+00:00"] + } + + +@parametrize +@pytest.mark.asyncio +async def test_datetime_custom_format(use_async: bool) -> None: + dt = parse_datetime("2022-01-15T06:34:23Z") + + result = await transform(dt, Annotated[datetime, PropertyInfo(format="custom", format_template="%H")], use_async) + assert result == "06" # type: ignore[comparison-overlap] + + +class DateDictWithRequiredAlias(TypedDict, total=False): + required_prop: Required[Annotated[date, PropertyInfo(format="iso8601", alias="prop")]] + + +@parametrize +@pytest.mark.asyncio +async def test_datetime_with_alias(use_async: bool) -> None: + assert await transform({"required_prop": None}, DateDictWithRequiredAlias, use_async) == {"prop": None} # type: ignore[comparison-overlap] + assert await transform( + {"required_prop": date.fromisoformat("2023-02-23")}, DateDictWithRequiredAlias, use_async + ) == {"prop": "2023-02-23"} # type: ignore[comparison-overlap] + + +class MyModel(BaseModel): + foo: str + + +@parametrize +@pytest.mark.asyncio +async def test_pydantic_model_to_dictionary(use_async: bool) -> None: + assert cast(Any, await transform(MyModel(foo="hi!"), Any, use_async)) == {"foo": "hi!"} + assert cast(Any, await transform(MyModel.construct(foo="hi!"), Any, use_async)) == {"foo": "hi!"} + + +@parametrize +@pytest.mark.asyncio +async def test_pydantic_empty_model(use_async: bool) -> None: + assert cast(Any, await transform(MyModel.construct(), Any, use_async)) == {} + + +@parametrize +@pytest.mark.asyncio +async def test_pydantic_unknown_field(use_async: bool) -> None: + assert cast(Any, await transform(MyModel.construct(my_untyped_field=True), Any, use_async)) == { + "my_untyped_field": True + } + + +@parametrize +@pytest.mark.asyncio +async def test_pydantic_mismatched_types(use_async: bool) -> None: + model = MyModel.construct(foo=True) + if PYDANTIC_V2: + with pytest.warns(UserWarning): + params = await transform(model, Any, use_async) + else: + params = await transform(model, Any, use_async) + assert cast(Any, params) == {"foo": True} + + +@parametrize +@pytest.mark.asyncio +async def test_pydantic_mismatched_object_type(use_async: bool) -> None: + model = MyModel.construct(foo=MyModel.construct(hello="world")) + if PYDANTIC_V2: + with pytest.warns(UserWarning): + params = await transform(model, Any, use_async) + else: + params = await transform(model, Any, use_async) + assert cast(Any, params) == {"foo": {"hello": "world"}} + + +class ModelNestedObjects(BaseModel): + nested: MyModel + + +@parametrize +@pytest.mark.asyncio +async def test_pydantic_nested_objects(use_async: bool) -> None: + model = ModelNestedObjects.construct(nested={"foo": "stainless"}) + assert isinstance(model.nested, MyModel) + assert cast(Any, await transform(model, Any, use_async)) == {"nested": {"foo": "stainless"}} + + +class ModelWithDefaultField(BaseModel): + foo: str + with_none_default: Union[str, None] = None + with_str_default: str = "foo" + + +@parametrize +@pytest.mark.asyncio +async def test_pydantic_default_field(use_async: bool) -> None: + # should be excluded when defaults are used + model = ModelWithDefaultField.construct() + assert model.with_none_default is None + assert model.with_str_default == "foo" + assert cast(Any, await transform(model, Any, use_async)) == {} + + # should be included when the default value is explicitly given + model = ModelWithDefaultField.construct(with_none_default=None, with_str_default="foo") + assert model.with_none_default is None + assert model.with_str_default == "foo" + assert cast(Any, await transform(model, Any, use_async)) == {"with_none_default": None, "with_str_default": "foo"} + + # should be included when a non-default value is explicitly given + model = ModelWithDefaultField.construct(with_none_default="bar", with_str_default="baz") + assert model.with_none_default == "bar" + assert model.with_str_default == "baz" + assert cast(Any, await transform(model, Any, use_async)) == {"with_none_default": "bar", "with_str_default": "baz"} + + +class TypedDictIterableUnion(TypedDict): + foo: Annotated[Union[Bar8, Iterable[Baz8]], PropertyInfo(alias="FOO")] + + +class Bar8(TypedDict): + foo_bar: Annotated[str, PropertyInfo(alias="fooBar")] + + +class Baz8(TypedDict): + foo_baz: Annotated[str, PropertyInfo(alias="fooBaz")] + + +@parametrize +@pytest.mark.asyncio +async def test_iterable_of_dictionaries(use_async: bool) -> None: + assert await transform({"foo": [{"foo_baz": "bar"}]}, TypedDictIterableUnion, use_async) == { + "FOO": [{"fooBaz": "bar"}] + } + assert cast(Any, await transform({"foo": ({"foo_baz": "bar"},)}, TypedDictIterableUnion, use_async)) == { + "FOO": [{"fooBaz": "bar"}] + } + + def my_iter() -> Iterable[Baz8]: + yield {"foo_baz": "hello"} + yield {"foo_baz": "world"} + + assert await transform({"foo": my_iter()}, TypedDictIterableUnion, use_async) == { + "FOO": [{"fooBaz": "hello"}, {"fooBaz": "world"}] + } + + +@parametrize +@pytest.mark.asyncio +async def test_dictionary_items(use_async: bool) -> None: + class DictItems(TypedDict): + foo_baz: Annotated[str, PropertyInfo(alias="fooBaz")] + + assert await transform({"foo": {"foo_baz": "bar"}}, Dict[str, DictItems], use_async) == {"foo": {"fooBaz": "bar"}} + + +class TypedDictIterableUnionStr(TypedDict): + foo: Annotated[Union[str, Iterable[Baz8]], PropertyInfo(alias="FOO")] + + +@parametrize +@pytest.mark.asyncio +async def test_iterable_union_str(use_async: bool) -> None: + assert await transform({"foo": "bar"}, TypedDictIterableUnionStr, use_async) == {"FOO": "bar"} + assert cast(Any, await transform(iter([{"foo_baz": "bar"}]), Union[str, Iterable[Baz8]], use_async)) == [ + {"fooBaz": "bar"} + ] + + +class TypedDictBase64Input(TypedDict): + foo: Annotated[Union[str, Base64FileInput], PropertyInfo(format="base64")] + + +@parametrize +@pytest.mark.asyncio +async def test_base64_file_input(use_async: bool) -> None: + # strings are left as-is + assert await transform({"foo": "bar"}, TypedDictBase64Input, use_async) == {"foo": "bar"} + + # pathlib.Path is automatically converted to base64 + assert await transform({"foo": SAMPLE_FILE_PATH}, TypedDictBase64Input, use_async) == { + "foo": "SGVsbG8sIHdvcmxkIQo=" + } # type: ignore[comparison-overlap] + + # io instances are automatically converted to base64 + assert await transform({"foo": io.StringIO("Hello, world!")}, TypedDictBase64Input, use_async) == { + "foo": "SGVsbG8sIHdvcmxkIQ==" + } # type: ignore[comparison-overlap] + assert await transform({"foo": io.BytesIO(b"Hello, world!")}, TypedDictBase64Input, use_async) == { + "foo": "SGVsbG8sIHdvcmxkIQ==" + } # type: ignore[comparison-overlap] + + +@parametrize +@pytest.mark.asyncio +async def test_transform_skipping(use_async: bool) -> None: + # lists of ints are left as-is + data = [1, 2, 3] + assert await transform(data, List[int], use_async) is data + + # iterables of ints are converted to a list + data = iter([1, 2, 3]) + assert await transform(data, Iterable[int], use_async) == [1, 2, 3] + + +@parametrize +@pytest.mark.asyncio +async def test_strips_notgiven(use_async: bool) -> None: + assert await transform({"foo_bar": "bar"}, Foo1, use_async) == {"fooBar": "bar"} + assert await transform({"foo_bar": NOT_GIVEN}, Foo1, use_async) == {} diff --git a/tests/test_utils/test_proxy.py b/tests/test_utils/test_proxy.py new file mode 100644 index 0000000..303ab41 --- /dev/null +++ b/tests/test_utils/test_proxy.py @@ -0,0 +1,34 @@ +import operator +from typing import Any +from typing_extensions import override + +from opencode_ai._utils import LazyProxy + + +class RecursiveLazyProxy(LazyProxy[Any]): + @override + def __load__(self) -> Any: + return self + + def __call__(self, *_args: Any, **_kwds: Any) -> Any: + raise RuntimeError("This should never be called!") + + +def test_recursive_proxy() -> None: + proxy = RecursiveLazyProxy() + assert repr(proxy) == "RecursiveLazyProxy" + assert str(proxy) == "RecursiveLazyProxy" + assert dir(proxy) == [] + assert type(proxy).__name__ == "RecursiveLazyProxy" + assert type(operator.attrgetter("name.foo.bar.baz")(proxy)).__name__ == "RecursiveLazyProxy" + + +def test_isinstance_does_not_error() -> None: + class AlwaysErrorProxy(LazyProxy[Any]): + @override + def __load__(self) -> Any: + raise RuntimeError("Mocking missing dependency") + + proxy = AlwaysErrorProxy() + assert not isinstance(proxy, dict) + assert isinstance(proxy, LazyProxy) diff --git a/tests/test_utils/test_typing.py b/tests/test_utils/test_typing.py new file mode 100644 index 0000000..47ff1f5 --- /dev/null +++ b/tests/test_utils/test_typing.py @@ -0,0 +1,73 @@ +from __future__ import annotations + +from typing import Generic, TypeVar, cast + +from opencode_ai._utils import extract_type_var_from_base + +_T = TypeVar("_T") +_T2 = TypeVar("_T2") +_T3 = TypeVar("_T3") + + +class BaseGeneric(Generic[_T]): ... + + +class SubclassGeneric(BaseGeneric[_T]): ... + + +class BaseGenericMultipleTypeArgs(Generic[_T, _T2, _T3]): ... + + +class SubclassGenericMultipleTypeArgs(BaseGenericMultipleTypeArgs[_T, _T2, _T3]): ... + + +class SubclassDifferentOrderGenericMultipleTypeArgs(BaseGenericMultipleTypeArgs[_T2, _T, _T3]): ... + + +def test_extract_type_var() -> None: + assert ( + extract_type_var_from_base( + BaseGeneric[int], + index=0, + generic_bases=cast("tuple[type, ...]", (BaseGeneric,)), + ) + == int + ) + + +def test_extract_type_var_generic_subclass() -> None: + assert ( + extract_type_var_from_base( + SubclassGeneric[int], + index=0, + generic_bases=cast("tuple[type, ...]", (BaseGeneric,)), + ) + == int + ) + + +def test_extract_type_var_multiple() -> None: + typ = BaseGenericMultipleTypeArgs[int, str, None] + + generic_bases = cast("tuple[type, ...]", (BaseGenericMultipleTypeArgs,)) + assert extract_type_var_from_base(typ, index=0, generic_bases=generic_bases) == int + assert extract_type_var_from_base(typ, index=1, generic_bases=generic_bases) == str + assert extract_type_var_from_base(typ, index=2, generic_bases=generic_bases) == type(None) + + +def test_extract_type_var_generic_subclass_multiple() -> None: + typ = SubclassGenericMultipleTypeArgs[int, str, None] + + generic_bases = cast("tuple[type, ...]", (BaseGenericMultipleTypeArgs,)) + assert extract_type_var_from_base(typ, index=0, generic_bases=generic_bases) == int + assert extract_type_var_from_base(typ, index=1, generic_bases=generic_bases) == str + assert extract_type_var_from_base(typ, index=2, generic_bases=generic_bases) == type(None) + + +def test_extract_type_var_generic_subclass_different_ordering_multiple() -> None: + typ = SubclassDifferentOrderGenericMultipleTypeArgs[int, str, None] + + generic_bases = cast("tuple[type, ...]", (BaseGenericMultipleTypeArgs,)) + assert extract_type_var_from_base(typ, index=0, generic_bases=generic_bases) == int + assert extract_type_var_from_base(typ, index=1, generic_bases=generic_bases) == str + assert extract_type_var_from_base(typ, index=2, generic_bases=generic_bases) == type(None) diff --git a/tests/utils.py b/tests/utils.py new file mode 100644 index 0000000..48e8c0f --- /dev/null +++ b/tests/utils.py @@ -0,0 +1,167 @@ +from __future__ import annotations + +import os +import inspect +import traceback +import contextlib +from typing import Any, TypeVar, Iterator, Sequence, cast +from datetime import date, datetime +from typing_extensions import Literal, get_args, get_origin, assert_type + +from opencode_ai._types import Omit, NoneType +from opencode_ai._utils import ( + is_dict, + is_list, + is_list_type, + is_union_type, + extract_type_arg, + is_sequence_type, + is_annotated_type, + is_type_alias_type, +) +from opencode_ai._compat import PYDANTIC_V2, field_outer_type, get_model_fields +from opencode_ai._models import BaseModel + +BaseModelT = TypeVar("BaseModelT", bound=BaseModel) + + +def assert_matches_model(model: type[BaseModelT], value: BaseModelT, *, path: list[str]) -> bool: + for name, field in get_model_fields(model).items(): + field_value = getattr(value, name) + if PYDANTIC_V2: + allow_none = False + else: + # in v1 nullability was structured differently + # https://docs.pydantic.dev/2.0/migration/#required-optional-and-nullable-fields + allow_none = getattr(field, "allow_none", False) + + assert_matches_type( + field_outer_type(field), + field_value, + path=[*path, name], + allow_none=allow_none, + ) + + return True + + +# Note: the `path` argument is only used to improve error messages when `--showlocals` is used +def assert_matches_type( + type_: Any, + value: object, + *, + path: list[str], + allow_none: bool = False, +) -> None: + if is_type_alias_type(type_): + type_ = type_.__value__ + + # unwrap `Annotated[T, ...]` -> `T` + if is_annotated_type(type_): + type_ = extract_type_arg(type_, 0) + + if allow_none and value is None: + return + + if type_ is None or type_ is NoneType: + assert value is None + return + + origin = get_origin(type_) or type_ + + if is_list_type(type_): + return _assert_list_type(type_, value) + + if is_sequence_type(type_): + assert isinstance(value, Sequence) + inner_type = get_args(type_)[0] + for entry in value: # type: ignore + assert_type(inner_type, entry) # type: ignore + return + + if origin == str: + assert isinstance(value, str) + elif origin == int: + assert isinstance(value, int) + elif origin == bool: + assert isinstance(value, bool) + elif origin == float: + assert isinstance(value, float) + elif origin == bytes: + assert isinstance(value, bytes) + elif origin == datetime: + assert isinstance(value, datetime) + elif origin == date: + assert isinstance(value, date) + elif origin == object: + # nothing to do here, the expected type is unknown + pass + elif origin == Literal: + assert value in get_args(type_) + elif origin == dict: + assert is_dict(value) + + args = get_args(type_) + key_type = args[0] + items_type = args[1] + + for key, item in value.items(): + assert_matches_type(key_type, key, path=[*path, ""]) + assert_matches_type(items_type, item, path=[*path, ""]) + elif is_union_type(type_): + variants = get_args(type_) + + try: + none_index = variants.index(type(None)) + except ValueError: + pass + else: + # special case Optional[T] for better error messages + if len(variants) == 2: + if value is None: + # valid + return + + return assert_matches_type(type_=variants[not none_index], value=value, path=path) + + for i, variant in enumerate(variants): + try: + assert_matches_type(variant, value, path=[*path, f"variant {i}"]) + return + except AssertionError: + traceback.print_exc() + continue + + raise AssertionError("Did not match any variants") + elif issubclass(origin, BaseModel): + assert isinstance(value, type_) + assert assert_matches_model(type_, cast(Any, value), path=path) + elif inspect.isclass(origin) and origin.__name__ == "HttpxBinaryResponseContent": + assert value.__class__.__name__ == "HttpxBinaryResponseContent" + else: + assert None, f"Unhandled field type: {type_}" + + +def _assert_list_type(type_: type[object], value: object) -> None: + assert is_list(value) + + inner_type = get_args(type_)[0] + for entry in value: + assert_type(inner_type, entry) # type: ignore + + +@contextlib.contextmanager +def update_env(**new_env: str | Omit) -> Iterator[None]: + old = os.environ.copy() + + try: + for name, value in new_env.items(): + if isinstance(value, Omit): + os.environ.pop(name, None) + else: + os.environ[name] = value + + yield None + finally: + os.environ.clear() + os.environ.update(old) From 5d58a795ce9d8635b311c8eae4d5c719ecf5558a Mon Sep 17 00:00:00 2001 From: "stainless-app[bot]" <142633134+stainless-app[bot]@users.noreply.github.com> Date: Thu, 4 Sep 2025 02:54:45 +0000 Subject: [PATCH 03/20] feat: improve future compat with pydantic v3 --- src/opencode_ai/_base_client.py | 6 +- src/opencode_ai/_compat.py | 96 +++++++-------- src/opencode_ai/_models.py | 80 ++++++------- src/opencode_ai/_utils/__init__.py | 10 +- src/opencode_ai/_utils/_compat.py | 45 +++++++ src/opencode_ai/_utils/_datetime_parse.py | 136 ++++++++++++++++++++++ src/opencode_ai/_utils/_transform.py | 6 +- src/opencode_ai/_utils/_typing.py | 2 +- src/opencode_ai/_utils/_utils.py | 1 - tests/test_models.py | 48 ++++---- tests/test_transform.py | 16 +-- tests/test_utils/test_datetime_parse.py | 110 +++++++++++++++++ tests/utils.py | 8 +- 13 files changed, 432 insertions(+), 132 deletions(-) create mode 100644 src/opencode_ai/_utils/_compat.py create mode 100644 src/opencode_ai/_utils/_datetime_parse.py create mode 100644 tests/test_utils/test_datetime_parse.py diff --git a/src/opencode_ai/_base_client.py b/src/opencode_ai/_base_client.py index eb05756..f7ce0f3 100644 --- a/src/opencode_ai/_base_client.py +++ b/src/opencode_ai/_base_client.py @@ -59,7 +59,7 @@ ModelBuilderProtocol, ) from ._utils import is_dict, is_list, asyncify, is_given, lru_cache, is_mapping -from ._compat import PYDANTIC_V2, model_copy, model_dump +from ._compat import PYDANTIC_V1, model_copy, model_dump from ._models import GenericModel, FinalRequestOptions, validate_type, construct_type from ._response import ( APIResponse, @@ -232,7 +232,7 @@ def _set_private_attributes( model: Type[_T], options: FinalRequestOptions, ) -> None: - if PYDANTIC_V2 and getattr(self, "__pydantic_private__", None) is None: + if (not PYDANTIC_V1) and getattr(self, "__pydantic_private__", None) is None: self.__pydantic_private__ = {} self._model = model @@ -320,7 +320,7 @@ def _set_private_attributes( client: AsyncAPIClient, options: FinalRequestOptions, ) -> None: - if PYDANTIC_V2 and getattr(self, "__pydantic_private__", None) is None: + if (not PYDANTIC_V1) and getattr(self, "__pydantic_private__", None) is None: self.__pydantic_private__ = {} self._model = model diff --git a/src/opencode_ai/_compat.py b/src/opencode_ai/_compat.py index 92d9ee6..bdef67f 100644 --- a/src/opencode_ai/_compat.py +++ b/src/opencode_ai/_compat.py @@ -12,14 +12,13 @@ _T = TypeVar("_T") _ModelT = TypeVar("_ModelT", bound=pydantic.BaseModel) -# --------------- Pydantic v2 compatibility --------------- +# --------------- Pydantic v2, v3 compatibility --------------- # Pyright incorrectly reports some of our functions as overriding a method when they don't # pyright: reportIncompatibleMethodOverride=false -PYDANTIC_V2 = pydantic.VERSION.startswith("2.") +PYDANTIC_V1 = pydantic.VERSION.startswith("1.") -# v1 re-exports if TYPE_CHECKING: def parse_date(value: date | StrBytesIntFloat) -> date: # noqa: ARG001 @@ -44,90 +43,92 @@ def is_typeddict(type_: type[Any]) -> bool: # noqa: ARG001 ... else: - if PYDANTIC_V2: - from pydantic.v1.typing import ( + # v1 re-exports + if PYDANTIC_V1: + from pydantic.typing import ( get_args as get_args, is_union as is_union, get_origin as get_origin, is_typeddict as is_typeddict, is_literal_type as is_literal_type, ) - from pydantic.v1.datetime_parse import parse_date as parse_date, parse_datetime as parse_datetime + from pydantic.datetime_parse import parse_date as parse_date, parse_datetime as parse_datetime else: - from pydantic.typing import ( + from ._utils import ( get_args as get_args, is_union as is_union, get_origin as get_origin, + parse_date as parse_date, is_typeddict as is_typeddict, + parse_datetime as parse_datetime, is_literal_type as is_literal_type, ) - from pydantic.datetime_parse import parse_date as parse_date, parse_datetime as parse_datetime # refactored config if TYPE_CHECKING: from pydantic import ConfigDict as ConfigDict else: - if PYDANTIC_V2: - from pydantic import ConfigDict - else: + if PYDANTIC_V1: # TODO: provide an error message here? ConfigDict = None + else: + from pydantic import ConfigDict as ConfigDict # renamed methods / properties def parse_obj(model: type[_ModelT], value: object) -> _ModelT: - if PYDANTIC_V2: - return model.model_validate(value) - else: + if PYDANTIC_V1: return cast(_ModelT, model.parse_obj(value)) # pyright: ignore[reportDeprecated, reportUnnecessaryCast] + else: + return model.model_validate(value) def field_is_required(field: FieldInfo) -> bool: - if PYDANTIC_V2: - return field.is_required() - return field.required # type: ignore + if PYDANTIC_V1: + return field.required # type: ignore + return field.is_required() def field_get_default(field: FieldInfo) -> Any: value = field.get_default() - if PYDANTIC_V2: - from pydantic_core import PydanticUndefined - - if value == PydanticUndefined: - return None + if PYDANTIC_V1: return value + from pydantic_core import PydanticUndefined + + if value == PydanticUndefined: + return None return value def field_outer_type(field: FieldInfo) -> Any: - if PYDANTIC_V2: - return field.annotation - return field.outer_type_ # type: ignore + if PYDANTIC_V1: + return field.outer_type_ # type: ignore + return field.annotation def get_model_config(model: type[pydantic.BaseModel]) -> Any: - if PYDANTIC_V2: - return model.model_config - return model.__config__ # type: ignore + if PYDANTIC_V1: + return model.__config__ # type: ignore + return model.model_config def get_model_fields(model: type[pydantic.BaseModel]) -> dict[str, FieldInfo]: - if PYDANTIC_V2: - return model.model_fields - return model.__fields__ # type: ignore + if PYDANTIC_V1: + return model.__fields__ # type: ignore + return model.model_fields def model_copy(model: _ModelT, *, deep: bool = False) -> _ModelT: - if PYDANTIC_V2: - return model.model_copy(deep=deep) - return model.copy(deep=deep) # type: ignore + if PYDANTIC_V1: + return model.copy(deep=deep) # type: ignore + return model.model_copy(deep=deep) def model_json(model: pydantic.BaseModel, *, indent: int | None = None) -> str: - if PYDANTIC_V2: - return model.model_dump_json(indent=indent) - return model.json(indent=indent) # type: ignore + if PYDANTIC_V1: + return model.json(indent=indent) # type: ignore + return model.model_dump_json(indent=indent) def model_dump( @@ -139,14 +140,14 @@ def model_dump( warnings: bool = True, mode: Literal["json", "python"] = "python", ) -> dict[str, Any]: - if PYDANTIC_V2 or hasattr(model, "model_dump"): + if (not PYDANTIC_V1) or hasattr(model, "model_dump"): return model.model_dump( mode=mode, exclude=exclude, exclude_unset=exclude_unset, exclude_defaults=exclude_defaults, # warnings are not supported in Pydantic v1 - warnings=warnings if PYDANTIC_V2 else True, + warnings=True if PYDANTIC_V1 else warnings, ) return cast( "dict[str, Any]", @@ -159,9 +160,9 @@ def model_dump( def model_parse(model: type[_ModelT], data: Any) -> _ModelT: - if PYDANTIC_V2: - return model.model_validate(data) - return model.parse_obj(data) # pyright: ignore[reportDeprecated] + if PYDANTIC_V1: + return model.parse_obj(data) # pyright: ignore[reportDeprecated] + return model.model_validate(data) # generic models @@ -170,17 +171,16 @@ def model_parse(model: type[_ModelT], data: Any) -> _ModelT: class GenericModel(pydantic.BaseModel): ... else: - if PYDANTIC_V2: + if PYDANTIC_V1: + import pydantic.generics + + class GenericModel(pydantic.generics.GenericModel, pydantic.BaseModel): ... + else: # there no longer needs to be a distinction in v2 but # we still have to create our own subclass to avoid # inconsistent MRO ordering errors class GenericModel(pydantic.BaseModel): ... - else: - import pydantic.generics - - class GenericModel(pydantic.generics.GenericModel, pydantic.BaseModel): ... - # cached properties if TYPE_CHECKING: diff --git a/src/opencode_ai/_models.py b/src/opencode_ai/_models.py index 92f7c10..3a6017e 100644 --- a/src/opencode_ai/_models.py +++ b/src/opencode_ai/_models.py @@ -50,7 +50,7 @@ strip_annotated_type, ) from ._compat import ( - PYDANTIC_V2, + PYDANTIC_V1, ConfigDict, GenericModel as BaseGenericModel, get_args, @@ -81,11 +81,7 @@ class _ConfigProtocol(Protocol): class BaseModel(pydantic.BaseModel): - if PYDANTIC_V2: - model_config: ClassVar[ConfigDict] = ConfigDict( - extra="allow", defer_build=coerce_boolean(os.environ.get("DEFER_PYDANTIC_BUILD", "true")) - ) - else: + if PYDANTIC_V1: @property @override @@ -95,6 +91,10 @@ def model_fields_set(self) -> set[str]: class Config(pydantic.BaseConfig): # pyright: ignore[reportDeprecated] extra: Any = pydantic.Extra.allow # type: ignore + else: + model_config: ClassVar[ConfigDict] = ConfigDict( + extra="allow", defer_build=coerce_boolean(os.environ.get("DEFER_PYDANTIC_BUILD", "true")) + ) def to_dict( self, @@ -215,25 +215,25 @@ def construct( # pyright: ignore[reportIncompatibleMethodOverride] if key not in model_fields: parsed = construct_type(value=value, type_=extra_field_type) if extra_field_type is not None else value - if PYDANTIC_V2: - _extra[key] = parsed - else: + if PYDANTIC_V1: _fields_set.add(key) fields_values[key] = parsed + else: + _extra[key] = parsed object.__setattr__(m, "__dict__", fields_values) - if PYDANTIC_V2: - # these properties are copied from Pydantic's `model_construct()` method - object.__setattr__(m, "__pydantic_private__", None) - object.__setattr__(m, "__pydantic_extra__", _extra) - object.__setattr__(m, "__pydantic_fields_set__", _fields_set) - else: + if PYDANTIC_V1: # init_private_attributes() does not exist in v2 m._init_private_attributes() # type: ignore # copied from Pydantic v1's `construct()` method object.__setattr__(m, "__fields_set__", _fields_set) + else: + # these properties are copied from Pydantic's `model_construct()` method + object.__setattr__(m, "__pydantic_private__", None) + object.__setattr__(m, "__pydantic_extra__", _extra) + object.__setattr__(m, "__pydantic_fields_set__", _fields_set) return m @@ -243,7 +243,7 @@ def construct( # pyright: ignore[reportIncompatibleMethodOverride] # although not in practice model_construct = construct - if not PYDANTIC_V2: + if PYDANTIC_V1: # we define aliases for some of the new pydantic v2 methods so # that we can just document these methods without having to specify # a specific pydantic version as some users may not know which @@ -363,10 +363,10 @@ def _construct_field(value: object, field: FieldInfo, key: str) -> object: if value is None: return field_get_default(field) - if PYDANTIC_V2: - type_ = field.annotation - else: + if PYDANTIC_V1: type_ = cast(type, field.outer_type_) # type: ignore + else: + type_ = field.annotation # type: ignore if type_ is None: raise RuntimeError(f"Unexpected field type is None for {key}") @@ -375,7 +375,7 @@ def _construct_field(value: object, field: FieldInfo, key: str) -> object: def _get_extra_fields_type(cls: type[pydantic.BaseModel]) -> type | None: - if not PYDANTIC_V2: + if PYDANTIC_V1: # TODO return None @@ -628,30 +628,30 @@ def _build_discriminated_union_meta(*, union: type, meta_annotations: tuple[Any, for variant in get_args(union): variant = strip_annotated_type(variant) if is_basemodel_type(variant): - if PYDANTIC_V2: - field = _extract_field_schema_pv2(variant, discriminator_field_name) - if not field: + if PYDANTIC_V1: + field_info = cast("dict[str, FieldInfo]", variant.__fields__).get(discriminator_field_name) # pyright: ignore[reportDeprecated, reportUnnecessaryCast] + if not field_info: continue # Note: if one variant defines an alias then they all should - discriminator_alias = field.get("serialization_alias") - - field_schema = field["schema"] + discriminator_alias = field_info.alias - if field_schema["type"] == "literal": - for entry in cast("LiteralSchema", field_schema)["expected"]: + if (annotation := getattr(field_info, "annotation", None)) and is_literal_type(annotation): + for entry in get_args(annotation): if isinstance(entry, str): mapping[entry] = variant else: - field_info = cast("dict[str, FieldInfo]", variant.__fields__).get(discriminator_field_name) # pyright: ignore[reportDeprecated, reportUnnecessaryCast] - if not field_info: + field = _extract_field_schema_pv2(variant, discriminator_field_name) + if not field: continue # Note: if one variant defines an alias then they all should - discriminator_alias = field_info.alias + discriminator_alias = field.get("serialization_alias") - if (annotation := getattr(field_info, "annotation", None)) and is_literal_type(annotation): - for entry in get_args(annotation): + field_schema = field["schema"] + + if field_schema["type"] == "literal": + for entry in cast("LiteralSchema", field_schema)["expected"]: if isinstance(entry, str): mapping[entry] = variant @@ -714,7 +714,7 @@ class GenericModel(BaseGenericModel, BaseModel): pass -if PYDANTIC_V2: +if not PYDANTIC_V1: from pydantic import TypeAdapter as _TypeAdapter _CachedTypeAdapter = cast("TypeAdapter[object]", lru_cache(maxsize=None)(_TypeAdapter)) @@ -782,12 +782,12 @@ class FinalRequestOptions(pydantic.BaseModel): json_data: Union[Body, None] = None extra_json: Union[AnyMapping, None] = None - if PYDANTIC_V2: - model_config: ClassVar[ConfigDict] = ConfigDict(arbitrary_types_allowed=True) - else: + if PYDANTIC_V1: class Config(pydantic.BaseConfig): # pyright: ignore[reportDeprecated] arbitrary_types_allowed: bool = True + else: + model_config: ClassVar[ConfigDict] = ConfigDict(arbitrary_types_allowed=True) def get_max_retries(self, max_retries: int) -> int: if isinstance(self.max_retries, NotGiven): @@ -820,9 +820,9 @@ def construct( # type: ignore key: strip_not_given(value) for key, value in values.items() } - if PYDANTIC_V2: - return super().model_construct(_fields_set, **kwargs) - return cast(FinalRequestOptions, super().construct(_fields_set, **kwargs)) # pyright: ignore[reportDeprecated] + if PYDANTIC_V1: + return cast(FinalRequestOptions, super().construct(_fields_set, **kwargs)) # pyright: ignore[reportDeprecated] + return super().model_construct(_fields_set, **kwargs) if not TYPE_CHECKING: # type checkers incorrectly complain about this assignment diff --git a/src/opencode_ai/_utils/__init__.py b/src/opencode_ai/_utils/__init__.py index ca547ce..dc64e29 100644 --- a/src/opencode_ai/_utils/__init__.py +++ b/src/opencode_ai/_utils/__init__.py @@ -10,7 +10,6 @@ lru_cache as lru_cache, is_mapping as is_mapping, is_tuple_t as is_tuple_t, - parse_date as parse_date, is_iterable as is_iterable, is_sequence as is_sequence, coerce_float as coerce_float, @@ -23,7 +22,6 @@ coerce_boolean as coerce_boolean, coerce_integer as coerce_integer, file_from_path as file_from_path, - parse_datetime as parse_datetime, strip_not_given as strip_not_given, deepcopy_minimal as deepcopy_minimal, get_async_library as get_async_library, @@ -32,6 +30,13 @@ maybe_coerce_boolean as maybe_coerce_boolean, maybe_coerce_integer as maybe_coerce_integer, ) +from ._compat import ( + get_args as get_args, + is_union as is_union, + get_origin as get_origin, + is_typeddict as is_typeddict, + is_literal_type as is_literal_type, +) from ._typing import ( is_list_type as is_list_type, is_union_type as is_union_type, @@ -56,3 +61,4 @@ function_has_argument as function_has_argument, assert_signatures_in_sync as assert_signatures_in_sync, ) +from ._datetime_parse import parse_date as parse_date, parse_datetime as parse_datetime diff --git a/src/opencode_ai/_utils/_compat.py b/src/opencode_ai/_utils/_compat.py new file mode 100644 index 0000000..dd70323 --- /dev/null +++ b/src/opencode_ai/_utils/_compat.py @@ -0,0 +1,45 @@ +from __future__ import annotations + +import sys +import typing_extensions +from typing import Any, Type, Union, Literal, Optional +from datetime import date, datetime +from typing_extensions import get_args as _get_args, get_origin as _get_origin + +from .._types import StrBytesIntFloat +from ._datetime_parse import parse_date as _parse_date, parse_datetime as _parse_datetime + +_LITERAL_TYPES = {Literal, typing_extensions.Literal} + + +def get_args(tp: type[Any]) -> tuple[Any, ...]: + return _get_args(tp) + + +def get_origin(tp: type[Any]) -> type[Any] | None: + return _get_origin(tp) + + +def is_union(tp: Optional[Type[Any]]) -> bool: + if sys.version_info < (3, 10): + return tp is Union # type: ignore[comparison-overlap] + else: + import types + + return tp is Union or tp is types.UnionType + + +def is_typeddict(tp: Type[Any]) -> bool: + return typing_extensions.is_typeddict(tp) + + +def is_literal_type(tp: Type[Any]) -> bool: + return get_origin(tp) in _LITERAL_TYPES + + +def parse_date(value: Union[date, StrBytesIntFloat]) -> date: + return _parse_date(value) + + +def parse_datetime(value: Union[datetime, StrBytesIntFloat]) -> datetime: + return _parse_datetime(value) diff --git a/src/opencode_ai/_utils/_datetime_parse.py b/src/opencode_ai/_utils/_datetime_parse.py new file mode 100644 index 0000000..7cb9d9e --- /dev/null +++ b/src/opencode_ai/_utils/_datetime_parse.py @@ -0,0 +1,136 @@ +""" +This file contains code from https://github.com/pydantic/pydantic/blob/main/pydantic/v1/datetime_parse.py +without the Pydantic v1 specific errors. +""" + +from __future__ import annotations + +import re +from typing import Dict, Union, Optional +from datetime import date, datetime, timezone, timedelta + +from .._types import StrBytesIntFloat + +date_expr = r"(?P\d{4})-(?P\d{1,2})-(?P\d{1,2})" +time_expr = ( + r"(?P\d{1,2}):(?P\d{1,2})" + r"(?::(?P\d{1,2})(?:\.(?P\d{1,6})\d{0,6})?)?" + r"(?PZ|[+-]\d{2}(?::?\d{2})?)?$" +) + +date_re = re.compile(f"{date_expr}$") +datetime_re = re.compile(f"{date_expr}[T ]{time_expr}") + + +EPOCH = datetime(1970, 1, 1) +# if greater than this, the number is in ms, if less than or equal it's in seconds +# (in seconds this is 11th October 2603, in ms it's 20th August 1970) +MS_WATERSHED = int(2e10) +# slightly more than datetime.max in ns - (datetime.max - EPOCH).total_seconds() * 1e9 +MAX_NUMBER = int(3e20) + + +def _get_numeric(value: StrBytesIntFloat, native_expected_type: str) -> Union[None, int, float]: + if isinstance(value, (int, float)): + return value + try: + return float(value) + except ValueError: + return None + except TypeError: + raise TypeError(f"invalid type; expected {native_expected_type}, string, bytes, int or float") from None + + +def _from_unix_seconds(seconds: Union[int, float]) -> datetime: + if seconds > MAX_NUMBER: + return datetime.max + elif seconds < -MAX_NUMBER: + return datetime.min + + while abs(seconds) > MS_WATERSHED: + seconds /= 1000 + dt = EPOCH + timedelta(seconds=seconds) + return dt.replace(tzinfo=timezone.utc) + + +def _parse_timezone(value: Optional[str]) -> Union[None, int, timezone]: + if value == "Z": + return timezone.utc + elif value is not None: + offset_mins = int(value[-2:]) if len(value) > 3 else 0 + offset = 60 * int(value[1:3]) + offset_mins + if value[0] == "-": + offset = -offset + return timezone(timedelta(minutes=offset)) + else: + return None + + +def parse_datetime(value: Union[datetime, StrBytesIntFloat]) -> datetime: + """ + Parse a datetime/int/float/string and return a datetime.datetime. + + This function supports time zone offsets. When the input contains one, + the output uses a timezone with a fixed offset from UTC. + + Raise ValueError if the input is well formatted but not a valid datetime. + Raise ValueError if the input isn't well formatted. + """ + if isinstance(value, datetime): + return value + + number = _get_numeric(value, "datetime") + if number is not None: + return _from_unix_seconds(number) + + if isinstance(value, bytes): + value = value.decode() + + assert not isinstance(value, (float, int)) + + match = datetime_re.match(value) + if match is None: + raise ValueError("invalid datetime format") + + kw = match.groupdict() + if kw["microsecond"]: + kw["microsecond"] = kw["microsecond"].ljust(6, "0") + + tzinfo = _parse_timezone(kw.pop("tzinfo")) + kw_: Dict[str, Union[None, int, timezone]] = {k: int(v) for k, v in kw.items() if v is not None} + kw_["tzinfo"] = tzinfo + + return datetime(**kw_) # type: ignore + + +def parse_date(value: Union[date, StrBytesIntFloat]) -> date: + """ + Parse a date/int/float/string and return a datetime.date. + + Raise ValueError if the input is well formatted but not a valid date. + Raise ValueError if the input isn't well formatted. + """ + if isinstance(value, date): + if isinstance(value, datetime): + return value.date() + else: + return value + + number = _get_numeric(value, "date") + if number is not None: + return _from_unix_seconds(number).date() + + if isinstance(value, bytes): + value = value.decode() + + assert not isinstance(value, (float, int)) + match = date_re.match(value) + if match is None: + raise ValueError("invalid date format") + + kw = {k: int(v) for k, v in match.groupdict().items()} + + try: + return date(**kw) + except ValueError: + raise ValueError("invalid date format") from None diff --git a/src/opencode_ai/_utils/_transform.py b/src/opencode_ai/_utils/_transform.py index f0bcefd..c19124f 100644 --- a/src/opencode_ai/_utils/_transform.py +++ b/src/opencode_ai/_utils/_transform.py @@ -19,6 +19,7 @@ is_sequence, ) from .._files import is_base64_file_input +from ._compat import get_origin, is_typeddict from ._typing import ( is_list_type, is_union_type, @@ -29,7 +30,6 @@ is_annotated_type, strip_annotated_type, ) -from .._compat import get_origin, model_dump, is_typeddict _T = TypeVar("_T") @@ -169,6 +169,8 @@ def _transform_recursive( Defaults to the same value as the `annotation` argument. """ + from .._compat import model_dump + if inner_type is None: inner_type = annotation @@ -333,6 +335,8 @@ async def _async_transform_recursive( Defaults to the same value as the `annotation` argument. """ + from .._compat import model_dump + if inner_type is None: inner_type = annotation diff --git a/src/opencode_ai/_utils/_typing.py b/src/opencode_ai/_utils/_typing.py index 845cd6b..193109f 100644 --- a/src/opencode_ai/_utils/_typing.py +++ b/src/opencode_ai/_utils/_typing.py @@ -15,7 +15,7 @@ from ._utils import lru_cache from .._types import InheritsGeneric -from .._compat import is_union as _is_union +from ._compat import is_union as _is_union def is_annotated_type(typ: type) -> bool: diff --git a/src/opencode_ai/_utils/_utils.py b/src/opencode_ai/_utils/_utils.py index ea3cf3f..f081859 100644 --- a/src/opencode_ai/_utils/_utils.py +++ b/src/opencode_ai/_utils/_utils.py @@ -22,7 +22,6 @@ import sniffio from .._types import NotGiven, FileTypes, NotGivenOr, HeadersLike -from .._compat import parse_date as parse_date, parse_datetime as parse_datetime _T = TypeVar("_T") _TupleT = TypeVar("_TupleT", bound=Tuple[object, ...]) diff --git a/tests/test_models.py b/tests/test_models.py index 94fa22d..c4ab3f0 100644 --- a/tests/test_models.py +++ b/tests/test_models.py @@ -8,7 +8,7 @@ from pydantic import Field from opencode_ai._utils import PropertyInfo -from opencode_ai._compat import PYDANTIC_V2, parse_obj, model_dump, model_json +from opencode_ai._compat import PYDANTIC_V1, parse_obj, model_dump, model_json from opencode_ai._models import BaseModel, construct_type @@ -294,12 +294,12 @@ class Model(BaseModel): assert cast(bool, m.foo) is True m = Model.construct(foo={"name": 3}) - if PYDANTIC_V2: - assert isinstance(m.foo, Submodel1) - assert m.foo.name == 3 # type: ignore - else: + if PYDANTIC_V1: assert isinstance(m.foo, Submodel2) assert m.foo.name == "3" + else: + assert isinstance(m.foo, Submodel1) + assert m.foo.name == 3 # type: ignore def test_list_of_unions() -> None: @@ -426,10 +426,10 @@ class Model(BaseModel): expected = datetime(2019, 12, 27, 18, 11, 19, 117000, tzinfo=timezone.utc) - if PYDANTIC_V2: - expected_json = '{"created_at":"2019-12-27T18:11:19.117000Z"}' - else: + if PYDANTIC_V1: expected_json = '{"created_at": "2019-12-27T18:11:19.117000+00:00"}' + else: + expected_json = '{"created_at":"2019-12-27T18:11:19.117000Z"}' model = Model.construct(created_at="2019-12-27T18:11:19.117Z") assert model.created_at == expected @@ -531,7 +531,7 @@ class Model2(BaseModel): assert m4.to_dict(mode="python") == {"created_at": datetime.fromisoformat(time_str)} assert m4.to_dict(mode="json") == {"created_at": time_str} - if not PYDANTIC_V2: + if PYDANTIC_V1: with pytest.raises(ValueError, match="warnings is only supported in Pydantic v2"): m.to_dict(warnings=False) @@ -556,7 +556,7 @@ class Model(BaseModel): assert m3.model_dump() == {"foo": None} assert m3.model_dump(exclude_none=True) == {} - if not PYDANTIC_V2: + if PYDANTIC_V1: with pytest.raises(ValueError, match="round_trip is only supported in Pydantic v2"): m.model_dump(round_trip=True) @@ -580,10 +580,10 @@ class Model(BaseModel): assert json.loads(m.to_json()) == {"FOO": "hello"} assert json.loads(m.to_json(use_api_names=False)) == {"foo": "hello"} - if PYDANTIC_V2: - assert m.to_json(indent=None) == '{"FOO":"hello"}' - else: + if PYDANTIC_V1: assert m.to_json(indent=None) == '{"FOO": "hello"}' + else: + assert m.to_json(indent=None) == '{"FOO":"hello"}' m2 = Model() assert json.loads(m2.to_json()) == {} @@ -595,7 +595,7 @@ class Model(BaseModel): assert json.loads(m3.to_json()) == {"FOO": None} assert json.loads(m3.to_json(exclude_none=True)) == {} - if not PYDANTIC_V2: + if PYDANTIC_V1: with pytest.raises(ValueError, match="warnings is only supported in Pydantic v2"): m.to_json(warnings=False) @@ -622,7 +622,7 @@ class Model(BaseModel): assert json.loads(m3.model_dump_json()) == {"foo": None} assert json.loads(m3.model_dump_json(exclude_none=True)) == {} - if not PYDANTIC_V2: + if PYDANTIC_V1: with pytest.raises(ValueError, match="round_trip is only supported in Pydantic v2"): m.model_dump_json(round_trip=True) @@ -679,12 +679,12 @@ class B(BaseModel): ) assert isinstance(m, A) assert m.type == "a" - if PYDANTIC_V2: - assert m.data == 100 # type: ignore[comparison-overlap] - else: + if PYDANTIC_V1: # pydantic v1 automatically converts inputs to strings # if the expected type is a str assert m.data == "100" + else: + assert m.data == 100 # type: ignore[comparison-overlap] def test_discriminated_unions_unknown_variant() -> None: @@ -768,12 +768,12 @@ class B(BaseModel): ) assert isinstance(m, A) assert m.foo_type == "a" - if PYDANTIC_V2: - assert m.data == 100 # type: ignore[comparison-overlap] - else: + if PYDANTIC_V1: # pydantic v1 automatically converts inputs to strings # if the expected type is a str assert m.data == "100" + else: + assert m.data == 100 # type: ignore[comparison-overlap] def test_discriminated_unions_overlapping_discriminators_invalid_data() -> None: @@ -833,7 +833,7 @@ class B(BaseModel): assert UnionType.__discriminator__ is discriminator -@pytest.mark.skipif(not PYDANTIC_V2, reason="TypeAliasType is not supported in Pydantic v1") +@pytest.mark.skipif(PYDANTIC_V1, reason="TypeAliasType is not supported in Pydantic v1") def test_type_alias_type() -> None: Alias = TypeAliasType("Alias", str) # pyright: ignore @@ -849,7 +849,7 @@ class Model(BaseModel): assert m.union == "bar" -@pytest.mark.skipif(not PYDANTIC_V2, reason="TypeAliasType is not supported in Pydantic v1") +@pytest.mark.skipif(PYDANTIC_V1, reason="TypeAliasType is not supported in Pydantic v1") def test_field_named_cls() -> None: class Model(BaseModel): cls: str @@ -936,7 +936,7 @@ class Type2(BaseModel): assert isinstance(model.value, InnerType2) -@pytest.mark.skipif(not PYDANTIC_V2, reason="this is only supported in pydantic v2 for now") +@pytest.mark.skipif(PYDANTIC_V1, reason="this is only supported in pydantic v2 for now") def test_extra_properties() -> None: class Item(BaseModel): prop: int diff --git a/tests/test_transform.py b/tests/test_transform.py index 14a6ae1..5ecdb1f 100644 --- a/tests/test_transform.py +++ b/tests/test_transform.py @@ -15,7 +15,7 @@ parse_datetime, async_transform as _async_transform, ) -from opencode_ai._compat import PYDANTIC_V2 +from opencode_ai._compat import PYDANTIC_V1 from opencode_ai._models import BaseModel _T = TypeVar("_T") @@ -189,7 +189,7 @@ class DateModel(BaseModel): @pytest.mark.asyncio async def test_iso8601_format(use_async: bool) -> None: dt = datetime.fromisoformat("2023-02-23T14:16:36.337692+00:00") - tz = "Z" if PYDANTIC_V2 else "+00:00" + tz = "+00:00" if PYDANTIC_V1 else "Z" assert await transform({"foo": dt}, DatetimeDict, use_async) == {"foo": "2023-02-23T14:16:36.337692+00:00"} # type: ignore[comparison-overlap] assert await transform(DatetimeModel(foo=dt), Any, use_async) == {"foo": "2023-02-23T14:16:36.337692" + tz} # type: ignore[comparison-overlap] @@ -297,11 +297,11 @@ async def test_pydantic_unknown_field(use_async: bool) -> None: @pytest.mark.asyncio async def test_pydantic_mismatched_types(use_async: bool) -> None: model = MyModel.construct(foo=True) - if PYDANTIC_V2: + if PYDANTIC_V1: + params = await transform(model, Any, use_async) + else: with pytest.warns(UserWarning): params = await transform(model, Any, use_async) - else: - params = await transform(model, Any, use_async) assert cast(Any, params) == {"foo": True} @@ -309,11 +309,11 @@ async def test_pydantic_mismatched_types(use_async: bool) -> None: @pytest.mark.asyncio async def test_pydantic_mismatched_object_type(use_async: bool) -> None: model = MyModel.construct(foo=MyModel.construct(hello="world")) - if PYDANTIC_V2: + if PYDANTIC_V1: + params = await transform(model, Any, use_async) + else: with pytest.warns(UserWarning): params = await transform(model, Any, use_async) - else: - params = await transform(model, Any, use_async) assert cast(Any, params) == {"foo": {"hello": "world"}} diff --git a/tests/test_utils/test_datetime_parse.py b/tests/test_utils/test_datetime_parse.py new file mode 100644 index 0000000..7363e95 --- /dev/null +++ b/tests/test_utils/test_datetime_parse.py @@ -0,0 +1,110 @@ +""" +Copied from https://github.com/pydantic/pydantic/blob/v1.10.22/tests/test_datetime_parse.py +with modifications so it works without pydantic v1 imports. +""" + +from typing import Type, Union +from datetime import date, datetime, timezone, timedelta + +import pytest + +from opencode_ai._utils import parse_date, parse_datetime + + +def create_tz(minutes: int) -> timezone: + return timezone(timedelta(minutes=minutes)) + + +@pytest.mark.parametrize( + "value,result", + [ + # Valid inputs + ("1494012444.883309", date(2017, 5, 5)), + (b"1494012444.883309", date(2017, 5, 5)), + (1_494_012_444.883_309, date(2017, 5, 5)), + ("1494012444", date(2017, 5, 5)), + (1_494_012_444, date(2017, 5, 5)), + (0, date(1970, 1, 1)), + ("2012-04-23", date(2012, 4, 23)), + (b"2012-04-23", date(2012, 4, 23)), + ("2012-4-9", date(2012, 4, 9)), + (date(2012, 4, 9), date(2012, 4, 9)), + (datetime(2012, 4, 9, 12, 15), date(2012, 4, 9)), + # Invalid inputs + ("x20120423", ValueError), + ("2012-04-56", ValueError), + (19_999_999_999, date(2603, 10, 11)), # just before watershed + (20_000_000_001, date(1970, 8, 20)), # just after watershed + (1_549_316_052, date(2019, 2, 4)), # nowish in s + (1_549_316_052_104, date(2019, 2, 4)), # nowish in ms + (1_549_316_052_104_324, date(2019, 2, 4)), # nowish in μs + (1_549_316_052_104_324_096, date(2019, 2, 4)), # nowish in ns + ("infinity", date(9999, 12, 31)), + ("inf", date(9999, 12, 31)), + (float("inf"), date(9999, 12, 31)), + ("infinity ", date(9999, 12, 31)), + (int("1" + "0" * 100), date(9999, 12, 31)), + (1e1000, date(9999, 12, 31)), + ("-infinity", date(1, 1, 1)), + ("-inf", date(1, 1, 1)), + ("nan", ValueError), + ], +) +def test_date_parsing(value: Union[str, bytes, int, float], result: Union[date, Type[Exception]]) -> None: + if type(result) == type and issubclass(result, Exception): # pyright: ignore[reportUnnecessaryIsInstance] + with pytest.raises(result): + parse_date(value) + else: + assert parse_date(value) == result + + +@pytest.mark.parametrize( + "value,result", + [ + # Valid inputs + # values in seconds + ("1494012444.883309", datetime(2017, 5, 5, 19, 27, 24, 883_309, tzinfo=timezone.utc)), + (1_494_012_444.883_309, datetime(2017, 5, 5, 19, 27, 24, 883_309, tzinfo=timezone.utc)), + ("1494012444", datetime(2017, 5, 5, 19, 27, 24, tzinfo=timezone.utc)), + (b"1494012444", datetime(2017, 5, 5, 19, 27, 24, tzinfo=timezone.utc)), + (1_494_012_444, datetime(2017, 5, 5, 19, 27, 24, tzinfo=timezone.utc)), + # values in ms + ("1494012444000.883309", datetime(2017, 5, 5, 19, 27, 24, 883, tzinfo=timezone.utc)), + ("-1494012444000.883309", datetime(1922, 8, 29, 4, 32, 35, 999117, tzinfo=timezone.utc)), + (1_494_012_444_000, datetime(2017, 5, 5, 19, 27, 24, tzinfo=timezone.utc)), + ("2012-04-23T09:15:00", datetime(2012, 4, 23, 9, 15)), + ("2012-4-9 4:8:16", datetime(2012, 4, 9, 4, 8, 16)), + ("2012-04-23T09:15:00Z", datetime(2012, 4, 23, 9, 15, 0, 0, timezone.utc)), + ("2012-4-9 4:8:16-0320", datetime(2012, 4, 9, 4, 8, 16, 0, create_tz(-200))), + ("2012-04-23T10:20:30.400+02:30", datetime(2012, 4, 23, 10, 20, 30, 400_000, create_tz(150))), + ("2012-04-23T10:20:30.400+02", datetime(2012, 4, 23, 10, 20, 30, 400_000, create_tz(120))), + ("2012-04-23T10:20:30.400-02", datetime(2012, 4, 23, 10, 20, 30, 400_000, create_tz(-120))), + (b"2012-04-23T10:20:30.400-02", datetime(2012, 4, 23, 10, 20, 30, 400_000, create_tz(-120))), + (datetime(2017, 5, 5), datetime(2017, 5, 5)), + (0, datetime(1970, 1, 1, 0, 0, 0, tzinfo=timezone.utc)), + # Invalid inputs + ("x20120423091500", ValueError), + ("2012-04-56T09:15:90", ValueError), + ("2012-04-23T11:05:00-25:00", ValueError), + (19_999_999_999, datetime(2603, 10, 11, 11, 33, 19, tzinfo=timezone.utc)), # just before watershed + (20_000_000_001, datetime(1970, 8, 20, 11, 33, 20, 1000, tzinfo=timezone.utc)), # just after watershed + (1_549_316_052, datetime(2019, 2, 4, 21, 34, 12, 0, tzinfo=timezone.utc)), # nowish in s + (1_549_316_052_104, datetime(2019, 2, 4, 21, 34, 12, 104_000, tzinfo=timezone.utc)), # nowish in ms + (1_549_316_052_104_324, datetime(2019, 2, 4, 21, 34, 12, 104_324, tzinfo=timezone.utc)), # nowish in μs + (1_549_316_052_104_324_096, datetime(2019, 2, 4, 21, 34, 12, 104_324, tzinfo=timezone.utc)), # nowish in ns + ("infinity", datetime(9999, 12, 31, 23, 59, 59, 999999)), + ("inf", datetime(9999, 12, 31, 23, 59, 59, 999999)), + ("inf ", datetime(9999, 12, 31, 23, 59, 59, 999999)), + (1e50, datetime(9999, 12, 31, 23, 59, 59, 999999)), + (float("inf"), datetime(9999, 12, 31, 23, 59, 59, 999999)), + ("-infinity", datetime(1, 1, 1, 0, 0)), + ("-inf", datetime(1, 1, 1, 0, 0)), + ("nan", ValueError), + ], +) +def test_datetime_parsing(value: Union[str, bytes, int, float], result: Union[datetime, Type[Exception]]) -> None: + if type(result) == type and issubclass(result, Exception): # pyright: ignore[reportUnnecessaryIsInstance] + with pytest.raises(result): + parse_datetime(value) + else: + assert parse_datetime(value) == result diff --git a/tests/utils.py b/tests/utils.py index 48e8c0f..bf5bda6 100644 --- a/tests/utils.py +++ b/tests/utils.py @@ -19,7 +19,7 @@ is_annotated_type, is_type_alias_type, ) -from opencode_ai._compat import PYDANTIC_V2, field_outer_type, get_model_fields +from opencode_ai._compat import PYDANTIC_V1, field_outer_type, get_model_fields from opencode_ai._models import BaseModel BaseModelT = TypeVar("BaseModelT", bound=BaseModel) @@ -28,12 +28,12 @@ def assert_matches_model(model: type[BaseModelT], value: BaseModelT, *, path: list[str]) -> bool: for name, field in get_model_fields(model).items(): field_value = getattr(value, name) - if PYDANTIC_V2: - allow_none = False - else: + if PYDANTIC_V1: # in v1 nullability was structured differently # https://docs.pydantic.dev/2.0/migration/#required-optional-and-nullable-fields allow_none = getattr(field, "allow_none", False) + else: + allow_none = False assert_matches_type( field_outer_type(field), From 273dffa4ac0653ed7bdb5455af4a5f640c48f91c Mon Sep 17 00:00:00 2001 From: "stainless-app[bot]" <142633134+stainless-app[bot]@users.noreply.github.com> Date: Fri, 5 Sep 2025 03:17:16 +0000 Subject: [PATCH 04/20] chore(internal): move mypy configurations to `pyproject.toml` file --- mypy.ini | 50 ------------------------------------------------ pyproject.toml | 52 ++++++++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 52 insertions(+), 50 deletions(-) delete mode 100644 mypy.ini diff --git a/mypy.ini b/mypy.ini deleted file mode 100644 index 34af79f..0000000 --- a/mypy.ini +++ /dev/null @@ -1,50 +0,0 @@ -[mypy] -pretty = True -show_error_codes = True - -# Exclude _files.py because mypy isn't smart enough to apply -# the correct type narrowing and as this is an internal module -# it's fine to just use Pyright. -# -# We also exclude our `tests` as mypy doesn't always infer -# types correctly and Pyright will still catch any type errors. -exclude = ^(src/opencode_ai/_files\.py|_dev/.*\.py|tests/.*)$ - -strict_equality = True -implicit_reexport = True -check_untyped_defs = True -no_implicit_optional = True - -warn_return_any = True -warn_unreachable = True -warn_unused_configs = True - -# Turn these options off as it could cause conflicts -# with the Pyright options. -warn_unused_ignores = False -warn_redundant_casts = False - -disallow_any_generics = True -disallow_untyped_defs = True -disallow_untyped_calls = True -disallow_subclassing_any = True -disallow_incomplete_defs = True -disallow_untyped_decorators = True -cache_fine_grained = True - -# By default, mypy reports an error if you assign a value to the result -# of a function call that doesn't return anything. We do this in our test -# cases: -# ``` -# result = ... -# assert result is None -# ``` -# Changing this codegen to make mypy happy would increase complexity -# and would not be worth it. -disable_error_code = func-returns-value,overload-cannot-match - -# https://github.com/python/mypy/issues/12162 -[mypy.overrides] -module = "black.files.*" -ignore_errors = true -ignore_missing_imports = true diff --git a/pyproject.toml b/pyproject.toml index f5c99e3..b5df221 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -157,6 +157,58 @@ reportOverlappingOverload = false reportImportCycles = false reportPrivateUsage = false +[tool.mypy] +pretty = true +show_error_codes = true + +# Exclude _files.py because mypy isn't smart enough to apply +# the correct type narrowing and as this is an internal module +# it's fine to just use Pyright. +# +# We also exclude our `tests` as mypy doesn't always infer +# types correctly and Pyright will still catch any type errors. +exclude = ['src/opencode_ai/_files.py', '_dev/.*.py', 'tests/.*'] + +strict_equality = true +implicit_reexport = true +check_untyped_defs = true +no_implicit_optional = true + +warn_return_any = true +warn_unreachable = true +warn_unused_configs = true + +# Turn these options off as it could cause conflicts +# with the Pyright options. +warn_unused_ignores = false +warn_redundant_casts = false + +disallow_any_generics = true +disallow_untyped_defs = true +disallow_untyped_calls = true +disallow_subclassing_any = true +disallow_incomplete_defs = true +disallow_untyped_decorators = true +cache_fine_grained = true + +# By default, mypy reports an error if you assign a value to the result +# of a function call that doesn't return anything. We do this in our test +# cases: +# ``` +# result = ... +# assert result is None +# ``` +# Changing this codegen to make mypy happy would increase complexity +# and would not be worth it. +disable_error_code = "func-returns-value,overload-cannot-match" + +# https://github.com/python/mypy/issues/12162 +[[tool.mypy.overrides]] +module = "black.files.*" +ignore_errors = true +ignore_missing_imports = true + + [tool.ruff] line-length = 120 output-format = "grouped" From 0a47e601d1cbcf879694ce130d9f2e1729a728bb Mon Sep 17 00:00:00 2001 From: "stainless-app[bot]" <142633134+stainless-app[bot]@users.noreply.github.com> Date: Sat, 6 Sep 2025 03:38:23 +0000 Subject: [PATCH 05/20] chore(tests): simplify `get_platform` test `nest_asyncio` is archived and broken on some platforms so it's not worth keeping in our test suite. --- pyproject.toml | 1 - requirements-dev.lock | 1 - tests/test_client.py | 53 +++++-------------------------------------- 3 files changed, 6 insertions(+), 49 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index b5df221..39cc41d 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -56,7 +56,6 @@ dev-dependencies = [ "dirty-equals>=0.6.0", "importlib-metadata>=6.7.0", "rich>=13.7.1", - "nest_asyncio==1.6.0", "pytest-xdist>=3.6.1", ] diff --git a/requirements-dev.lock b/requirements-dev.lock index d88557d..5f46ada 100644 --- a/requirements-dev.lock +++ b/requirements-dev.lock @@ -75,7 +75,6 @@ multidict==6.4.4 mypy==1.14.1 mypy-extensions==1.0.0 # via mypy -nest-asyncio==1.6.0 nodeenv==1.8.0 # via pyright nox==2023.4.22 diff --git a/tests/test_client.py b/tests/test_client.py index f8225c7..fede131 100644 --- a/tests/test_client.py +++ b/tests/test_client.py @@ -6,13 +6,10 @@ import os import sys import json -import time import asyncio import inspect -import subprocess import tracemalloc from typing import Any, Union, cast -from textwrap import dedent from unittest import mock from typing_extensions import Literal @@ -23,6 +20,7 @@ from opencode_ai import Opencode, AsyncOpencode, APIResponseValidationError from opencode_ai._types import Omit +from opencode_ai._utils import asyncify from opencode_ai._models import BaseModel, FinalRequestOptions from opencode_ai._streaming import Stream, AsyncStream from opencode_ai._exceptions import APIStatusError, APITimeoutError, APIResponseValidationError @@ -30,8 +28,10 @@ DEFAULT_TIMEOUT, HTTPX_DEFAULT_TIMEOUT, BaseClient, + OtherPlatform, DefaultHttpxClient, DefaultAsyncHttpxClient, + get_platform, make_request_options, ) @@ -1566,50 +1566,9 @@ def retry_handler(_request: httpx.Request) -> httpx.Response: assert response.http_request.headers.get("x-stainless-retry-count") == "42" - def test_get_platform(self) -> None: - # A previous implementation of asyncify could leave threads unterminated when - # used with nest_asyncio. - # - # Since nest_asyncio.apply() is global and cannot be un-applied, this - # test is run in a separate process to avoid affecting other tests. - test_code = dedent(""" - import asyncio - import nest_asyncio - import threading - - from opencode_ai._utils import asyncify - from opencode_ai._base_client import get_platform - - async def test_main() -> None: - result = await asyncify(get_platform)() - print(result) - for thread in threading.enumerate(): - print(thread.name) - - nest_asyncio.apply() - asyncio.run(test_main()) - """) - with subprocess.Popen( - [sys.executable, "-c", test_code], - text=True, - ) as process: - timeout = 10 # seconds - - start_time = time.monotonic() - while True: - return_code = process.poll() - if return_code is not None: - if return_code != 0: - raise AssertionError("calling get_platform using asyncify resulted in a non-zero exit code") - - # success - break - - if time.monotonic() - start_time > timeout: - process.kill() - raise AssertionError("calling get_platform using asyncify resulted in a hung process") - - time.sleep(0.1) + async def test_get_platform(self) -> None: + platform = await asyncify(get_platform)() + assert isinstance(platform, (str, OtherPlatform)) async def test_proxy_environment_variables(self, monkeypatch: pytest.MonkeyPatch) -> None: # Test that the proxy environment variables are set correctly From 6ae1aed3aab37a20707d677b943bdd8c5ee3d4bf Mon Sep 17 00:00:00 2001 From: "stainless-app[bot]" <142633134+stainless-app[bot]@users.noreply.github.com> Date: Wed, 17 Sep 2025 02:29:36 +0000 Subject: [PATCH 06/20] chore(internal): update pydantic dependency --- requirements-dev.lock | 7 +++++-- requirements.lock | 7 +++++-- src/opencode_ai/_models.py | 14 ++++++++++---- 3 files changed, 20 insertions(+), 8 deletions(-) diff --git a/requirements-dev.lock b/requirements-dev.lock index 5f46ada..75fb82c 100644 --- a/requirements-dev.lock +++ b/requirements-dev.lock @@ -88,9 +88,9 @@ pluggy==1.5.0 propcache==0.3.1 # via aiohttp # via yarl -pydantic==2.10.3 +pydantic==2.11.9 # via opencode-ai -pydantic-core==2.27.1 +pydantic-core==2.33.2 # via pydantic pygments==2.18.0 # via rich @@ -126,6 +126,9 @@ typing-extensions==4.12.2 # via pydantic # via pydantic-core # via pyright + # via typing-inspection +typing-inspection==0.4.1 + # via pydantic virtualenv==20.24.5 # via nox yarl==1.20.0 diff --git a/requirements.lock b/requirements.lock index 0ab22ae..7537c91 100644 --- a/requirements.lock +++ b/requirements.lock @@ -55,9 +55,9 @@ multidict==6.4.4 propcache==0.3.1 # via aiohttp # via yarl -pydantic==2.10.3 +pydantic==2.11.9 # via opencode-ai -pydantic-core==2.27.1 +pydantic-core==2.33.2 # via pydantic sniffio==1.3.0 # via anyio @@ -68,5 +68,8 @@ typing-extensions==4.12.2 # via opencode-ai # via pydantic # via pydantic-core + # via typing-inspection +typing-inspection==0.4.1 + # via pydantic yarl==1.20.0 # via aiohttp diff --git a/src/opencode_ai/_models.py b/src/opencode_ai/_models.py index 3a6017e..6a3cd1d 100644 --- a/src/opencode_ai/_models.py +++ b/src/opencode_ai/_models.py @@ -256,7 +256,7 @@ def model_dump( mode: Literal["json", "python"] | str = "python", include: IncEx | None = None, exclude: IncEx | None = None, - by_alias: bool = False, + by_alias: bool | None = None, exclude_unset: bool = False, exclude_defaults: bool = False, exclude_none: bool = False, @@ -264,6 +264,7 @@ def model_dump( warnings: bool | Literal["none", "warn", "error"] = True, context: dict[str, Any] | None = None, serialize_as_any: bool = False, + fallback: Callable[[Any], Any] | None = None, ) -> dict[str, Any]: """Usage docs: https://docs.pydantic.dev/2.4/concepts/serialization/#modelmodel_dump @@ -295,10 +296,12 @@ def model_dump( raise ValueError("context is only supported in Pydantic v2") if serialize_as_any != False: raise ValueError("serialize_as_any is only supported in Pydantic v2") + if fallback is not None: + raise ValueError("fallback is only supported in Pydantic v2") dumped = super().dict( # pyright: ignore[reportDeprecated] include=include, exclude=exclude, - by_alias=by_alias, + by_alias=by_alias if by_alias is not None else False, exclude_unset=exclude_unset, exclude_defaults=exclude_defaults, exclude_none=exclude_none, @@ -313,13 +316,14 @@ def model_dump_json( indent: int | None = None, include: IncEx | None = None, exclude: IncEx | None = None, - by_alias: bool = False, + by_alias: bool | None = None, exclude_unset: bool = False, exclude_defaults: bool = False, exclude_none: bool = False, round_trip: bool = False, warnings: bool | Literal["none", "warn", "error"] = True, context: dict[str, Any] | None = None, + fallback: Callable[[Any], Any] | None = None, serialize_as_any: bool = False, ) -> str: """Usage docs: https://docs.pydantic.dev/2.4/concepts/serialization/#modelmodel_dump_json @@ -348,11 +352,13 @@ def model_dump_json( raise ValueError("context is only supported in Pydantic v2") if serialize_as_any != False: raise ValueError("serialize_as_any is only supported in Pydantic v2") + if fallback is not None: + raise ValueError("fallback is only supported in Pydantic v2") return super().json( # type: ignore[reportDeprecated] indent=indent, include=include, exclude=exclude, - by_alias=by_alias, + by_alias=by_alias if by_alias is not None else False, exclude_unset=exclude_unset, exclude_defaults=exclude_defaults, exclude_none=exclude_none, From ed48f59b5316e425d3ae13e9c493848b37ffc03e Mon Sep 17 00:00:00 2001 From: "stainless-app[bot]" <142633134+stainless-app[bot]@users.noreply.github.com> Date: Fri, 19 Sep 2025 02:34:35 +0000 Subject: [PATCH 07/20] chore(types): change optional parameter type from NotGiven to Omit --- src/opencode_ai/__init__.py | 4 +- src/opencode_ai/_base_client.py | 18 +- src/opencode_ai/_client.py | 16 +- src/opencode_ai/_qs.py | 14 +- src/opencode_ai/_types.py | 29 ++- src/opencode_ai/_utils/_transform.py | 4 +- src/opencode_ai/_utils/_utils.py | 8 +- src/opencode_ai/resources/agent.py | 10 +- src/opencode_ai/resources/app.py | 22 +- src/opencode_ai/resources/command.py | 10 +- src/opencode_ai/resources/config.py | 10 +- src/opencode_ai/resources/event.py | 10 +- src/opencode_ai/resources/file.py | 26 +-- src/opencode_ai/resources/find.py | 26 +-- src/opencode_ai/resources/path.py | 10 +- src/opencode_ai/resources/project.py | 18 +- .../resources/session/permissions.py | 10 +- src/opencode_ai/resources/session/session.py | 194 +++++++++--------- src/opencode_ai/resources/tui.py | 78 +++---- tests/test_transform.py | 11 +- 20 files changed, 272 insertions(+), 256 deletions(-) diff --git a/src/opencode_ai/__init__.py b/src/opencode_ai/__init__.py index 7d8c13c..7debe41 100644 --- a/src/opencode_ai/__init__.py +++ b/src/opencode_ai/__init__.py @@ -3,7 +3,7 @@ import typing as _t from . import types -from ._types import NOT_GIVEN, Omit, NoneType, NotGiven, Transport, ProxiesTypes +from ._types import NOT_GIVEN, Omit, NoneType, NotGiven, Transport, ProxiesTypes, omit, not_given from ._utils import file_from_path from ._client import ( Client, @@ -48,7 +48,9 @@ "ProxiesTypes", "NotGiven", "NOT_GIVEN", + "not_given", "Omit", + "omit", "OpencodeError", "APIError", "APIStatusError", diff --git a/src/opencode_ai/_base_client.py b/src/opencode_ai/_base_client.py index f7ce0f3..c8a1977 100644 --- a/src/opencode_ai/_base_client.py +++ b/src/opencode_ai/_base_client.py @@ -42,7 +42,6 @@ from ._qs import Querystring from ._files import to_httpx_files, async_to_httpx_files from ._types import ( - NOT_GIVEN, Body, Omit, Query, @@ -57,6 +56,7 @@ RequestOptions, HttpxRequestFiles, ModelBuilderProtocol, + not_given, ) from ._utils import is_dict, is_list, asyncify, is_given, lru_cache, is_mapping from ._compat import PYDANTIC_V1, model_copy, model_dump @@ -145,9 +145,9 @@ def __init__( def __init__( self, *, - url: URL | NotGiven = NOT_GIVEN, - json: Body | NotGiven = NOT_GIVEN, - params: Query | NotGiven = NOT_GIVEN, + url: URL | NotGiven = not_given, + json: Body | NotGiven = not_given, + params: Query | NotGiven = not_given, ) -> None: self.url = url self.json = json @@ -595,7 +595,7 @@ def _maybe_override_cast_to(self, cast_to: type[ResponseT], options: FinalReques # we internally support defining a temporary header to override the # default `cast_to` type for use with `.with_raw_response` and `.with_streaming_response` # see _response.py for implementation details - override_cast_to = headers.pop(OVERRIDE_CAST_TO_HEADER, NOT_GIVEN) + override_cast_to = headers.pop(OVERRIDE_CAST_TO_HEADER, not_given) if is_given(override_cast_to): options.headers = headers return cast(Type[ResponseT], override_cast_to) @@ -825,7 +825,7 @@ def __init__( version: str, base_url: str | URL, max_retries: int = DEFAULT_MAX_RETRIES, - timeout: float | Timeout | None | NotGiven = NOT_GIVEN, + timeout: float | Timeout | None | NotGiven = not_given, http_client: httpx.Client | None = None, custom_headers: Mapping[str, str] | None = None, custom_query: Mapping[str, object] | None = None, @@ -1356,7 +1356,7 @@ def __init__( base_url: str | URL, _strict_response_validation: bool, max_retries: int = DEFAULT_MAX_RETRIES, - timeout: float | Timeout | None | NotGiven = NOT_GIVEN, + timeout: float | Timeout | None | NotGiven = not_given, http_client: httpx.AsyncClient | None = None, custom_headers: Mapping[str, str] | None = None, custom_query: Mapping[str, object] | None = None, @@ -1818,8 +1818,8 @@ def make_request_options( extra_query: Query | None = None, extra_body: Body | None = None, idempotency_key: str | None = None, - timeout: float | httpx.Timeout | None | NotGiven = NOT_GIVEN, - post_parser: PostParser | NotGiven = NOT_GIVEN, + timeout: float | httpx.Timeout | None | NotGiven = not_given, + post_parser: PostParser | NotGiven = not_given, ) -> RequestOptions: """Create a dict of type RequestOptions without keys of NotGiven values.""" options: RequestOptions = {} diff --git a/src/opencode_ai/_client.py b/src/opencode_ai/_client.py index 133e1e5..3a34a0a 100644 --- a/src/opencode_ai/_client.py +++ b/src/opencode_ai/_client.py @@ -3,7 +3,7 @@ from __future__ import annotations import os -from typing import Any, Union, Mapping +from typing import Any, Mapping from typing_extensions import Self, override import httpx @@ -11,13 +11,13 @@ from . import _exceptions from ._qs import Querystring from ._types import ( - NOT_GIVEN, Omit, Timeout, NotGiven, Transport, ProxiesTypes, RequestOptions, + not_given, ) from ._utils import is_given, get_async_library from ._version import __version__ @@ -64,7 +64,7 @@ def __init__( self, *, base_url: str | httpx.URL | None = None, - timeout: Union[float, Timeout, None, NotGiven] = NOT_GIVEN, + timeout: float | Timeout | None | NotGiven = not_given, max_retries: int = DEFAULT_MAX_RETRIES, default_headers: Mapping[str, str] | None = None, default_query: Mapping[str, object] | None = None, @@ -133,9 +133,9 @@ def copy( self, *, base_url: str | httpx.URL | None = None, - timeout: float | Timeout | None | NotGiven = NOT_GIVEN, + timeout: float | Timeout | None | NotGiven = not_given, http_client: httpx.Client | None = None, - max_retries: int | NotGiven = NOT_GIVEN, + max_retries: int | NotGiven = not_given, default_headers: Mapping[str, str] | None = None, set_default_headers: Mapping[str, str] | None = None, default_query: Mapping[str, object] | None = None, @@ -233,7 +233,7 @@ def __init__( self, *, base_url: str | httpx.URL | None = None, - timeout: Union[float, Timeout, None, NotGiven] = NOT_GIVEN, + timeout: float | Timeout | None | NotGiven = not_given, max_retries: int = DEFAULT_MAX_RETRIES, default_headers: Mapping[str, str] | None = None, default_query: Mapping[str, object] | None = None, @@ -302,9 +302,9 @@ def copy( self, *, base_url: str | httpx.URL | None = None, - timeout: float | Timeout | None | NotGiven = NOT_GIVEN, + timeout: float | Timeout | None | NotGiven = not_given, http_client: httpx.AsyncClient | None = None, - max_retries: int | NotGiven = NOT_GIVEN, + max_retries: int | NotGiven = not_given, default_headers: Mapping[str, str] | None = None, set_default_headers: Mapping[str, str] | None = None, default_query: Mapping[str, object] | None = None, diff --git a/src/opencode_ai/_qs.py b/src/opencode_ai/_qs.py index 274320c..ada6fd3 100644 --- a/src/opencode_ai/_qs.py +++ b/src/opencode_ai/_qs.py @@ -4,7 +4,7 @@ from urllib.parse import parse_qs, urlencode from typing_extensions import Literal, get_args -from ._types import NOT_GIVEN, NotGiven, NotGivenOr +from ._types import NotGiven, not_given from ._utils import flatten _T = TypeVar("_T") @@ -41,8 +41,8 @@ def stringify( self, params: Params, *, - array_format: NotGivenOr[ArrayFormat] = NOT_GIVEN, - nested_format: NotGivenOr[NestedFormat] = NOT_GIVEN, + array_format: ArrayFormat | NotGiven = not_given, + nested_format: NestedFormat | NotGiven = not_given, ) -> str: return urlencode( self.stringify_items( @@ -56,8 +56,8 @@ def stringify_items( self, params: Params, *, - array_format: NotGivenOr[ArrayFormat] = NOT_GIVEN, - nested_format: NotGivenOr[NestedFormat] = NOT_GIVEN, + array_format: ArrayFormat | NotGiven = not_given, + nested_format: NestedFormat | NotGiven = not_given, ) -> list[tuple[str, str]]: opts = Options( qs=self, @@ -143,8 +143,8 @@ def __init__( self, qs: Querystring = _qs, *, - array_format: NotGivenOr[ArrayFormat] = NOT_GIVEN, - nested_format: NotGivenOr[NestedFormat] = NOT_GIVEN, + array_format: ArrayFormat | NotGiven = not_given, + nested_format: NestedFormat | NotGiven = not_given, ) -> None: self.array_format = qs.array_format if isinstance(array_format, NotGiven) else array_format self.nested_format = qs.nested_format if isinstance(nested_format, NotGiven) else nested_format diff --git a/src/opencode_ai/_types.py b/src/opencode_ai/_types.py index 5dfb606..97c5da7 100644 --- a/src/opencode_ai/_types.py +++ b/src/opencode_ai/_types.py @@ -117,18 +117,21 @@ class RequestOptions(TypedDict, total=False): # Sentinel class used until PEP 0661 is accepted class NotGiven: """ - A sentinel singleton class used to distinguish omitted keyword arguments - from those passed in with the value None (which may have different behavior). + For parameters with a meaningful None value, we need to distinguish between + the user explicitly passing None, and the user not passing the parameter at + all. + + User code shouldn't need to use not_given directly. For example: ```py - def get(timeout: Union[int, NotGiven, None] = NotGiven()) -> Response: ... + def create(timeout: Timeout | None | NotGiven = not_given): ... - get(timeout=1) # 1s timeout - get(timeout=None) # No timeout - get() # Default timeout behavior, which may not be statically known at the method definition. + create(timeout=1) # 1s timeout + create(timeout=None) # No timeout + create() # Default timeout behavior ``` """ @@ -140,13 +143,14 @@ def __repr__(self) -> str: return "NOT_GIVEN" -NotGivenOr = Union[_T, NotGiven] +not_given = NotGiven() +# for backwards compatibility: NOT_GIVEN = NotGiven() class Omit: - """In certain situations you need to be able to represent a case where a default value has - to be explicitly removed and `None` is not an appropriate substitute, for example: + """ + To explicitly omit something from being sent in a request, use `omit`. ```py # as the default `Content-Type` header is `application/json` that will be sent @@ -156,8 +160,8 @@ class Omit: # to look something like: 'multipart/form-data; boundary=0d8382fcf5f8c3be01ca2e11002d2983' client.post(..., headers={"Content-Type": "multipart/form-data"}) - # instead you can remove the default `application/json` header by passing Omit - client.post(..., headers={"Content-Type": Omit()}) + # instead you can remove the default `application/json` header by passing omit + client.post(..., headers={"Content-Type": omit}) ``` """ @@ -165,6 +169,9 @@ def __bool__(self) -> Literal[False]: return False +omit = Omit() + + @runtime_checkable class ModelBuilderProtocol(Protocol): @classmethod diff --git a/src/opencode_ai/_utils/_transform.py b/src/opencode_ai/_utils/_transform.py index c19124f..5207549 100644 --- a/src/opencode_ai/_utils/_transform.py +++ b/src/opencode_ai/_utils/_transform.py @@ -268,7 +268,7 @@ def _transform_typeddict( annotations = get_type_hints(expected_type, include_extras=True) for key, value in data.items(): if not is_given(value): - # we don't need to include `NotGiven` values here as they'll + # we don't need to include omitted values here as they'll # be stripped out before the request is sent anyway continue @@ -434,7 +434,7 @@ async def _async_transform_typeddict( annotations = get_type_hints(expected_type, include_extras=True) for key, value in data.items(): if not is_given(value): - # we don't need to include `NotGiven` values here as they'll + # we don't need to include omitted values here as they'll # be stripped out before the request is sent anyway continue diff --git a/src/opencode_ai/_utils/_utils.py b/src/opencode_ai/_utils/_utils.py index f081859..50d5926 100644 --- a/src/opencode_ai/_utils/_utils.py +++ b/src/opencode_ai/_utils/_utils.py @@ -21,7 +21,7 @@ import sniffio -from .._types import NotGiven, FileTypes, NotGivenOr, HeadersLike +from .._types import Omit, NotGiven, FileTypes, HeadersLike _T = TypeVar("_T") _TupleT = TypeVar("_TupleT", bound=Tuple[object, ...]) @@ -63,7 +63,7 @@ def _extract_items( try: key = path[index] except IndexError: - if isinstance(obj, NotGiven): + if not is_given(obj): # no value was provided - we can safely ignore return [] @@ -126,8 +126,8 @@ def _extract_items( return [] -def is_given(obj: NotGivenOr[_T]) -> TypeGuard[_T]: - return not isinstance(obj, NotGiven) +def is_given(obj: _T | NotGiven | Omit) -> TypeGuard[_T]: + return not isinstance(obj, NotGiven) and not isinstance(obj, Omit) # Type safe methods for narrowing types with TypeVars. diff --git a/src/opencode_ai/resources/agent.py b/src/opencode_ai/resources/agent.py index 084f87e..32bcfbf 100644 --- a/src/opencode_ai/resources/agent.py +++ b/src/opencode_ai/resources/agent.py @@ -5,7 +5,7 @@ import httpx from ..types import agent_list_params -from .._types import NOT_GIVEN, Body, Query, Headers, NotGiven +from .._types import Body, Omit, Query, Headers, NotGiven, omit, not_given from .._utils import maybe_transform, async_maybe_transform from .._compat import cached_property from .._resource import SyncAPIResource, AsyncAPIResource @@ -44,13 +44,13 @@ def with_streaming_response(self) -> AgentResourceWithStreamingResponse: def list( self, *, - directory: str | NotGiven = NOT_GIVEN, + directory: str | Omit = omit, # Use the following arguments if you need to pass additional parameters to the API that aren't available via kwargs. # The extra values given here take precedence over values defined on the client or passed to this method. extra_headers: Headers | None = None, extra_query: Query | None = None, extra_body: Body | None = None, - timeout: float | httpx.Timeout | None | NotGiven = NOT_GIVEN, + timeout: float | httpx.Timeout | None | NotGiven = not_given, ) -> AgentListResponse: """ List all agents @@ -100,13 +100,13 @@ def with_streaming_response(self) -> AsyncAgentResourceWithStreamingResponse: async def list( self, *, - directory: str | NotGiven = NOT_GIVEN, + directory: str | Omit = omit, # Use the following arguments if you need to pass additional parameters to the API that aren't available via kwargs. # The extra values given here take precedence over values defined on the client or passed to this method. extra_headers: Headers | None = None, extra_query: Query | None = None, extra_body: Body | None = None, - timeout: float | httpx.Timeout | None | NotGiven = NOT_GIVEN, + timeout: float | httpx.Timeout | None | NotGiven = not_given, ) -> AgentListResponse: """ List all agents diff --git a/src/opencode_ai/resources/app.py b/src/opencode_ai/resources/app.py index 60ff06e..2c139b2 100644 --- a/src/opencode_ai/resources/app.py +++ b/src/opencode_ai/resources/app.py @@ -8,7 +8,7 @@ import httpx from ..types import app_log_params, app_providers_params -from .._types import NOT_GIVEN, Body, Query, Headers, NotGiven +from .._types import Body, Omit, Query, Headers, NotGiven, omit, not_given from .._utils import maybe_transform, async_maybe_transform from .._compat import cached_property from .._resource import SyncAPIResource, AsyncAPIResource @@ -51,14 +51,14 @@ def log( level: Literal["debug", "info", "error", "warn"], message: str, service: str, - directory: str | NotGiven = NOT_GIVEN, - extra: Dict[str, object] | NotGiven = NOT_GIVEN, + directory: str | Omit = omit, + extra: Dict[str, object] | Omit = omit, # Use the following arguments if you need to pass additional parameters to the API that aren't available via kwargs. # The extra values given here take precedence over values defined on the client or passed to this method. extra_headers: Headers | None = None, extra_query: Query | None = None, extra_body: Body | None = None, - timeout: float | httpx.Timeout | None | NotGiven = NOT_GIVEN, + timeout: float | httpx.Timeout | None | NotGiven = not_given, ) -> AppLogResponse: """ Write a log entry to the server logs @@ -104,13 +104,13 @@ def log( def providers( self, *, - directory: str | NotGiven = NOT_GIVEN, + directory: str | Omit = omit, # Use the following arguments if you need to pass additional parameters to the API that aren't available via kwargs. # The extra values given here take precedence over values defined on the client or passed to this method. extra_headers: Headers | None = None, extra_query: Query | None = None, extra_body: Body | None = None, - timeout: float | httpx.Timeout | None | NotGiven = NOT_GIVEN, + timeout: float | httpx.Timeout | None | NotGiven = not_given, ) -> AppProvidersResponse: """ List all providers @@ -163,14 +163,14 @@ async def log( level: Literal["debug", "info", "error", "warn"], message: str, service: str, - directory: str | NotGiven = NOT_GIVEN, - extra: Dict[str, object] | NotGiven = NOT_GIVEN, + directory: str | Omit = omit, + extra: Dict[str, object] | Omit = omit, # Use the following arguments if you need to pass additional parameters to the API that aren't available via kwargs. # The extra values given here take precedence over values defined on the client or passed to this method. extra_headers: Headers | None = None, extra_query: Query | None = None, extra_body: Body | None = None, - timeout: float | httpx.Timeout | None | NotGiven = NOT_GIVEN, + timeout: float | httpx.Timeout | None | NotGiven = not_given, ) -> AppLogResponse: """ Write a log entry to the server logs @@ -216,13 +216,13 @@ async def log( async def providers( self, *, - directory: str | NotGiven = NOT_GIVEN, + directory: str | Omit = omit, # Use the following arguments if you need to pass additional parameters to the API that aren't available via kwargs. # The extra values given here take precedence over values defined on the client or passed to this method. extra_headers: Headers | None = None, extra_query: Query | None = None, extra_body: Body | None = None, - timeout: float | httpx.Timeout | None | NotGiven = NOT_GIVEN, + timeout: float | httpx.Timeout | None | NotGiven = not_given, ) -> AppProvidersResponse: """ List all providers diff --git a/src/opencode_ai/resources/command.py b/src/opencode_ai/resources/command.py index 57cfbc1..e31c6b4 100644 --- a/src/opencode_ai/resources/command.py +++ b/src/opencode_ai/resources/command.py @@ -5,7 +5,7 @@ import httpx from ..types import command_list_params -from .._types import NOT_GIVEN, Body, Query, Headers, NotGiven +from .._types import Body, Omit, Query, Headers, NotGiven, omit, not_given from .._utils import maybe_transform, async_maybe_transform from .._compat import cached_property from .._resource import SyncAPIResource, AsyncAPIResource @@ -44,13 +44,13 @@ def with_streaming_response(self) -> CommandResourceWithStreamingResponse: def list( self, *, - directory: str | NotGiven = NOT_GIVEN, + directory: str | Omit = omit, # Use the following arguments if you need to pass additional parameters to the API that aren't available via kwargs. # The extra values given here take precedence over values defined on the client or passed to this method. extra_headers: Headers | None = None, extra_query: Query | None = None, extra_body: Body | None = None, - timeout: float | httpx.Timeout | None | NotGiven = NOT_GIVEN, + timeout: float | httpx.Timeout | None | NotGiven = not_given, ) -> CommandListResponse: """ List all commands @@ -100,13 +100,13 @@ def with_streaming_response(self) -> AsyncCommandResourceWithStreamingResponse: async def list( self, *, - directory: str | NotGiven = NOT_GIVEN, + directory: str | Omit = omit, # Use the following arguments if you need to pass additional parameters to the API that aren't available via kwargs. # The extra values given here take precedence over values defined on the client or passed to this method. extra_headers: Headers | None = None, extra_query: Query | None = None, extra_body: Body | None = None, - timeout: float | httpx.Timeout | None | NotGiven = NOT_GIVEN, + timeout: float | httpx.Timeout | None | NotGiven = not_given, ) -> CommandListResponse: """ List all commands diff --git a/src/opencode_ai/resources/config.py b/src/opencode_ai/resources/config.py index f460338..70c1c59 100644 --- a/src/opencode_ai/resources/config.py +++ b/src/opencode_ai/resources/config.py @@ -5,7 +5,7 @@ import httpx from ..types import config_get_params -from .._types import NOT_GIVEN, Body, Query, Headers, NotGiven +from .._types import Body, Omit, Query, Headers, NotGiven, omit, not_given from .._utils import maybe_transform, async_maybe_transform from .._compat import cached_property from .._resource import SyncAPIResource, AsyncAPIResource @@ -44,13 +44,13 @@ def with_streaming_response(self) -> ConfigResourceWithStreamingResponse: def get( self, *, - directory: str | NotGiven = NOT_GIVEN, + directory: str | Omit = omit, # Use the following arguments if you need to pass additional parameters to the API that aren't available via kwargs. # The extra values given here take precedence over values defined on the client or passed to this method. extra_headers: Headers | None = None, extra_query: Query | None = None, extra_body: Body | None = None, - timeout: float | httpx.Timeout | None | NotGiven = NOT_GIVEN, + timeout: float | httpx.Timeout | None | NotGiven = not_given, ) -> Config: """ Get config info @@ -100,13 +100,13 @@ def with_streaming_response(self) -> AsyncConfigResourceWithStreamingResponse: async def get( self, *, - directory: str | NotGiven = NOT_GIVEN, + directory: str | Omit = omit, # Use the following arguments if you need to pass additional parameters to the API that aren't available via kwargs. # The extra values given here take precedence over values defined on the client or passed to this method. extra_headers: Headers | None = None, extra_query: Query | None = None, extra_body: Body | None = None, - timeout: float | httpx.Timeout | None | NotGiven = NOT_GIVEN, + timeout: float | httpx.Timeout | None | NotGiven = not_given, ) -> Config: """ Get config info diff --git a/src/opencode_ai/resources/event.py b/src/opencode_ai/resources/event.py index 118c557..6c33d60 100644 --- a/src/opencode_ai/resources/event.py +++ b/src/opencode_ai/resources/event.py @@ -7,7 +7,7 @@ import httpx from ..types import event_list_params -from .._types import NOT_GIVEN, Body, Query, Headers, NotGiven +from .._types import Body, Omit, Query, Headers, NotGiven, omit, not_given from .._utils import maybe_transform, async_maybe_transform from .._compat import cached_property from .._resource import SyncAPIResource, AsyncAPIResource @@ -47,13 +47,13 @@ def with_streaming_response(self) -> EventResourceWithStreamingResponse: def list( self, *, - directory: str | NotGiven = NOT_GIVEN, + directory: str | Omit = omit, # Use the following arguments if you need to pass additional parameters to the API that aren't available via kwargs. # The extra values given here take precedence over values defined on the client or passed to this method. extra_headers: Headers | None = None, extra_query: Query | None = None, extra_body: Body | None = None, - timeout: float | httpx.Timeout | None | NotGiven = NOT_GIVEN, + timeout: float | httpx.Timeout | None | NotGiven = not_given, ) -> Stream[EventListResponse]: """ Get events @@ -106,13 +106,13 @@ def with_streaming_response(self) -> AsyncEventResourceWithStreamingResponse: async def list( self, *, - directory: str | NotGiven = NOT_GIVEN, + directory: str | Omit = omit, # Use the following arguments if you need to pass additional parameters to the API that aren't available via kwargs. # The extra values given here take precedence over values defined on the client or passed to this method. extra_headers: Headers | None = None, extra_query: Query | None = None, extra_body: Body | None = None, - timeout: float | httpx.Timeout | None | NotGiven = NOT_GIVEN, + timeout: float | httpx.Timeout | None | NotGiven = not_given, ) -> AsyncStream[EventListResponse]: """ Get events diff --git a/src/opencode_ai/resources/file.py b/src/opencode_ai/resources/file.py index 2e10468..711df13 100644 --- a/src/opencode_ai/resources/file.py +++ b/src/opencode_ai/resources/file.py @@ -5,7 +5,7 @@ import httpx from ..types import file_list_params, file_read_params, file_status_params -from .._types import NOT_GIVEN, Body, Query, Headers, NotGiven +from .._types import Body, Omit, Query, Headers, NotGiven, omit, not_given from .._utils import maybe_transform, async_maybe_transform from .._compat import cached_property from .._resource import SyncAPIResource, AsyncAPIResource @@ -47,13 +47,13 @@ def list( self, *, path: str, - directory: str | NotGiven = NOT_GIVEN, + directory: str | Omit = omit, # Use the following arguments if you need to pass additional parameters to the API that aren't available via kwargs. # The extra values given here take precedence over values defined on the client or passed to this method. extra_headers: Headers | None = None, extra_query: Query | None = None, extra_body: Body | None = None, - timeout: float | httpx.Timeout | None | NotGiven = NOT_GIVEN, + timeout: float | httpx.Timeout | None | NotGiven = not_given, ) -> FileListResponse: """ List files and directories @@ -89,13 +89,13 @@ def read( self, *, path: str, - directory: str | NotGiven = NOT_GIVEN, + directory: str | Omit = omit, # Use the following arguments if you need to pass additional parameters to the API that aren't available via kwargs. # The extra values given here take precedence over values defined on the client or passed to this method. extra_headers: Headers | None = None, extra_query: Query | None = None, extra_body: Body | None = None, - timeout: float | httpx.Timeout | None | NotGiven = NOT_GIVEN, + timeout: float | httpx.Timeout | None | NotGiven = not_given, ) -> FileReadResponse: """ Read a file @@ -130,13 +130,13 @@ def read( def status( self, *, - directory: str | NotGiven = NOT_GIVEN, + directory: str | Omit = omit, # Use the following arguments if you need to pass additional parameters to the API that aren't available via kwargs. # The extra values given here take precedence over values defined on the client or passed to this method. extra_headers: Headers | None = None, extra_query: Query | None = None, extra_body: Body | None = None, - timeout: float | httpx.Timeout | None | NotGiven = NOT_GIVEN, + timeout: float | httpx.Timeout | None | NotGiven = not_given, ) -> FileStatusResponse: """ Get file status @@ -187,13 +187,13 @@ async def list( self, *, path: str, - directory: str | NotGiven = NOT_GIVEN, + directory: str | Omit = omit, # Use the following arguments if you need to pass additional parameters to the API that aren't available via kwargs. # The extra values given here take precedence over values defined on the client or passed to this method. extra_headers: Headers | None = None, extra_query: Query | None = None, extra_body: Body | None = None, - timeout: float | httpx.Timeout | None | NotGiven = NOT_GIVEN, + timeout: float | httpx.Timeout | None | NotGiven = not_given, ) -> FileListResponse: """ List files and directories @@ -229,13 +229,13 @@ async def read( self, *, path: str, - directory: str | NotGiven = NOT_GIVEN, + directory: str | Omit = omit, # Use the following arguments if you need to pass additional parameters to the API that aren't available via kwargs. # The extra values given here take precedence over values defined on the client or passed to this method. extra_headers: Headers | None = None, extra_query: Query | None = None, extra_body: Body | None = None, - timeout: float | httpx.Timeout | None | NotGiven = NOT_GIVEN, + timeout: float | httpx.Timeout | None | NotGiven = not_given, ) -> FileReadResponse: """ Read a file @@ -270,13 +270,13 @@ async def read( async def status( self, *, - directory: str | NotGiven = NOT_GIVEN, + directory: str | Omit = omit, # Use the following arguments if you need to pass additional parameters to the API that aren't available via kwargs. # The extra values given here take precedence over values defined on the client or passed to this method. extra_headers: Headers | None = None, extra_query: Query | None = None, extra_body: Body | None = None, - timeout: float | httpx.Timeout | None | NotGiven = NOT_GIVEN, + timeout: float | httpx.Timeout | None | NotGiven = not_given, ) -> FileStatusResponse: """ Get file status diff --git a/src/opencode_ai/resources/find.py b/src/opencode_ai/resources/find.py index 6d8476d..7374a04 100644 --- a/src/opencode_ai/resources/find.py +++ b/src/opencode_ai/resources/find.py @@ -5,7 +5,7 @@ import httpx from ..types import find_text_params, find_files_params, find_symbols_params -from .._types import NOT_GIVEN, Body, Query, Headers, NotGiven +from .._types import Body, Omit, Query, Headers, NotGiven, omit, not_given from .._utils import maybe_transform, async_maybe_transform from .._compat import cached_property from .._resource import SyncAPIResource, AsyncAPIResource @@ -47,13 +47,13 @@ def files( self, *, query: str, - directory: str | NotGiven = NOT_GIVEN, + directory: str | Omit = omit, # Use the following arguments if you need to pass additional parameters to the API that aren't available via kwargs. # The extra values given here take precedence over values defined on the client or passed to this method. extra_headers: Headers | None = None, extra_query: Query | None = None, extra_body: Body | None = None, - timeout: float | httpx.Timeout | None | NotGiven = NOT_GIVEN, + timeout: float | httpx.Timeout | None | NotGiven = not_given, ) -> FindFilesResponse: """ Find files @@ -89,13 +89,13 @@ def symbols( self, *, query: str, - directory: str | NotGiven = NOT_GIVEN, + directory: str | Omit = omit, # Use the following arguments if you need to pass additional parameters to the API that aren't available via kwargs. # The extra values given here take precedence over values defined on the client or passed to this method. extra_headers: Headers | None = None, extra_query: Query | None = None, extra_body: Body | None = None, - timeout: float | httpx.Timeout | None | NotGiven = NOT_GIVEN, + timeout: float | httpx.Timeout | None | NotGiven = not_given, ) -> FindSymbolsResponse: """ Find workspace symbols @@ -131,13 +131,13 @@ def text( self, *, pattern: str, - directory: str | NotGiven = NOT_GIVEN, + directory: str | Omit = omit, # Use the following arguments if you need to pass additional parameters to the API that aren't available via kwargs. # The extra values given here take precedence over values defined on the client or passed to this method. extra_headers: Headers | None = None, extra_query: Query | None = None, extra_body: Body | None = None, - timeout: float | httpx.Timeout | None | NotGiven = NOT_GIVEN, + timeout: float | httpx.Timeout | None | NotGiven = not_given, ) -> FindTextResponse: """ Find text in files @@ -194,13 +194,13 @@ async def files( self, *, query: str, - directory: str | NotGiven = NOT_GIVEN, + directory: str | Omit = omit, # Use the following arguments if you need to pass additional parameters to the API that aren't available via kwargs. # The extra values given here take precedence over values defined on the client or passed to this method. extra_headers: Headers | None = None, extra_query: Query | None = None, extra_body: Body | None = None, - timeout: float | httpx.Timeout | None | NotGiven = NOT_GIVEN, + timeout: float | httpx.Timeout | None | NotGiven = not_given, ) -> FindFilesResponse: """ Find files @@ -236,13 +236,13 @@ async def symbols( self, *, query: str, - directory: str | NotGiven = NOT_GIVEN, + directory: str | Omit = omit, # Use the following arguments if you need to pass additional parameters to the API that aren't available via kwargs. # The extra values given here take precedence over values defined on the client or passed to this method. extra_headers: Headers | None = None, extra_query: Query | None = None, extra_body: Body | None = None, - timeout: float | httpx.Timeout | None | NotGiven = NOT_GIVEN, + timeout: float | httpx.Timeout | None | NotGiven = not_given, ) -> FindSymbolsResponse: """ Find workspace symbols @@ -278,13 +278,13 @@ async def text( self, *, pattern: str, - directory: str | NotGiven = NOT_GIVEN, + directory: str | Omit = omit, # Use the following arguments if you need to pass additional parameters to the API that aren't available via kwargs. # The extra values given here take precedence over values defined on the client or passed to this method. extra_headers: Headers | None = None, extra_query: Query | None = None, extra_body: Body | None = None, - timeout: float | httpx.Timeout | None | NotGiven = NOT_GIVEN, + timeout: float | httpx.Timeout | None | NotGiven = not_given, ) -> FindTextResponse: """ Find text in files diff --git a/src/opencode_ai/resources/path.py b/src/opencode_ai/resources/path.py index 7231a7e..6afee36 100644 --- a/src/opencode_ai/resources/path.py +++ b/src/opencode_ai/resources/path.py @@ -5,7 +5,7 @@ import httpx from ..types import path_get_params -from .._types import NOT_GIVEN, Body, Query, Headers, NotGiven +from .._types import Body, Omit, Query, Headers, NotGiven, omit, not_given from .._utils import maybe_transform, async_maybe_transform from .._compat import cached_property from .._resource import SyncAPIResource, AsyncAPIResource @@ -44,13 +44,13 @@ def with_streaming_response(self) -> PathResourceWithStreamingResponse: def get( self, *, - directory: str | NotGiven = NOT_GIVEN, + directory: str | Omit = omit, # Use the following arguments if you need to pass additional parameters to the API that aren't available via kwargs. # The extra values given here take precedence over values defined on the client or passed to this method. extra_headers: Headers | None = None, extra_query: Query | None = None, extra_body: Body | None = None, - timeout: float | httpx.Timeout | None | NotGiven = NOT_GIVEN, + timeout: float | httpx.Timeout | None | NotGiven = not_given, ) -> Path: """ Get the current path @@ -100,13 +100,13 @@ def with_streaming_response(self) -> AsyncPathResourceWithStreamingResponse: async def get( self, *, - directory: str | NotGiven = NOT_GIVEN, + directory: str | Omit = omit, # Use the following arguments if you need to pass additional parameters to the API that aren't available via kwargs. # The extra values given here take precedence over values defined on the client or passed to this method. extra_headers: Headers | None = None, extra_query: Query | None = None, extra_body: Body | None = None, - timeout: float | httpx.Timeout | None | NotGiven = NOT_GIVEN, + timeout: float | httpx.Timeout | None | NotGiven = not_given, ) -> Path: """ Get the current path diff --git a/src/opencode_ai/resources/project.py b/src/opencode_ai/resources/project.py index 72b214d..e0b94b5 100644 --- a/src/opencode_ai/resources/project.py +++ b/src/opencode_ai/resources/project.py @@ -5,7 +5,7 @@ import httpx from ..types import project_list_params, project_current_params -from .._types import NOT_GIVEN, Body, Query, Headers, NotGiven +from .._types import Body, Omit, Query, Headers, NotGiven, omit, not_given from .._utils import maybe_transform, async_maybe_transform from .._compat import cached_property from .._resource import SyncAPIResource, AsyncAPIResource @@ -45,13 +45,13 @@ def with_streaming_response(self) -> ProjectResourceWithStreamingResponse: def list( self, *, - directory: str | NotGiven = NOT_GIVEN, + directory: str | Omit = omit, # Use the following arguments if you need to pass additional parameters to the API that aren't available via kwargs. # The extra values given here take precedence over values defined on the client or passed to this method. extra_headers: Headers | None = None, extra_query: Query | None = None, extra_body: Body | None = None, - timeout: float | httpx.Timeout | None | NotGiven = NOT_GIVEN, + timeout: float | httpx.Timeout | None | NotGiven = not_given, ) -> ProjectListResponse: """ List all projects @@ -80,13 +80,13 @@ def list( def current( self, *, - directory: str | NotGiven = NOT_GIVEN, + directory: str | Omit = omit, # Use the following arguments if you need to pass additional parameters to the API that aren't available via kwargs. # The extra values given here take precedence over values defined on the client or passed to this method. extra_headers: Headers | None = None, extra_query: Query | None = None, extra_body: Body | None = None, - timeout: float | httpx.Timeout | None | NotGiven = NOT_GIVEN, + timeout: float | httpx.Timeout | None | NotGiven = not_given, ) -> Project: """ Get the current project @@ -136,13 +136,13 @@ def with_streaming_response(self) -> AsyncProjectResourceWithStreamingResponse: async def list( self, *, - directory: str | NotGiven = NOT_GIVEN, + directory: str | Omit = omit, # Use the following arguments if you need to pass additional parameters to the API that aren't available via kwargs. # The extra values given here take precedence over values defined on the client or passed to this method. extra_headers: Headers | None = None, extra_query: Query | None = None, extra_body: Body | None = None, - timeout: float | httpx.Timeout | None | NotGiven = NOT_GIVEN, + timeout: float | httpx.Timeout | None | NotGiven = not_given, ) -> ProjectListResponse: """ List all projects @@ -171,13 +171,13 @@ async def list( async def current( self, *, - directory: str | NotGiven = NOT_GIVEN, + directory: str | Omit = omit, # Use the following arguments if you need to pass additional parameters to the API that aren't available via kwargs. # The extra values given here take precedence over values defined on the client or passed to this method. extra_headers: Headers | None = None, extra_query: Query | None = None, extra_body: Body | None = None, - timeout: float | httpx.Timeout | None | NotGiven = NOT_GIVEN, + timeout: float | httpx.Timeout | None | NotGiven = not_given, ) -> Project: """ Get the current project diff --git a/src/opencode_ai/resources/session/permissions.py b/src/opencode_ai/resources/session/permissions.py index 8b521bd..a4d9c23 100644 --- a/src/opencode_ai/resources/session/permissions.py +++ b/src/opencode_ai/resources/session/permissions.py @@ -6,7 +6,7 @@ import httpx -from ..._types import NOT_GIVEN, Body, Query, Headers, NotGiven +from ..._types import Body, Omit, Query, Headers, NotGiven, omit, not_given from ..._utils import maybe_transform, async_maybe_transform from ..._compat import cached_property from ..._resource import SyncAPIResource, AsyncAPIResource @@ -49,13 +49,13 @@ def respond( *, id: str, response: Literal["once", "always", "reject"], - directory: str | NotGiven = NOT_GIVEN, + directory: str | Omit = omit, # Use the following arguments if you need to pass additional parameters to the API that aren't available via kwargs. # The extra values given here take precedence over values defined on the client or passed to this method. extra_headers: Headers | None = None, extra_query: Query | None = None, extra_body: Body | None = None, - timeout: float | httpx.Timeout | None | NotGiven = NOT_GIVEN, + timeout: float | httpx.Timeout | None | NotGiven = not_given, ) -> PermissionRespondResponse: """ Respond to a permission request @@ -113,13 +113,13 @@ async def respond( *, id: str, response: Literal["once", "always", "reject"], - directory: str | NotGiven = NOT_GIVEN, + directory: str | Omit = omit, # Use the following arguments if you need to pass additional parameters to the API that aren't available via kwargs. # The extra values given here take precedence over values defined on the client or passed to this method. extra_headers: Headers | None = None, extra_query: Query | None = None, extra_body: Body | None = None, - timeout: float | httpx.Timeout | None | NotGiven = NOT_GIVEN, + timeout: float | httpx.Timeout | None | NotGiven = not_given, ) -> PermissionRespondResponse: """ Respond to a permission request diff --git a/src/opencode_ai/resources/session/session.py b/src/opencode_ai/resources/session/session.py index 8e29247..950acf1 100644 --- a/src/opencode_ai/resources/session/session.py +++ b/src/opencode_ai/resources/session/session.py @@ -26,7 +26,7 @@ session_unrevert_params, session_summarize_params, ) -from ..._types import NOT_GIVEN, Body, Query, Headers, NotGiven +from ..._types import Body, Omit, Query, Headers, NotGiven, omit, not_given from ..._utils import maybe_transform, async_maybe_transform from ..._compat import cached_property from ..._resource import SyncAPIResource, AsyncAPIResource @@ -88,15 +88,15 @@ def with_streaming_response(self) -> SessionResourceWithStreamingResponse: def create( self, *, - directory: str | NotGiven = NOT_GIVEN, - parent_id: str | NotGiven = NOT_GIVEN, - title: str | NotGiven = NOT_GIVEN, + directory: str | Omit = omit, + parent_id: str | Omit = omit, + title: str | Omit = omit, # Use the following arguments if you need to pass additional parameters to the API that aren't available via kwargs. # The extra values given here take precedence over values defined on the client or passed to this method. extra_headers: Headers | None = None, extra_query: Query | None = None, extra_body: Body | None = None, - timeout: float | httpx.Timeout | None | NotGiven = NOT_GIVEN, + timeout: float | httpx.Timeout | None | NotGiven = not_given, ) -> Session: """ Create a new session @@ -133,14 +133,14 @@ def update( self, id: str, *, - directory: str | NotGiven = NOT_GIVEN, - title: str | NotGiven = NOT_GIVEN, + directory: str | Omit = omit, + title: str | Omit = omit, # Use the following arguments if you need to pass additional parameters to the API that aren't available via kwargs. # The extra values given here take precedence over values defined on the client or passed to this method. extra_headers: Headers | None = None, extra_query: Query | None = None, extra_body: Body | None = None, - timeout: float | httpx.Timeout | None | NotGiven = NOT_GIVEN, + timeout: float | httpx.Timeout | None | NotGiven = not_given, ) -> Session: """ Update session properties @@ -172,13 +172,13 @@ def update( def list( self, *, - directory: str | NotGiven = NOT_GIVEN, + directory: str | Omit = omit, # Use the following arguments if you need to pass additional parameters to the API that aren't available via kwargs. # The extra values given here take precedence over values defined on the client or passed to this method. extra_headers: Headers | None = None, extra_query: Query | None = None, extra_body: Body | None = None, - timeout: float | httpx.Timeout | None | NotGiven = NOT_GIVEN, + timeout: float | httpx.Timeout | None | NotGiven = not_given, ) -> SessionListResponse: """ List all sessions @@ -208,13 +208,13 @@ def delete( self, id: str, *, - directory: str | NotGiven = NOT_GIVEN, + directory: str | Omit = omit, # Use the following arguments if you need to pass additional parameters to the API that aren't available via kwargs. # The extra values given here take precedence over values defined on the client or passed to this method. extra_headers: Headers | None = None, extra_query: Query | None = None, extra_body: Body | None = None, - timeout: float | httpx.Timeout | None | NotGiven = NOT_GIVEN, + timeout: float | httpx.Timeout | None | NotGiven = not_given, ) -> SessionDeleteResponse: """ Delete a session and all its data @@ -246,13 +246,13 @@ def abort( self, id: str, *, - directory: str | NotGiven = NOT_GIVEN, + directory: str | Omit = omit, # Use the following arguments if you need to pass additional parameters to the API that aren't available via kwargs. # The extra values given here take precedence over values defined on the client or passed to this method. extra_headers: Headers | None = None, extra_query: Query | None = None, extra_body: Body | None = None, - timeout: float | httpx.Timeout | None | NotGiven = NOT_GIVEN, + timeout: float | httpx.Timeout | None | NotGiven = not_given, ) -> SessionAbortResponse: """ Abort a session @@ -284,13 +284,13 @@ def children( self, id: str, *, - directory: str | NotGiven = NOT_GIVEN, + directory: str | Omit = omit, # Use the following arguments if you need to pass additional parameters to the API that aren't available via kwargs. # The extra values given here take precedence over values defined on the client or passed to this method. extra_headers: Headers | None = None, extra_query: Query | None = None, extra_body: Body | None = None, - timeout: float | httpx.Timeout | None | NotGiven = NOT_GIVEN, + timeout: float | httpx.Timeout | None | NotGiven = not_given, ) -> SessionChildrenResponse: """ Get a session's children @@ -324,16 +324,16 @@ def command( *, arguments: str, command: str, - directory: str | NotGiven = NOT_GIVEN, - agent: str | NotGiven = NOT_GIVEN, - message_id: str | NotGiven = NOT_GIVEN, - model: str | NotGiven = NOT_GIVEN, + directory: str | Omit = omit, + agent: str | Omit = omit, + message_id: str | Omit = omit, + model: str | Omit = omit, # Use the following arguments if you need to pass additional parameters to the API that aren't available via kwargs. # The extra values given here take precedence over values defined on the client or passed to this method. extra_headers: Headers | None = None, extra_query: Query | None = None, extra_body: Body | None = None, - timeout: float | httpx.Timeout | None | NotGiven = NOT_GIVEN, + timeout: float | httpx.Timeout | None | NotGiven = not_given, ) -> SessionCommandResponse: """ Send a new command to a session @@ -377,13 +377,13 @@ def get( self, id: str, *, - directory: str | NotGiven = NOT_GIVEN, + directory: str | Omit = omit, # Use the following arguments if you need to pass additional parameters to the API that aren't available via kwargs. # The extra values given here take precedence over values defined on the client or passed to this method. extra_headers: Headers | None = None, extra_query: Query | None = None, extra_body: Body | None = None, - timeout: float | httpx.Timeout | None | NotGiven = NOT_GIVEN, + timeout: float | httpx.Timeout | None | NotGiven = not_given, ) -> Session: """ Get session @@ -418,13 +418,13 @@ def init( message_id: str, model_id: str, provider_id: str, - directory: str | NotGiven = NOT_GIVEN, + directory: str | Omit = omit, # Use the following arguments if you need to pass additional parameters to the API that aren't available via kwargs. # The extra values given here take precedence over values defined on the client or passed to this method. extra_headers: Headers | None = None, extra_query: Query | None = None, extra_body: Body | None = None, - timeout: float | httpx.Timeout | None | NotGiven = NOT_GIVEN, + timeout: float | httpx.Timeout | None | NotGiven = not_given, ) -> SessionInitResponse: """ Analyze the app and create an AGENTS.md file @@ -467,13 +467,13 @@ def message( message_id: str, *, id: str, - directory: str | NotGiven = NOT_GIVEN, + directory: str | Omit = omit, # Use the following arguments if you need to pass additional parameters to the API that aren't available via kwargs. # The extra values given here take precedence over values defined on the client or passed to this method. extra_headers: Headers | None = None, extra_query: Query | None = None, extra_body: Body | None = None, - timeout: float | httpx.Timeout | None | NotGiven = NOT_GIVEN, + timeout: float | httpx.Timeout | None | NotGiven = not_given, ) -> SessionMessageResponse: """ Get a message from a session @@ -511,13 +511,13 @@ def messages( self, id: str, *, - directory: str | NotGiven = NOT_GIVEN, + directory: str | Omit = omit, # Use the following arguments if you need to pass additional parameters to the API that aren't available via kwargs. # The extra values given here take precedence over values defined on the client or passed to this method. extra_headers: Headers | None = None, extra_query: Query | None = None, extra_body: Body | None = None, - timeout: float | httpx.Timeout | None | NotGiven = NOT_GIVEN, + timeout: float | httpx.Timeout | None | NotGiven = not_given, ) -> SessionMessagesResponse: """ List messages for a session @@ -552,18 +552,18 @@ def prompt( id: str, *, parts: Iterable[session_prompt_params.Part], - directory: str | NotGiven = NOT_GIVEN, - agent: str | NotGiven = NOT_GIVEN, - message_id: str | NotGiven = NOT_GIVEN, - model: session_prompt_params.Model | NotGiven = NOT_GIVEN, - system: str | NotGiven = NOT_GIVEN, - tools: Dict[str, bool] | NotGiven = NOT_GIVEN, + directory: str | Omit = omit, + agent: str | Omit = omit, + message_id: str | Omit = omit, + model: session_prompt_params.Model | Omit = omit, + system: str | Omit = omit, + tools: Dict[str, bool] | Omit = omit, # Use the following arguments if you need to pass additional parameters to the API that aren't available via kwargs. # The extra values given here take precedence over values defined on the client or passed to this method. extra_headers: Headers | None = None, extra_query: Query | None = None, extra_body: Body | None = None, - timeout: float | httpx.Timeout | None | NotGiven = NOT_GIVEN, + timeout: float | httpx.Timeout | None | NotGiven = not_given, ) -> SessionPromptResponse: """ Create and send a new message to a session @@ -609,14 +609,14 @@ def revert( id: str, *, message_id: str, - directory: str | NotGiven = NOT_GIVEN, - part_id: str | NotGiven = NOT_GIVEN, + directory: str | Omit = omit, + part_id: str | Omit = omit, # Use the following arguments if you need to pass additional parameters to the API that aren't available via kwargs. # The extra values given here take precedence over values defined on the client or passed to this method. extra_headers: Headers | None = None, extra_query: Query | None = None, extra_body: Body | None = None, - timeout: float | httpx.Timeout | None | NotGiven = NOT_GIVEN, + timeout: float | httpx.Timeout | None | NotGiven = not_given, ) -> Session: """ Revert a message @@ -655,13 +655,13 @@ def share( self, id: str, *, - directory: str | NotGiven = NOT_GIVEN, + directory: str | Omit = omit, # Use the following arguments if you need to pass additional parameters to the API that aren't available via kwargs. # The extra values given here take precedence over values defined on the client or passed to this method. extra_headers: Headers | None = None, extra_query: Query | None = None, extra_body: Body | None = None, - timeout: float | httpx.Timeout | None | NotGiven = NOT_GIVEN, + timeout: float | httpx.Timeout | None | NotGiven = not_given, ) -> Session: """ Share a session @@ -695,13 +695,13 @@ def shell( *, agent: str, command: str, - directory: str | NotGiven = NOT_GIVEN, + directory: str | Omit = omit, # Use the following arguments if you need to pass additional parameters to the API that aren't available via kwargs. # The extra values given here take precedence over values defined on the client or passed to this method. extra_headers: Headers | None = None, extra_query: Query | None = None, extra_body: Body | None = None, - timeout: float | httpx.Timeout | None | NotGiven = NOT_GIVEN, + timeout: float | httpx.Timeout | None | NotGiven = not_given, ) -> AssistantMessage: """ Run a shell command @@ -744,13 +744,13 @@ def summarize( *, model_id: str, provider_id: str, - directory: str | NotGiven = NOT_GIVEN, + directory: str | Omit = omit, # Use the following arguments if you need to pass additional parameters to the API that aren't available via kwargs. # The extra values given here take precedence over values defined on the client or passed to this method. extra_headers: Headers | None = None, extra_query: Query | None = None, extra_body: Body | None = None, - timeout: float | httpx.Timeout | None | NotGiven = NOT_GIVEN, + timeout: float | httpx.Timeout | None | NotGiven = not_given, ) -> SessionSummarizeResponse: """ Summarize the session @@ -791,13 +791,13 @@ def unrevert( self, id: str, *, - directory: str | NotGiven = NOT_GIVEN, + directory: str | Omit = omit, # Use the following arguments if you need to pass additional parameters to the API that aren't available via kwargs. # The extra values given here take precedence over values defined on the client or passed to this method. extra_headers: Headers | None = None, extra_query: Query | None = None, extra_body: Body | None = None, - timeout: float | httpx.Timeout | None | NotGiven = NOT_GIVEN, + timeout: float | httpx.Timeout | None | NotGiven = not_given, ) -> Session: """ Restore all reverted messages @@ -829,13 +829,13 @@ def unshare( self, id: str, *, - directory: str | NotGiven = NOT_GIVEN, + directory: str | Omit = omit, # Use the following arguments if you need to pass additional parameters to the API that aren't available via kwargs. # The extra values given here take precedence over values defined on the client or passed to this method. extra_headers: Headers | None = None, extra_query: Query | None = None, extra_body: Body | None = None, - timeout: float | httpx.Timeout | None | NotGiven = NOT_GIVEN, + timeout: float | httpx.Timeout | None | NotGiven = not_given, ) -> Session: """ Unshare the session @@ -891,15 +891,15 @@ def with_streaming_response(self) -> AsyncSessionResourceWithStreamingResponse: async def create( self, *, - directory: str | NotGiven = NOT_GIVEN, - parent_id: str | NotGiven = NOT_GIVEN, - title: str | NotGiven = NOT_GIVEN, + directory: str | Omit = omit, + parent_id: str | Omit = omit, + title: str | Omit = omit, # Use the following arguments if you need to pass additional parameters to the API that aren't available via kwargs. # The extra values given here take precedence over values defined on the client or passed to this method. extra_headers: Headers | None = None, extra_query: Query | None = None, extra_body: Body | None = None, - timeout: float | httpx.Timeout | None | NotGiven = NOT_GIVEN, + timeout: float | httpx.Timeout | None | NotGiven = not_given, ) -> Session: """ Create a new session @@ -936,14 +936,14 @@ async def update( self, id: str, *, - directory: str | NotGiven = NOT_GIVEN, - title: str | NotGiven = NOT_GIVEN, + directory: str | Omit = omit, + title: str | Omit = omit, # Use the following arguments if you need to pass additional parameters to the API that aren't available via kwargs. # The extra values given here take precedence over values defined on the client or passed to this method. extra_headers: Headers | None = None, extra_query: Query | None = None, extra_body: Body | None = None, - timeout: float | httpx.Timeout | None | NotGiven = NOT_GIVEN, + timeout: float | httpx.Timeout | None | NotGiven = not_given, ) -> Session: """ Update session properties @@ -975,13 +975,13 @@ async def update( async def list( self, *, - directory: str | NotGiven = NOT_GIVEN, + directory: str | Omit = omit, # Use the following arguments if you need to pass additional parameters to the API that aren't available via kwargs. # The extra values given here take precedence over values defined on the client or passed to this method. extra_headers: Headers | None = None, extra_query: Query | None = None, extra_body: Body | None = None, - timeout: float | httpx.Timeout | None | NotGiven = NOT_GIVEN, + timeout: float | httpx.Timeout | None | NotGiven = not_given, ) -> SessionListResponse: """ List all sessions @@ -1011,13 +1011,13 @@ async def delete( self, id: str, *, - directory: str | NotGiven = NOT_GIVEN, + directory: str | Omit = omit, # Use the following arguments if you need to pass additional parameters to the API that aren't available via kwargs. # The extra values given here take precedence over values defined on the client or passed to this method. extra_headers: Headers | None = None, extra_query: Query | None = None, extra_body: Body | None = None, - timeout: float | httpx.Timeout | None | NotGiven = NOT_GIVEN, + timeout: float | httpx.Timeout | None | NotGiven = not_given, ) -> SessionDeleteResponse: """ Delete a session and all its data @@ -1049,13 +1049,13 @@ async def abort( self, id: str, *, - directory: str | NotGiven = NOT_GIVEN, + directory: str | Omit = omit, # Use the following arguments if you need to pass additional parameters to the API that aren't available via kwargs. # The extra values given here take precedence over values defined on the client or passed to this method. extra_headers: Headers | None = None, extra_query: Query | None = None, extra_body: Body | None = None, - timeout: float | httpx.Timeout | None | NotGiven = NOT_GIVEN, + timeout: float | httpx.Timeout | None | NotGiven = not_given, ) -> SessionAbortResponse: """ Abort a session @@ -1087,13 +1087,13 @@ async def children( self, id: str, *, - directory: str | NotGiven = NOT_GIVEN, + directory: str | Omit = omit, # Use the following arguments if you need to pass additional parameters to the API that aren't available via kwargs. # The extra values given here take precedence over values defined on the client or passed to this method. extra_headers: Headers | None = None, extra_query: Query | None = None, extra_body: Body | None = None, - timeout: float | httpx.Timeout | None | NotGiven = NOT_GIVEN, + timeout: float | httpx.Timeout | None | NotGiven = not_given, ) -> SessionChildrenResponse: """ Get a session's children @@ -1129,16 +1129,16 @@ async def command( *, arguments: str, command: str, - directory: str | NotGiven = NOT_GIVEN, - agent: str | NotGiven = NOT_GIVEN, - message_id: str | NotGiven = NOT_GIVEN, - model: str | NotGiven = NOT_GIVEN, + directory: str | Omit = omit, + agent: str | Omit = omit, + message_id: str | Omit = omit, + model: str | Omit = omit, # Use the following arguments if you need to pass additional parameters to the API that aren't available via kwargs. # The extra values given here take precedence over values defined on the client or passed to this method. extra_headers: Headers | None = None, extra_query: Query | None = None, extra_body: Body | None = None, - timeout: float | httpx.Timeout | None | NotGiven = NOT_GIVEN, + timeout: float | httpx.Timeout | None | NotGiven = not_given, ) -> SessionCommandResponse: """ Send a new command to a session @@ -1184,13 +1184,13 @@ async def get( self, id: str, *, - directory: str | NotGiven = NOT_GIVEN, + directory: str | Omit = omit, # Use the following arguments if you need to pass additional parameters to the API that aren't available via kwargs. # The extra values given here take precedence over values defined on the client or passed to this method. extra_headers: Headers | None = None, extra_query: Query | None = None, extra_body: Body | None = None, - timeout: float | httpx.Timeout | None | NotGiven = NOT_GIVEN, + timeout: float | httpx.Timeout | None | NotGiven = not_given, ) -> Session: """ Get session @@ -1225,13 +1225,13 @@ async def init( message_id: str, model_id: str, provider_id: str, - directory: str | NotGiven = NOT_GIVEN, + directory: str | Omit = omit, # Use the following arguments if you need to pass additional parameters to the API that aren't available via kwargs. # The extra values given here take precedence over values defined on the client or passed to this method. extra_headers: Headers | None = None, extra_query: Query | None = None, extra_body: Body | None = None, - timeout: float | httpx.Timeout | None | NotGiven = NOT_GIVEN, + timeout: float | httpx.Timeout | None | NotGiven = not_given, ) -> SessionInitResponse: """ Analyze the app and create an AGENTS.md file @@ -1274,13 +1274,13 @@ async def message( message_id: str, *, id: str, - directory: str | NotGiven = NOT_GIVEN, + directory: str | Omit = omit, # Use the following arguments if you need to pass additional parameters to the API that aren't available via kwargs. # The extra values given here take precedence over values defined on the client or passed to this method. extra_headers: Headers | None = None, extra_query: Query | None = None, extra_body: Body | None = None, - timeout: float | httpx.Timeout | None | NotGiven = NOT_GIVEN, + timeout: float | httpx.Timeout | None | NotGiven = not_given, ) -> SessionMessageResponse: """ Get a message from a session @@ -1320,13 +1320,13 @@ async def messages( self, id: str, *, - directory: str | NotGiven = NOT_GIVEN, + directory: str | Omit = omit, # Use the following arguments if you need to pass additional parameters to the API that aren't available via kwargs. # The extra values given here take precedence over values defined on the client or passed to this method. extra_headers: Headers | None = None, extra_query: Query | None = None, extra_body: Body | None = None, - timeout: float | httpx.Timeout | None | NotGiven = NOT_GIVEN, + timeout: float | httpx.Timeout | None | NotGiven = not_given, ) -> SessionMessagesResponse: """ List messages for a session @@ -1363,18 +1363,18 @@ async def prompt( id: str, *, parts: Iterable[session_prompt_params.Part], - directory: str | NotGiven = NOT_GIVEN, - agent: str | NotGiven = NOT_GIVEN, - message_id: str | NotGiven = NOT_GIVEN, - model: session_prompt_params.Model | NotGiven = NOT_GIVEN, - system: str | NotGiven = NOT_GIVEN, - tools: Dict[str, bool] | NotGiven = NOT_GIVEN, + directory: str | Omit = omit, + agent: str | Omit = omit, + message_id: str | Omit = omit, + model: session_prompt_params.Model | Omit = omit, + system: str | Omit = omit, + tools: Dict[str, bool] | Omit = omit, # Use the following arguments if you need to pass additional parameters to the API that aren't available via kwargs. # The extra values given here take precedence over values defined on the client or passed to this method. extra_headers: Headers | None = None, extra_query: Query | None = None, extra_body: Body | None = None, - timeout: float | httpx.Timeout | None | NotGiven = NOT_GIVEN, + timeout: float | httpx.Timeout | None | NotGiven = not_given, ) -> SessionPromptResponse: """ Create and send a new message to a session @@ -1420,14 +1420,14 @@ async def revert( id: str, *, message_id: str, - directory: str | NotGiven = NOT_GIVEN, - part_id: str | NotGiven = NOT_GIVEN, + directory: str | Omit = omit, + part_id: str | Omit = omit, # Use the following arguments if you need to pass additional parameters to the API that aren't available via kwargs. # The extra values given here take precedence over values defined on the client or passed to this method. extra_headers: Headers | None = None, extra_query: Query | None = None, extra_body: Body | None = None, - timeout: float | httpx.Timeout | None | NotGiven = NOT_GIVEN, + timeout: float | httpx.Timeout | None | NotGiven = not_given, ) -> Session: """ Revert a message @@ -1466,13 +1466,13 @@ async def share( self, id: str, *, - directory: str | NotGiven = NOT_GIVEN, + directory: str | Omit = omit, # Use the following arguments if you need to pass additional parameters to the API that aren't available via kwargs. # The extra values given here take precedence over values defined on the client or passed to this method. extra_headers: Headers | None = None, extra_query: Query | None = None, extra_body: Body | None = None, - timeout: float | httpx.Timeout | None | NotGiven = NOT_GIVEN, + timeout: float | httpx.Timeout | None | NotGiven = not_given, ) -> Session: """ Share a session @@ -1506,13 +1506,13 @@ async def shell( *, agent: str, command: str, - directory: str | NotGiven = NOT_GIVEN, + directory: str | Omit = omit, # Use the following arguments if you need to pass additional parameters to the API that aren't available via kwargs. # The extra values given here take precedence over values defined on the client or passed to this method. extra_headers: Headers | None = None, extra_query: Query | None = None, extra_body: Body | None = None, - timeout: float | httpx.Timeout | None | NotGiven = NOT_GIVEN, + timeout: float | httpx.Timeout | None | NotGiven = not_given, ) -> AssistantMessage: """ Run a shell command @@ -1555,13 +1555,13 @@ async def summarize( *, model_id: str, provider_id: str, - directory: str | NotGiven = NOT_GIVEN, + directory: str | Omit = omit, # Use the following arguments if you need to pass additional parameters to the API that aren't available via kwargs. # The extra values given here take precedence over values defined on the client or passed to this method. extra_headers: Headers | None = None, extra_query: Query | None = None, extra_body: Body | None = None, - timeout: float | httpx.Timeout | None | NotGiven = NOT_GIVEN, + timeout: float | httpx.Timeout | None | NotGiven = not_given, ) -> SessionSummarizeResponse: """ Summarize the session @@ -1604,13 +1604,13 @@ async def unrevert( self, id: str, *, - directory: str | NotGiven = NOT_GIVEN, + directory: str | Omit = omit, # Use the following arguments if you need to pass additional parameters to the API that aren't available via kwargs. # The extra values given here take precedence over values defined on the client or passed to this method. extra_headers: Headers | None = None, extra_query: Query | None = None, extra_body: Body | None = None, - timeout: float | httpx.Timeout | None | NotGiven = NOT_GIVEN, + timeout: float | httpx.Timeout | None | NotGiven = not_given, ) -> Session: """ Restore all reverted messages @@ -1644,13 +1644,13 @@ async def unshare( self, id: str, *, - directory: str | NotGiven = NOT_GIVEN, + directory: str | Omit = omit, # Use the following arguments if you need to pass additional parameters to the API that aren't available via kwargs. # The extra values given here take precedence over values defined on the client or passed to this method. extra_headers: Headers | None = None, extra_query: Query | None = None, extra_body: Body | None = None, - timeout: float | httpx.Timeout | None | NotGiven = NOT_GIVEN, + timeout: float | httpx.Timeout | None | NotGiven = not_given, ) -> Session: """ Unshare the session diff --git a/src/opencode_ai/resources/tui.py b/src/opencode_ai/resources/tui.py index 0a13597..11ffd2d 100644 --- a/src/opencode_ai/resources/tui.py +++ b/src/opencode_ai/resources/tui.py @@ -17,7 +17,7 @@ tui_submit_prompt_params, tui_execute_command_params, ) -from .._types import NOT_GIVEN, Body, Query, Headers, NotGiven +from .._types import Body, Omit, Query, Headers, NotGiven, omit, not_given from .._utils import maybe_transform, async_maybe_transform from .._compat import cached_property from .._resource import SyncAPIResource, AsyncAPIResource @@ -65,13 +65,13 @@ def append_prompt( self, *, text: str, - directory: str | NotGiven = NOT_GIVEN, + directory: str | Omit = omit, # Use the following arguments if you need to pass additional parameters to the API that aren't available via kwargs. # The extra values given here take precedence over values defined on the client or passed to this method. extra_headers: Headers | None = None, extra_query: Query | None = None, extra_body: Body | None = None, - timeout: float | httpx.Timeout | None | NotGiven = NOT_GIVEN, + timeout: float | httpx.Timeout | None | NotGiven = not_given, ) -> TuiAppendPromptResponse: """ Append prompt to the TUI @@ -101,13 +101,13 @@ def append_prompt( def clear_prompt( self, *, - directory: str | NotGiven = NOT_GIVEN, + directory: str | Omit = omit, # Use the following arguments if you need to pass additional parameters to the API that aren't available via kwargs. # The extra values given here take precedence over values defined on the client or passed to this method. extra_headers: Headers | None = None, extra_query: Query | None = None, extra_body: Body | None = None, - timeout: float | httpx.Timeout | None | NotGiven = NOT_GIVEN, + timeout: float | httpx.Timeout | None | NotGiven = not_given, ) -> TuiClearPromptResponse: """ Clear the prompt @@ -137,13 +137,13 @@ def execute_command( self, *, command: str, - directory: str | NotGiven = NOT_GIVEN, + directory: str | Omit = omit, # Use the following arguments if you need to pass additional parameters to the API that aren't available via kwargs. # The extra values given here take precedence over values defined on the client or passed to this method. extra_headers: Headers | None = None, extra_query: Query | None = None, extra_body: Body | None = None, - timeout: float | httpx.Timeout | None | NotGiven = NOT_GIVEN, + timeout: float | httpx.Timeout | None | NotGiven = not_given, ) -> TuiExecuteCommandResponse: """Execute a TUI command (e.g. @@ -174,13 +174,13 @@ def execute_command( def open_help( self, *, - directory: str | NotGiven = NOT_GIVEN, + directory: str | Omit = omit, # Use the following arguments if you need to pass additional parameters to the API that aren't available via kwargs. # The extra values given here take precedence over values defined on the client or passed to this method. extra_headers: Headers | None = None, extra_query: Query | None = None, extra_body: Body | None = None, - timeout: float | httpx.Timeout | None | NotGiven = NOT_GIVEN, + timeout: float | httpx.Timeout | None | NotGiven = not_given, ) -> TuiOpenHelpResponse: """ Open the help dialog @@ -209,13 +209,13 @@ def open_help( def open_models( self, *, - directory: str | NotGiven = NOT_GIVEN, + directory: str | Omit = omit, # Use the following arguments if you need to pass additional parameters to the API that aren't available via kwargs. # The extra values given here take precedence over values defined on the client or passed to this method. extra_headers: Headers | None = None, extra_query: Query | None = None, extra_body: Body | None = None, - timeout: float | httpx.Timeout | None | NotGiven = NOT_GIVEN, + timeout: float | httpx.Timeout | None | NotGiven = not_given, ) -> TuiOpenModelsResponse: """ Open the model dialog @@ -244,13 +244,13 @@ def open_models( def open_sessions( self, *, - directory: str | NotGiven = NOT_GIVEN, + directory: str | Omit = omit, # Use the following arguments if you need to pass additional parameters to the API that aren't available via kwargs. # The extra values given here take precedence over values defined on the client or passed to this method. extra_headers: Headers | None = None, extra_query: Query | None = None, extra_body: Body | None = None, - timeout: float | httpx.Timeout | None | NotGiven = NOT_GIVEN, + timeout: float | httpx.Timeout | None | NotGiven = not_given, ) -> TuiOpenSessionsResponse: """ Open the session dialog @@ -279,13 +279,13 @@ def open_sessions( def open_themes( self, *, - directory: str | NotGiven = NOT_GIVEN, + directory: str | Omit = omit, # Use the following arguments if you need to pass additional parameters to the API that aren't available via kwargs. # The extra values given here take precedence over values defined on the client or passed to this method. extra_headers: Headers | None = None, extra_query: Query | None = None, extra_body: Body | None = None, - timeout: float | httpx.Timeout | None | NotGiven = NOT_GIVEN, + timeout: float | httpx.Timeout | None | NotGiven = not_given, ) -> TuiOpenThemesResponse: """ Open the theme dialog @@ -316,14 +316,14 @@ def show_toast( *, message: str, variant: Literal["info", "success", "warning", "error"], - directory: str | NotGiven = NOT_GIVEN, - title: str | NotGiven = NOT_GIVEN, + directory: str | Omit = omit, + title: str | Omit = omit, # Use the following arguments if you need to pass additional parameters to the API that aren't available via kwargs. # The extra values given here take precedence over values defined on the client or passed to this method. extra_headers: Headers | None = None, extra_query: Query | None = None, extra_body: Body | None = None, - timeout: float | httpx.Timeout | None | NotGiven = NOT_GIVEN, + timeout: float | httpx.Timeout | None | NotGiven = not_given, ) -> TuiShowToastResponse: """ Show a toast notification in the TUI @@ -360,13 +360,13 @@ def show_toast( def submit_prompt( self, *, - directory: str | NotGiven = NOT_GIVEN, + directory: str | Omit = omit, # Use the following arguments if you need to pass additional parameters to the API that aren't available via kwargs. # The extra values given here take precedence over values defined on the client or passed to this method. extra_headers: Headers | None = None, extra_query: Query | None = None, extra_body: Body | None = None, - timeout: float | httpx.Timeout | None | NotGiven = NOT_GIVEN, + timeout: float | httpx.Timeout | None | NotGiven = not_given, ) -> TuiSubmitPromptResponse: """ Submit the prompt @@ -417,13 +417,13 @@ async def append_prompt( self, *, text: str, - directory: str | NotGiven = NOT_GIVEN, + directory: str | Omit = omit, # Use the following arguments if you need to pass additional parameters to the API that aren't available via kwargs. # The extra values given here take precedence over values defined on the client or passed to this method. extra_headers: Headers | None = None, extra_query: Query | None = None, extra_body: Body | None = None, - timeout: float | httpx.Timeout | None | NotGiven = NOT_GIVEN, + timeout: float | httpx.Timeout | None | NotGiven = not_given, ) -> TuiAppendPromptResponse: """ Append prompt to the TUI @@ -455,13 +455,13 @@ async def append_prompt( async def clear_prompt( self, *, - directory: str | NotGiven = NOT_GIVEN, + directory: str | Omit = omit, # Use the following arguments if you need to pass additional parameters to the API that aren't available via kwargs. # The extra values given here take precedence over values defined on the client or passed to this method. extra_headers: Headers | None = None, extra_query: Query | None = None, extra_body: Body | None = None, - timeout: float | httpx.Timeout | None | NotGiven = NOT_GIVEN, + timeout: float | httpx.Timeout | None | NotGiven = not_given, ) -> TuiClearPromptResponse: """ Clear the prompt @@ -493,13 +493,13 @@ async def execute_command( self, *, command: str, - directory: str | NotGiven = NOT_GIVEN, + directory: str | Omit = omit, # Use the following arguments if you need to pass additional parameters to the API that aren't available via kwargs. # The extra values given here take precedence over values defined on the client or passed to this method. extra_headers: Headers | None = None, extra_query: Query | None = None, extra_body: Body | None = None, - timeout: float | httpx.Timeout | None | NotGiven = NOT_GIVEN, + timeout: float | httpx.Timeout | None | NotGiven = not_given, ) -> TuiExecuteCommandResponse: """Execute a TUI command (e.g. @@ -532,13 +532,13 @@ async def execute_command( async def open_help( self, *, - directory: str | NotGiven = NOT_GIVEN, + directory: str | Omit = omit, # Use the following arguments if you need to pass additional parameters to the API that aren't available via kwargs. # The extra values given here take precedence over values defined on the client or passed to this method. extra_headers: Headers | None = None, extra_query: Query | None = None, extra_body: Body | None = None, - timeout: float | httpx.Timeout | None | NotGiven = NOT_GIVEN, + timeout: float | httpx.Timeout | None | NotGiven = not_given, ) -> TuiOpenHelpResponse: """ Open the help dialog @@ -567,13 +567,13 @@ async def open_help( async def open_models( self, *, - directory: str | NotGiven = NOT_GIVEN, + directory: str | Omit = omit, # Use the following arguments if you need to pass additional parameters to the API that aren't available via kwargs. # The extra values given here take precedence over values defined on the client or passed to this method. extra_headers: Headers | None = None, extra_query: Query | None = None, extra_body: Body | None = None, - timeout: float | httpx.Timeout | None | NotGiven = NOT_GIVEN, + timeout: float | httpx.Timeout | None | NotGiven = not_given, ) -> TuiOpenModelsResponse: """ Open the model dialog @@ -602,13 +602,13 @@ async def open_models( async def open_sessions( self, *, - directory: str | NotGiven = NOT_GIVEN, + directory: str | Omit = omit, # Use the following arguments if you need to pass additional parameters to the API that aren't available via kwargs. # The extra values given here take precedence over values defined on the client or passed to this method. extra_headers: Headers | None = None, extra_query: Query | None = None, extra_body: Body | None = None, - timeout: float | httpx.Timeout | None | NotGiven = NOT_GIVEN, + timeout: float | httpx.Timeout | None | NotGiven = not_given, ) -> TuiOpenSessionsResponse: """ Open the session dialog @@ -639,13 +639,13 @@ async def open_sessions( async def open_themes( self, *, - directory: str | NotGiven = NOT_GIVEN, + directory: str | Omit = omit, # Use the following arguments if you need to pass additional parameters to the API that aren't available via kwargs. # The extra values given here take precedence over values defined on the client or passed to this method. extra_headers: Headers | None = None, extra_query: Query | None = None, extra_body: Body | None = None, - timeout: float | httpx.Timeout | None | NotGiven = NOT_GIVEN, + timeout: float | httpx.Timeout | None | NotGiven = not_given, ) -> TuiOpenThemesResponse: """ Open the theme dialog @@ -676,14 +676,14 @@ async def show_toast( *, message: str, variant: Literal["info", "success", "warning", "error"], - directory: str | NotGiven = NOT_GIVEN, - title: str | NotGiven = NOT_GIVEN, + directory: str | Omit = omit, + title: str | Omit = omit, # Use the following arguments if you need to pass additional parameters to the API that aren't available via kwargs. # The extra values given here take precedence over values defined on the client or passed to this method. extra_headers: Headers | None = None, extra_query: Query | None = None, extra_body: Body | None = None, - timeout: float | httpx.Timeout | None | NotGiven = NOT_GIVEN, + timeout: float | httpx.Timeout | None | NotGiven = not_given, ) -> TuiShowToastResponse: """ Show a toast notification in the TUI @@ -720,13 +720,13 @@ async def show_toast( async def submit_prompt( self, *, - directory: str | NotGiven = NOT_GIVEN, + directory: str | Omit = omit, # Use the following arguments if you need to pass additional parameters to the API that aren't available via kwargs. # The extra values given here take precedence over values defined on the client or passed to this method. extra_headers: Headers | None = None, extra_query: Query | None = None, extra_body: Body | None = None, - timeout: float | httpx.Timeout | None | NotGiven = NOT_GIVEN, + timeout: float | httpx.Timeout | None | NotGiven = not_given, ) -> TuiSubmitPromptResponse: """ Submit the prompt diff --git a/tests/test_transform.py b/tests/test_transform.py index 5ecdb1f..5adf5f4 100644 --- a/tests/test_transform.py +++ b/tests/test_transform.py @@ -8,7 +8,7 @@ import pytest -from opencode_ai._types import NOT_GIVEN, Base64FileInput +from opencode_ai._types import Base64FileInput, omit, not_given from opencode_ai._utils import ( PropertyInfo, transform as _transform, @@ -450,4 +450,11 @@ async def test_transform_skipping(use_async: bool) -> None: @pytest.mark.asyncio async def test_strips_notgiven(use_async: bool) -> None: assert await transform({"foo_bar": "bar"}, Foo1, use_async) == {"fooBar": "bar"} - assert await transform({"foo_bar": NOT_GIVEN}, Foo1, use_async) == {} + assert await transform({"foo_bar": not_given}, Foo1, use_async) == {} + + +@parametrize +@pytest.mark.asyncio +async def test_strips_omit(use_async: bool) -> None: + assert await transform({"foo_bar": "bar"}, Foo1, use_async) == {"fooBar": "bar"} + assert await transform({"foo_bar": omit}, Foo1, use_async) == {} From 74f0fa24042592fdaa27e85a58ae7fc299a8e6e3 Mon Sep 17 00:00:00 2001 From: "stainless-app[bot]" <142633134+stainless-app[bot]@users.noreply.github.com> Date: Sat, 20 Sep 2025 02:39:47 +0000 Subject: [PATCH 08/20] chore: do not install brew dependencies in ./scripts/bootstrap by default --- scripts/bootstrap | 14 +++++++++++--- 1 file changed, 11 insertions(+), 3 deletions(-) diff --git a/scripts/bootstrap b/scripts/bootstrap index e84fe62..b430fee 100755 --- a/scripts/bootstrap +++ b/scripts/bootstrap @@ -4,10 +4,18 @@ set -e cd "$(dirname "$0")/.." -if ! command -v rye >/dev/null 2>&1 && [ -f "Brewfile" ] && [ "$(uname -s)" = "Darwin" ]; then +if [ -f "Brewfile" ] && [ "$(uname -s)" = "Darwin" ] && [ "$SKIP_BREW" != "1" ] && [ -t 0 ]; then brew bundle check >/dev/null 2>&1 || { - echo "==> Installing Homebrew dependencies…" - brew bundle + echo -n "==> Install Homebrew dependencies? (y/N): " + read -r response + case "$response" in + [yY][eE][sS]|[yY]) + brew bundle + ;; + *) + ;; + esac + echo } fi From 662246b39c07bcac1346f3033f36c4188a80196e Mon Sep 17 00:00:00 2001 From: "stainless-app[bot]" <142633134+stainless-app[bot]@users.noreply.github.com> Date: Thu, 25 Sep 2025 02:10:48 +0000 Subject: [PATCH 09/20] fix(compat): compat with `pydantic<2.8.0` when using additional fields --- src/opencode_ai/types/config.py | 70 ++++++++++++++++++++++++++++----- 1 file changed, 60 insertions(+), 10 deletions(-) diff --git a/src/opencode_ai/types/config.py b/src/opencode_ai/types/config.py index 761532b..5be481d 100644 --- a/src/opencode_ai/types/config.py +++ b/src/opencode_ai/types/config.py @@ -77,12 +77,17 @@ class AgentBuild(BaseModel): top_p: Optional[float] = None - __pydantic_extra__: Dict[str, object] = FieldInfo(init=False) # pyright: ignore[reportIncompatibleVariableOverride] if TYPE_CHECKING: + # Some versions of Pydantic <2.8.0 have a bug and don’t allow assigning a + # value to this field, so for compatibility we avoid doing it at runtime. + __pydantic_extra__: Dict[str, object] = FieldInfo(init=False) # pyright: ignore[reportIncompatibleVariableOverride] + # Stub to indicate that arbitrary properties are accepted. # To access properties that are not valid identifiers you can use `getattr`, e.g. # `getattr(obj, '$type')` def __getattr__(self, attr: str) -> object: ... + else: + __pydantic_extra__: Dict[str, object] class AgentGeneralPermission(BaseModel): @@ -113,12 +118,17 @@ class AgentGeneral(BaseModel): top_p: Optional[float] = None - __pydantic_extra__: Dict[str, object] = FieldInfo(init=False) # pyright: ignore[reportIncompatibleVariableOverride] if TYPE_CHECKING: + # Some versions of Pydantic <2.8.0 have a bug and don’t allow assigning a + # value to this field, so for compatibility we avoid doing it at runtime. + __pydantic_extra__: Dict[str, object] = FieldInfo(init=False) # pyright: ignore[reportIncompatibleVariableOverride] + # Stub to indicate that arbitrary properties are accepted. # To access properties that are not valid identifiers you can use `getattr`, e.g. # `getattr(obj, '$type')` def __getattr__(self, attr: str) -> object: ... + else: + __pydantic_extra__: Dict[str, object] class AgentPlanPermission(BaseModel): @@ -149,12 +159,17 @@ class AgentPlan(BaseModel): top_p: Optional[float] = None - __pydantic_extra__: Dict[str, object] = FieldInfo(init=False) # pyright: ignore[reportIncompatibleVariableOverride] if TYPE_CHECKING: + # Some versions of Pydantic <2.8.0 have a bug and don’t allow assigning a + # value to this field, so for compatibility we avoid doing it at runtime. + __pydantic_extra__: Dict[str, object] = FieldInfo(init=False) # pyright: ignore[reportIncompatibleVariableOverride] + # Stub to indicate that arbitrary properties are accepted. # To access properties that are not valid identifiers you can use `getattr`, e.g. # `getattr(obj, '$type')` def __getattr__(self, attr: str) -> object: ... + else: + __pydantic_extra__: Dict[str, object] class AgentAgentItemPermission(BaseModel): @@ -185,12 +200,17 @@ class AgentAgentItem(BaseModel): top_p: Optional[float] = None - __pydantic_extra__: Dict[str, object] = FieldInfo(init=False) # pyright: ignore[reportIncompatibleVariableOverride] if TYPE_CHECKING: + # Some versions of Pydantic <2.8.0 have a bug and don’t allow assigning a + # value to this field, so for compatibility we avoid doing it at runtime. + __pydantic_extra__: Dict[str, object] = FieldInfo(init=False) # pyright: ignore[reportIncompatibleVariableOverride] + # Stub to indicate that arbitrary properties are accepted. # To access properties that are not valid identifiers you can use `getattr`, e.g. # `getattr(obj, '$type')` def __getattr__(self, attr: str) -> object: ... + else: + __pydantic_extra__: Dict[str, object] class Agent(BaseModel): @@ -200,12 +220,17 @@ class Agent(BaseModel): plan: Optional[AgentPlan] = None - __pydantic_extra__: Dict[str, AgentAgentItem] = FieldInfo(init=False) # pyright: ignore[reportIncompatibleVariableOverride] if TYPE_CHECKING: + # Some versions of Pydantic <2.8.0 have a bug and don’t allow assigning a + # value to this field, so for compatibility we avoid doing it at runtime. + __pydantic_extra__: Dict[str, AgentAgentItem] = FieldInfo(init=False) # pyright: ignore[reportIncompatibleVariableOverride] + # Stub to indicate that arbitrary properties are accepted. # To access properties that are not valid identifiers you can use `getattr`, e.g. # `getattr(obj, '$type')` def __getattr__(self, attr: str) -> AgentAgentItem: ... + else: + __pydantic_extra__: Dict[str, AgentAgentItem] class Command(BaseModel): @@ -299,12 +324,17 @@ class ModeBuild(BaseModel): top_p: Optional[float] = None - __pydantic_extra__: Dict[str, object] = FieldInfo(init=False) # pyright: ignore[reportIncompatibleVariableOverride] if TYPE_CHECKING: + # Some versions of Pydantic <2.8.0 have a bug and don’t allow assigning a + # value to this field, so for compatibility we avoid doing it at runtime. + __pydantic_extra__: Dict[str, object] = FieldInfo(init=False) # pyright: ignore[reportIncompatibleVariableOverride] + # Stub to indicate that arbitrary properties are accepted. # To access properties that are not valid identifiers you can use `getattr`, e.g. # `getattr(obj, '$type')` def __getattr__(self, attr: str) -> object: ... + else: + __pydantic_extra__: Dict[str, object] class ModePlanPermission(BaseModel): @@ -335,12 +365,17 @@ class ModePlan(BaseModel): top_p: Optional[float] = None - __pydantic_extra__: Dict[str, object] = FieldInfo(init=False) # pyright: ignore[reportIncompatibleVariableOverride] if TYPE_CHECKING: + # Some versions of Pydantic <2.8.0 have a bug and don’t allow assigning a + # value to this field, so for compatibility we avoid doing it at runtime. + __pydantic_extra__: Dict[str, object] = FieldInfo(init=False) # pyright: ignore[reportIncompatibleVariableOverride] + # Stub to indicate that arbitrary properties are accepted. # To access properties that are not valid identifiers you can use `getattr`, e.g. # `getattr(obj, '$type')` def __getattr__(self, attr: str) -> object: ... + else: + __pydantic_extra__: Dict[str, object] class ModeModeItemPermission(BaseModel): @@ -371,12 +406,17 @@ class ModeModeItem(BaseModel): top_p: Optional[float] = None - __pydantic_extra__: Dict[str, object] = FieldInfo(init=False) # pyright: ignore[reportIncompatibleVariableOverride] if TYPE_CHECKING: + # Some versions of Pydantic <2.8.0 have a bug and don’t allow assigning a + # value to this field, so for compatibility we avoid doing it at runtime. + __pydantic_extra__: Dict[str, object] = FieldInfo(init=False) # pyright: ignore[reportIncompatibleVariableOverride] + # Stub to indicate that arbitrary properties are accepted. # To access properties that are not valid identifiers you can use `getattr`, e.g. # `getattr(obj, '$type')` def __getattr__(self, attr: str) -> object: ... + else: + __pydantic_extra__: Dict[str, object] class Mode(BaseModel): @@ -384,12 +424,17 @@ class Mode(BaseModel): plan: Optional[ModePlan] = None - __pydantic_extra__: Dict[str, ModeModeItem] = FieldInfo(init=False) # pyright: ignore[reportIncompatibleVariableOverride] if TYPE_CHECKING: + # Some versions of Pydantic <2.8.0 have a bug and don’t allow assigning a + # value to this field, so for compatibility we avoid doing it at runtime. + __pydantic_extra__: Dict[str, ModeModeItem] = FieldInfo(init=False) # pyright: ignore[reportIncompatibleVariableOverride] + # Stub to indicate that arbitrary properties are accepted. # To access properties that are not valid identifiers you can use `getattr`, e.g. # `getattr(obj, '$type')` def __getattr__(self, attr: str) -> ModeModeItem: ... + else: + __pydantic_extra__: Dict[str, ModeModeItem] class Permission(BaseModel): @@ -449,12 +494,17 @@ class ProviderOptions(BaseModel): Default is 300000 (5 minutes). Set to false to disable timeout. """ - __pydantic_extra__: Dict[str, object] = FieldInfo(init=False) # pyright: ignore[reportIncompatibleVariableOverride] if TYPE_CHECKING: + # Some versions of Pydantic <2.8.0 have a bug and don’t allow assigning a + # value to this field, so for compatibility we avoid doing it at runtime. + __pydantic_extra__: Dict[str, object] = FieldInfo(init=False) # pyright: ignore[reportIncompatibleVariableOverride] + # Stub to indicate that arbitrary properties are accepted. # To access properties that are not valid identifiers you can use `getattr`, e.g. # `getattr(obj, '$type')` def __getattr__(self, attr: str) -> object: ... + else: + __pydantic_extra__: Dict[str, object] class Provider(BaseModel): From 932d414bd019219a28c392790c5f031313a6b936 Mon Sep 17 00:00:00 2001 From: "stainless-app[bot]" <142633134+stainless-app[bot]@users.noreply.github.com> Date: Sat, 11 Oct 2025 02:10:22 +0000 Subject: [PATCH 10/20] chore(internal): detect missing future annotations with ruff --- pyproject.toml | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/pyproject.toml b/pyproject.toml index 39cc41d..1cf1578 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -224,6 +224,8 @@ select = [ "B", # remove unused imports "F401", + # check for missing future annotations + "FA102", # bare except statements "E722", # unused arguments @@ -246,6 +248,8 @@ unfixable = [ "T203", ] +extend-safe-fixes = ["FA102"] + [tool.ruff.lint.flake8-tidy-imports.banned-api] "functools.lru_cache".msg = "This function does not retain type information for the wrapped function's arguments; The `lru_cache` function from `_utils` should be used instead" From 92cb13de6acb1716afc4e51d0d865058d7eabbe0 Mon Sep 17 00:00:00 2001 From: "stainless-app[bot]" <142633134+stainless-app[bot]@users.noreply.github.com> Date: Sat, 18 Oct 2025 02:06:15 +0000 Subject: [PATCH 11/20] chore: bump `httpx-aiohttp` version to 0.1.9 --- pyproject.toml | 2 +- requirements-dev.lock | 2 +- requirements.lock | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index 1cf1578..ed74dd3 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -39,7 +39,7 @@ Homepage = "https://github.com/sst/opencode-sdk-python" Repository = "https://github.com/sst/opencode-sdk-python" [project.optional-dependencies] -aiohttp = ["aiohttp", "httpx_aiohttp>=0.1.8"] +aiohttp = ["aiohttp", "httpx_aiohttp>=0.1.9"] [tool.rye] managed = true diff --git a/requirements-dev.lock b/requirements-dev.lock index 75fb82c..e68d6f3 100644 --- a/requirements-dev.lock +++ b/requirements-dev.lock @@ -56,7 +56,7 @@ httpx==0.28.1 # via httpx-aiohttp # via opencode-ai # via respx -httpx-aiohttp==0.1.8 +httpx-aiohttp==0.1.9 # via opencode-ai idna==3.4 # via anyio diff --git a/requirements.lock b/requirements.lock index 7537c91..bd0c6f4 100644 --- a/requirements.lock +++ b/requirements.lock @@ -43,7 +43,7 @@ httpcore==1.0.9 httpx==0.28.1 # via httpx-aiohttp # via opencode-ai -httpx-aiohttp==0.1.8 +httpx-aiohttp==0.1.9 # via opencode-ai idna==3.4 # via anyio From 81dc9145e044aff113827b1bd7bc54f9aeb1db8e Mon Sep 17 00:00:00 2001 From: "stainless-app[bot]" <142633134+stainless-app[bot]@users.noreply.github.com> Date: Thu, 30 Oct 2025 02:10:23 +0000 Subject: [PATCH 12/20] fix(client): close streams without requiring full consumption --- src/opencode_ai/_streaming.py | 10 ++++------ 1 file changed, 4 insertions(+), 6 deletions(-) diff --git a/src/opencode_ai/_streaming.py b/src/opencode_ai/_streaming.py index 34499b5..5dc9d33 100644 --- a/src/opencode_ai/_streaming.py +++ b/src/opencode_ai/_streaming.py @@ -57,9 +57,8 @@ def __stream__(self) -> Iterator[_T]: for sse in iterator: yield process_data(data=sse.json(), cast_to=cast_to, response=response) - # Ensure the entire stream is consumed - for _sse in iterator: - ... + # As we might not fully consume the response stream, we need to close it explicitly + response.close() def __enter__(self) -> Self: return self @@ -121,9 +120,8 @@ async def __stream__(self) -> AsyncIterator[_T]: async for sse in iterator: yield process_data(data=sse.json(), cast_to=cast_to, response=response) - # Ensure the entire stream is consumed - async for _sse in iterator: - ... + # As we might not fully consume the response stream, we need to close it explicitly + await response.aclose() async def __aenter__(self) -> Self: return self From 33f3521d9b17fe39b2f13f24f6edd29e3ff26ef9 Mon Sep 17 00:00:00 2001 From: "stainless-app[bot]" <142633134+stainless-app[bot]@users.noreply.github.com> Date: Fri, 31 Oct 2025 02:31:29 +0000 Subject: [PATCH 13/20] chore(internal): codegen related update --- tests/test_client.py | 365 ++++++++++++++++++++++++------------------- 1 file changed, 200 insertions(+), 165 deletions(-) diff --git a/tests/test_client.py b/tests/test_client.py index fede131..7f7092c 100644 --- a/tests/test_client.py +++ b/tests/test_client.py @@ -59,47 +59,45 @@ def _get_open_connections(client: Opencode | AsyncOpencode) -> int: class TestOpencode: - client = Opencode(base_url=base_url, _strict_response_validation=True) - @pytest.mark.respx(base_url=base_url) - def test_raw_response(self, respx_mock: MockRouter) -> None: + def test_raw_response(self, respx_mock: MockRouter, client: Opencode) -> None: respx_mock.post("/foo").mock(return_value=httpx.Response(200, json={"foo": "bar"})) - response = self.client.post("/foo", cast_to=httpx.Response) + response = client.post("/foo", cast_to=httpx.Response) assert response.status_code == 200 assert isinstance(response, httpx.Response) assert response.json() == {"foo": "bar"} @pytest.mark.respx(base_url=base_url) - def test_raw_response_for_binary(self, respx_mock: MockRouter) -> None: + def test_raw_response_for_binary(self, respx_mock: MockRouter, client: Opencode) -> None: respx_mock.post("/foo").mock( return_value=httpx.Response(200, headers={"Content-Type": "application/binary"}, content='{"foo": "bar"}') ) - response = self.client.post("/foo", cast_to=httpx.Response) + response = client.post("/foo", cast_to=httpx.Response) assert response.status_code == 200 assert isinstance(response, httpx.Response) assert response.json() == {"foo": "bar"} - def test_copy(self) -> None: - copied = self.client.copy() - assert id(copied) != id(self.client) + def test_copy(self, client: Opencode) -> None: + copied = client.copy() + assert id(copied) != id(client) - def test_copy_default_options(self) -> None: + def test_copy_default_options(self, client: Opencode) -> None: # options that have a default are overridden correctly - copied = self.client.copy(max_retries=7) + copied = client.copy(max_retries=7) assert copied.max_retries == 7 - assert self.client.max_retries == 2 + assert client.max_retries == 2 copied2 = copied.copy(max_retries=6) assert copied2.max_retries == 6 assert copied.max_retries == 7 # timeout - assert isinstance(self.client.timeout, httpx.Timeout) - copied = self.client.copy(timeout=None) + assert isinstance(client.timeout, httpx.Timeout) + copied = client.copy(timeout=None) assert copied.timeout is None - assert isinstance(self.client.timeout, httpx.Timeout) + assert isinstance(client.timeout, httpx.Timeout) def test_copy_default_headers(self) -> None: client = Opencode(base_url=base_url, _strict_response_validation=True, default_headers={"X-Foo": "bar"}) @@ -132,6 +130,7 @@ def test_copy_default_headers(self) -> None: match="`default_headers` and `set_default_headers` arguments are mutually exclusive", ): client.copy(set_default_headers={}, default_headers={"X-Foo": "Bar"}) + client.close() def test_copy_default_query(self) -> None: client = Opencode(base_url=base_url, _strict_response_validation=True, default_query={"foo": "bar"}) @@ -167,13 +166,15 @@ def test_copy_default_query(self) -> None: ): client.copy(set_default_query={}, default_query={"foo": "Bar"}) - def test_copy_signature(self) -> None: + client.close() + + def test_copy_signature(self, client: Opencode) -> None: # ensure the same parameters that can be passed to the client are defined in the `.copy()` method init_signature = inspect.signature( # mypy doesn't like that we access the `__init__` property. - self.client.__init__, # type: ignore[misc] + client.__init__, # type: ignore[misc] ) - copy_signature = inspect.signature(self.client.copy) + copy_signature = inspect.signature(client.copy) exclude_params = {"transport", "proxies", "_strict_response_validation"} for name in init_signature.parameters.keys(): @@ -184,12 +185,12 @@ def test_copy_signature(self) -> None: assert copy_param is not None, f"copy() signature is missing the {name} param" @pytest.mark.skipif(sys.version_info >= (3, 10), reason="fails because of a memory leak that started from 3.12") - def test_copy_build_request(self) -> None: + def test_copy_build_request(self, client: Opencode) -> None: options = FinalRequestOptions(method="get", url="/foo") def build_request(options: FinalRequestOptions) -> None: - client = self.client.copy() - client._build_request(options) + client_copy = client.copy() + client_copy._build_request(options) # ensure that the machinery is warmed up before tracing starts. build_request(options) @@ -246,14 +247,12 @@ def add_leak(leaks: list[tracemalloc.StatisticDiff], diff: tracemalloc.Statistic print(frame) raise AssertionError() - def test_request_timeout(self) -> None: - request = self.client._build_request(FinalRequestOptions(method="get", url="/foo")) + def test_request_timeout(self, client: Opencode) -> None: + request = client._build_request(FinalRequestOptions(method="get", url="/foo")) timeout = httpx.Timeout(**request.extensions["timeout"]) # type: ignore assert timeout == DEFAULT_TIMEOUT - request = self.client._build_request( - FinalRequestOptions(method="get", url="/foo", timeout=httpx.Timeout(100.0)) - ) + request = client._build_request(FinalRequestOptions(method="get", url="/foo", timeout=httpx.Timeout(100.0))) timeout = httpx.Timeout(**request.extensions["timeout"]) # type: ignore assert timeout == httpx.Timeout(100.0) @@ -264,6 +263,8 @@ def test_client_timeout_option(self) -> None: timeout = httpx.Timeout(**request.extensions["timeout"]) # type: ignore assert timeout == httpx.Timeout(0) + client.close() + def test_http_client_timeout_option(self) -> None: # custom timeout given to the httpx client should be used with httpx.Client(timeout=None) as http_client: @@ -273,6 +274,8 @@ def test_http_client_timeout_option(self) -> None: timeout = httpx.Timeout(**request.extensions["timeout"]) # type: ignore assert timeout == httpx.Timeout(None) + client.close() + # no timeout given to the httpx client should not use the httpx default with httpx.Client() as http_client: client = Opencode(base_url=base_url, _strict_response_validation=True, http_client=http_client) @@ -281,6 +284,8 @@ def test_http_client_timeout_option(self) -> None: timeout = httpx.Timeout(**request.extensions["timeout"]) # type: ignore assert timeout == DEFAULT_TIMEOUT + client.close() + # explicitly passing the default timeout currently results in it being ignored with httpx.Client(timeout=HTTPX_DEFAULT_TIMEOUT) as http_client: client = Opencode(base_url=base_url, _strict_response_validation=True, http_client=http_client) @@ -289,18 +294,20 @@ def test_http_client_timeout_option(self) -> None: timeout = httpx.Timeout(**request.extensions["timeout"]) # type: ignore assert timeout == DEFAULT_TIMEOUT # our default + client.close() + async def test_invalid_http_client(self) -> None: with pytest.raises(TypeError, match="Invalid `http_client` arg"): async with httpx.AsyncClient() as http_client: Opencode(base_url=base_url, _strict_response_validation=True, http_client=cast(Any, http_client)) def test_default_headers_option(self) -> None: - client = Opencode(base_url=base_url, _strict_response_validation=True, default_headers={"X-Foo": "bar"}) - request = client._build_request(FinalRequestOptions(method="get", url="/foo")) + test_client = Opencode(base_url=base_url, _strict_response_validation=True, default_headers={"X-Foo": "bar"}) + request = test_client._build_request(FinalRequestOptions(method="get", url="/foo")) assert request.headers.get("x-foo") == "bar" assert request.headers.get("x-stainless-lang") == "python" - client2 = Opencode( + test_client2 = Opencode( base_url=base_url, _strict_response_validation=True, default_headers={ @@ -308,10 +315,13 @@ def test_default_headers_option(self) -> None: "X-Stainless-Lang": "my-overriding-header", }, ) - request = client2._build_request(FinalRequestOptions(method="get", url="/foo")) + request = test_client2._build_request(FinalRequestOptions(method="get", url="/foo")) assert request.headers.get("x-foo") == "stainless" assert request.headers.get("x-stainless-lang") == "my-overriding-header" + test_client.close() + test_client2.close() + def test_default_query_option(self) -> None: client = Opencode(base_url=base_url, _strict_response_validation=True, default_query={"query_param": "bar"}) request = client._build_request(FinalRequestOptions(method="get", url="/foo")) @@ -328,8 +338,10 @@ def test_default_query_option(self) -> None: url = httpx.URL(request.url) assert dict(url.params) == {"foo": "baz", "query_param": "overridden"} - def test_request_extra_json(self) -> None: - request = self.client._build_request( + client.close() + + def test_request_extra_json(self, client: Opencode) -> None: + request = client._build_request( FinalRequestOptions( method="post", url="/foo", @@ -340,7 +352,7 @@ def test_request_extra_json(self) -> None: data = json.loads(request.content.decode("utf-8")) assert data == {"foo": "bar", "baz": False} - request = self.client._build_request( + request = client._build_request( FinalRequestOptions( method="post", url="/foo", @@ -351,7 +363,7 @@ def test_request_extra_json(self) -> None: assert data == {"baz": False} # `extra_json` takes priority over `json_data` when keys clash - request = self.client._build_request( + request = client._build_request( FinalRequestOptions( method="post", url="/foo", @@ -362,8 +374,8 @@ def test_request_extra_json(self) -> None: data = json.loads(request.content.decode("utf-8")) assert data == {"foo": "bar", "baz": None} - def test_request_extra_headers(self) -> None: - request = self.client._build_request( + def test_request_extra_headers(self, client: Opencode) -> None: + request = client._build_request( FinalRequestOptions( method="post", url="/foo", @@ -373,7 +385,7 @@ def test_request_extra_headers(self) -> None: assert request.headers.get("X-Foo") == "Foo" # `extra_headers` takes priority over `default_headers` when keys clash - request = self.client.with_options(default_headers={"X-Bar": "true"})._build_request( + request = client.with_options(default_headers={"X-Bar": "true"})._build_request( FinalRequestOptions( method="post", url="/foo", @@ -384,8 +396,8 @@ def test_request_extra_headers(self) -> None: ) assert request.headers.get("X-Bar") == "false" - def test_request_extra_query(self) -> None: - request = self.client._build_request( + def test_request_extra_query(self, client: Opencode) -> None: + request = client._build_request( FinalRequestOptions( method="post", url="/foo", @@ -398,7 +410,7 @@ def test_request_extra_query(self) -> None: assert params == {"my_query_param": "Foo"} # if both `query` and `extra_query` are given, they are merged - request = self.client._build_request( + request = client._build_request( FinalRequestOptions( method="post", url="/foo", @@ -412,7 +424,7 @@ def test_request_extra_query(self) -> None: assert params == {"bar": "1", "foo": "2"} # `extra_query` takes priority over `query` when keys clash - request = self.client._build_request( + request = client._build_request( FinalRequestOptions( method="post", url="/foo", @@ -455,7 +467,7 @@ def test_multipart_repeating_array(self, client: Opencode) -> None: ] @pytest.mark.respx(base_url=base_url) - def test_basic_union_response(self, respx_mock: MockRouter) -> None: + def test_basic_union_response(self, respx_mock: MockRouter, client: Opencode) -> None: class Model1(BaseModel): name: str @@ -464,12 +476,12 @@ class Model2(BaseModel): respx_mock.get("/foo").mock(return_value=httpx.Response(200, json={"foo": "bar"})) - response = self.client.get("/foo", cast_to=cast(Any, Union[Model1, Model2])) + response = client.get("/foo", cast_to=cast(Any, Union[Model1, Model2])) assert isinstance(response, Model2) assert response.foo == "bar" @pytest.mark.respx(base_url=base_url) - def test_union_response_different_types(self, respx_mock: MockRouter) -> None: + def test_union_response_different_types(self, respx_mock: MockRouter, client: Opencode) -> None: """Union of objects with the same field name using a different type""" class Model1(BaseModel): @@ -480,18 +492,18 @@ class Model2(BaseModel): respx_mock.get("/foo").mock(return_value=httpx.Response(200, json={"foo": "bar"})) - response = self.client.get("/foo", cast_to=cast(Any, Union[Model1, Model2])) + response = client.get("/foo", cast_to=cast(Any, Union[Model1, Model2])) assert isinstance(response, Model2) assert response.foo == "bar" respx_mock.get("/foo").mock(return_value=httpx.Response(200, json={"foo": 1})) - response = self.client.get("/foo", cast_to=cast(Any, Union[Model1, Model2])) + response = client.get("/foo", cast_to=cast(Any, Union[Model1, Model2])) assert isinstance(response, Model1) assert response.foo == 1 @pytest.mark.respx(base_url=base_url) - def test_non_application_json_content_type_for_json_data(self, respx_mock: MockRouter) -> None: + def test_non_application_json_content_type_for_json_data(self, respx_mock: MockRouter, client: Opencode) -> None: """ Response that sets Content-Type to something other than application/json but returns json data """ @@ -507,7 +519,7 @@ class Model(BaseModel): ) ) - response = self.client.get("/foo", cast_to=Model) + response = client.get("/foo", cast_to=Model) assert isinstance(response, Model) assert response.foo == 2 @@ -519,6 +531,8 @@ def test_base_url_setter(self) -> None: assert client.base_url == "https://example.com/from_setter/" + client.close() + def test_base_url_env(self) -> None: with update_env(OPENCODE_BASE_URL="http://localhost:5000/from/env"): client = Opencode(_strict_response_validation=True) @@ -545,6 +559,7 @@ def test_base_url_trailing_slash(self, client: Opencode) -> None: ), ) assert request.url == "http://localhost:5000/custom/path/foo" + client.close() @pytest.mark.parametrize( "client", @@ -567,6 +582,7 @@ def test_base_url_no_trailing_slash(self, client: Opencode) -> None: ), ) assert request.url == "http://localhost:5000/custom/path/foo" + client.close() @pytest.mark.parametrize( "client", @@ -589,35 +605,36 @@ def test_absolute_request_url(self, client: Opencode) -> None: ), ) assert request.url == "https://myapi.com/foo" + client.close() def test_copied_client_does_not_close_http(self) -> None: - client = Opencode(base_url=base_url, _strict_response_validation=True) - assert not client.is_closed() + test_client = Opencode(base_url=base_url, _strict_response_validation=True) + assert not test_client.is_closed() - copied = client.copy() - assert copied is not client + copied = test_client.copy() + assert copied is not test_client del copied - assert not client.is_closed() + assert not test_client.is_closed() def test_client_context_manager(self) -> None: - client = Opencode(base_url=base_url, _strict_response_validation=True) - with client as c2: - assert c2 is client + test_client = Opencode(base_url=base_url, _strict_response_validation=True) + with test_client as c2: + assert c2 is test_client assert not c2.is_closed() - assert not client.is_closed() - assert client.is_closed() + assert not test_client.is_closed() + assert test_client.is_closed() @pytest.mark.respx(base_url=base_url) - def test_client_response_validation_error(self, respx_mock: MockRouter) -> None: + def test_client_response_validation_error(self, respx_mock: MockRouter, client: Opencode) -> None: class Model(BaseModel): foo: str respx_mock.get("/foo").mock(return_value=httpx.Response(200, json={"foo": {"invalid": True}})) with pytest.raises(APIResponseValidationError) as exc: - self.client.get("/foo", cast_to=Model) + client.get("/foo", cast_to=Model) assert isinstance(exc.value.__cause__, ValidationError) @@ -626,13 +643,13 @@ def test_client_max_retries_validation(self) -> None: Opencode(base_url=base_url, _strict_response_validation=True, max_retries=cast(Any, None)) @pytest.mark.respx(base_url=base_url) - def test_default_stream_cls(self, respx_mock: MockRouter) -> None: + def test_default_stream_cls(self, respx_mock: MockRouter, client: Opencode) -> None: class Model(BaseModel): name: str respx_mock.post("/foo").mock(return_value=httpx.Response(200, json={"foo": "bar"})) - stream = self.client.post("/foo", cast_to=Model, stream=True, stream_cls=Stream[Model]) + stream = client.post("/foo", cast_to=Model, stream=True, stream_cls=Stream[Model]) assert isinstance(stream, Stream) stream.response.close() @@ -648,11 +665,14 @@ class Model(BaseModel): with pytest.raises(APIResponseValidationError): strict_client.get("/foo", cast_to=Model) - client = Opencode(base_url=base_url, _strict_response_validation=False) + non_strict_client = Opencode(base_url=base_url, _strict_response_validation=False) - response = client.get("/foo", cast_to=Model) + response = non_strict_client.get("/foo", cast_to=Model) assert isinstance(response, str) # type: ignore[unreachable] + strict_client.close() + non_strict_client.close() + @pytest.mark.parametrize( "remaining_retries,retry_after,timeout", [ @@ -675,9 +695,9 @@ class Model(BaseModel): ], ) @mock.patch("time.time", mock.MagicMock(return_value=1696004797)) - def test_parse_retry_after_header(self, remaining_retries: int, retry_after: str, timeout: float) -> None: - client = Opencode(base_url=base_url, _strict_response_validation=True) - + def test_parse_retry_after_header( + self, remaining_retries: int, retry_after: str, timeout: float, client: Opencode + ) -> None: headers = httpx.Headers({"retry-after": retry_after}) options = FinalRequestOptions(method="get", url="/foo", max_retries=3) calculated = client._calculate_retry_timeout(remaining_retries, options, headers) @@ -691,7 +711,7 @@ def test_retrying_timeout_errors_doesnt_leak(self, respx_mock: MockRouter, clien with pytest.raises(APITimeoutError): client.session.with_streaming_response.list().__enter__() - assert _get_open_connections(self.client) == 0 + assert _get_open_connections(client) == 0 @mock.patch("opencode_ai._base_client.BaseClient._calculate_retry_timeout", _low_retry_timeout) @pytest.mark.respx(base_url=base_url) @@ -700,7 +720,7 @@ def test_retrying_status_errors_doesnt_leak(self, respx_mock: MockRouter, client with pytest.raises(APIStatusError): client.session.with_streaming_response.list().__enter__() - assert _get_open_connections(self.client) == 0 + assert _get_open_connections(client) == 0 @pytest.mark.parametrize("failures_before_success", [0, 2, 4]) @mock.patch("opencode_ai._base_client.BaseClient._calculate_retry_timeout", _low_retry_timeout) @@ -802,79 +822,73 @@ def test_default_client_creation(self) -> None: ) @pytest.mark.respx(base_url=base_url) - def test_follow_redirects(self, respx_mock: MockRouter) -> None: + def test_follow_redirects(self, respx_mock: MockRouter, client: Opencode) -> None: # Test that the default follow_redirects=True allows following redirects respx_mock.post("/redirect").mock( return_value=httpx.Response(302, headers={"Location": f"{base_url}/redirected"}) ) respx_mock.get("/redirected").mock(return_value=httpx.Response(200, json={"status": "ok"})) - response = self.client.post("/redirect", body={"key": "value"}, cast_to=httpx.Response) + response = client.post("/redirect", body={"key": "value"}, cast_to=httpx.Response) assert response.status_code == 200 assert response.json() == {"status": "ok"} @pytest.mark.respx(base_url=base_url) - def test_follow_redirects_disabled(self, respx_mock: MockRouter) -> None: + def test_follow_redirects_disabled(self, respx_mock: MockRouter, client: Opencode) -> None: # Test that follow_redirects=False prevents following redirects respx_mock.post("/redirect").mock( return_value=httpx.Response(302, headers={"Location": f"{base_url}/redirected"}) ) with pytest.raises(APIStatusError) as exc_info: - self.client.post( - "/redirect", body={"key": "value"}, options={"follow_redirects": False}, cast_to=httpx.Response - ) + client.post("/redirect", body={"key": "value"}, options={"follow_redirects": False}, cast_to=httpx.Response) assert exc_info.value.response.status_code == 302 assert exc_info.value.response.headers["Location"] == f"{base_url}/redirected" class TestAsyncOpencode: - client = AsyncOpencode(base_url=base_url, _strict_response_validation=True) - @pytest.mark.respx(base_url=base_url) - @pytest.mark.asyncio - async def test_raw_response(self, respx_mock: MockRouter) -> None: + async def test_raw_response(self, respx_mock: MockRouter, async_client: AsyncOpencode) -> None: respx_mock.post("/foo").mock(return_value=httpx.Response(200, json={"foo": "bar"})) - response = await self.client.post("/foo", cast_to=httpx.Response) + response = await async_client.post("/foo", cast_to=httpx.Response) assert response.status_code == 200 assert isinstance(response, httpx.Response) assert response.json() == {"foo": "bar"} @pytest.mark.respx(base_url=base_url) - @pytest.mark.asyncio - async def test_raw_response_for_binary(self, respx_mock: MockRouter) -> None: + async def test_raw_response_for_binary(self, respx_mock: MockRouter, async_client: AsyncOpencode) -> None: respx_mock.post("/foo").mock( return_value=httpx.Response(200, headers={"Content-Type": "application/binary"}, content='{"foo": "bar"}') ) - response = await self.client.post("/foo", cast_to=httpx.Response) + response = await async_client.post("/foo", cast_to=httpx.Response) assert response.status_code == 200 assert isinstance(response, httpx.Response) assert response.json() == {"foo": "bar"} - def test_copy(self) -> None: - copied = self.client.copy() - assert id(copied) != id(self.client) + def test_copy(self, async_client: AsyncOpencode) -> None: + copied = async_client.copy() + assert id(copied) != id(async_client) - def test_copy_default_options(self) -> None: + def test_copy_default_options(self, async_client: AsyncOpencode) -> None: # options that have a default are overridden correctly - copied = self.client.copy(max_retries=7) + copied = async_client.copy(max_retries=7) assert copied.max_retries == 7 - assert self.client.max_retries == 2 + assert async_client.max_retries == 2 copied2 = copied.copy(max_retries=6) assert copied2.max_retries == 6 assert copied.max_retries == 7 # timeout - assert isinstance(self.client.timeout, httpx.Timeout) - copied = self.client.copy(timeout=None) + assert isinstance(async_client.timeout, httpx.Timeout) + copied = async_client.copy(timeout=None) assert copied.timeout is None - assert isinstance(self.client.timeout, httpx.Timeout) + assert isinstance(async_client.timeout, httpx.Timeout) - def test_copy_default_headers(self) -> None: + async def test_copy_default_headers(self) -> None: client = AsyncOpencode(base_url=base_url, _strict_response_validation=True, default_headers={"X-Foo": "bar"}) assert client.default_headers["X-Foo"] == "bar" @@ -905,8 +919,9 @@ def test_copy_default_headers(self) -> None: match="`default_headers` and `set_default_headers` arguments are mutually exclusive", ): client.copy(set_default_headers={}, default_headers={"X-Foo": "Bar"}) + await client.close() - def test_copy_default_query(self) -> None: + async def test_copy_default_query(self) -> None: client = AsyncOpencode(base_url=base_url, _strict_response_validation=True, default_query={"foo": "bar"}) assert _get_params(client)["foo"] == "bar" @@ -940,13 +955,15 @@ def test_copy_default_query(self) -> None: ): client.copy(set_default_query={}, default_query={"foo": "Bar"}) - def test_copy_signature(self) -> None: + await client.close() + + def test_copy_signature(self, async_client: AsyncOpencode) -> None: # ensure the same parameters that can be passed to the client are defined in the `.copy()` method init_signature = inspect.signature( # mypy doesn't like that we access the `__init__` property. - self.client.__init__, # type: ignore[misc] + async_client.__init__, # type: ignore[misc] ) - copy_signature = inspect.signature(self.client.copy) + copy_signature = inspect.signature(async_client.copy) exclude_params = {"transport", "proxies", "_strict_response_validation"} for name in init_signature.parameters.keys(): @@ -957,12 +974,12 @@ def test_copy_signature(self) -> None: assert copy_param is not None, f"copy() signature is missing the {name} param" @pytest.mark.skipif(sys.version_info >= (3, 10), reason="fails because of a memory leak that started from 3.12") - def test_copy_build_request(self) -> None: + def test_copy_build_request(self, async_client: AsyncOpencode) -> None: options = FinalRequestOptions(method="get", url="/foo") def build_request(options: FinalRequestOptions) -> None: - client = self.client.copy() - client._build_request(options) + client_copy = async_client.copy() + client_copy._build_request(options) # ensure that the machinery is warmed up before tracing starts. build_request(options) @@ -1019,12 +1036,12 @@ def add_leak(leaks: list[tracemalloc.StatisticDiff], diff: tracemalloc.Statistic print(frame) raise AssertionError() - async def test_request_timeout(self) -> None: - request = self.client._build_request(FinalRequestOptions(method="get", url="/foo")) + async def test_request_timeout(self, async_client: AsyncOpencode) -> None: + request = async_client._build_request(FinalRequestOptions(method="get", url="/foo")) timeout = httpx.Timeout(**request.extensions["timeout"]) # type: ignore assert timeout == DEFAULT_TIMEOUT - request = self.client._build_request( + request = async_client._build_request( FinalRequestOptions(method="get", url="/foo", timeout=httpx.Timeout(100.0)) ) timeout = httpx.Timeout(**request.extensions["timeout"]) # type: ignore @@ -1037,6 +1054,8 @@ async def test_client_timeout_option(self) -> None: timeout = httpx.Timeout(**request.extensions["timeout"]) # type: ignore assert timeout == httpx.Timeout(0) + await client.close() + async def test_http_client_timeout_option(self) -> None: # custom timeout given to the httpx client should be used async with httpx.AsyncClient(timeout=None) as http_client: @@ -1046,6 +1065,8 @@ async def test_http_client_timeout_option(self) -> None: timeout = httpx.Timeout(**request.extensions["timeout"]) # type: ignore assert timeout == httpx.Timeout(None) + await client.close() + # no timeout given to the httpx client should not use the httpx default async with httpx.AsyncClient() as http_client: client = AsyncOpencode(base_url=base_url, _strict_response_validation=True, http_client=http_client) @@ -1054,6 +1075,8 @@ async def test_http_client_timeout_option(self) -> None: timeout = httpx.Timeout(**request.extensions["timeout"]) # type: ignore assert timeout == DEFAULT_TIMEOUT + await client.close() + # explicitly passing the default timeout currently results in it being ignored async with httpx.AsyncClient(timeout=HTTPX_DEFAULT_TIMEOUT) as http_client: client = AsyncOpencode(base_url=base_url, _strict_response_validation=True, http_client=http_client) @@ -1062,18 +1085,22 @@ async def test_http_client_timeout_option(self) -> None: timeout = httpx.Timeout(**request.extensions["timeout"]) # type: ignore assert timeout == DEFAULT_TIMEOUT # our default + await client.close() + def test_invalid_http_client(self) -> None: with pytest.raises(TypeError, match="Invalid `http_client` arg"): with httpx.Client() as http_client: AsyncOpencode(base_url=base_url, _strict_response_validation=True, http_client=cast(Any, http_client)) - def test_default_headers_option(self) -> None: - client = AsyncOpencode(base_url=base_url, _strict_response_validation=True, default_headers={"X-Foo": "bar"}) - request = client._build_request(FinalRequestOptions(method="get", url="/foo")) + async def test_default_headers_option(self) -> None: + test_client = AsyncOpencode( + base_url=base_url, _strict_response_validation=True, default_headers={"X-Foo": "bar"} + ) + request = test_client._build_request(FinalRequestOptions(method="get", url="/foo")) assert request.headers.get("x-foo") == "bar" assert request.headers.get("x-stainless-lang") == "python" - client2 = AsyncOpencode( + test_client2 = AsyncOpencode( base_url=base_url, _strict_response_validation=True, default_headers={ @@ -1081,11 +1108,14 @@ def test_default_headers_option(self) -> None: "X-Stainless-Lang": "my-overriding-header", }, ) - request = client2._build_request(FinalRequestOptions(method="get", url="/foo")) + request = test_client2._build_request(FinalRequestOptions(method="get", url="/foo")) assert request.headers.get("x-foo") == "stainless" assert request.headers.get("x-stainless-lang") == "my-overriding-header" - def test_default_query_option(self) -> None: + await test_client.close() + await test_client2.close() + + async def test_default_query_option(self) -> None: client = AsyncOpencode( base_url=base_url, _strict_response_validation=True, default_query={"query_param": "bar"} ) @@ -1103,8 +1133,10 @@ def test_default_query_option(self) -> None: url = httpx.URL(request.url) assert dict(url.params) == {"foo": "baz", "query_param": "overridden"} - def test_request_extra_json(self) -> None: - request = self.client._build_request( + await client.close() + + def test_request_extra_json(self, client: Opencode) -> None: + request = client._build_request( FinalRequestOptions( method="post", url="/foo", @@ -1115,7 +1147,7 @@ def test_request_extra_json(self) -> None: data = json.loads(request.content.decode("utf-8")) assert data == {"foo": "bar", "baz": False} - request = self.client._build_request( + request = client._build_request( FinalRequestOptions( method="post", url="/foo", @@ -1126,7 +1158,7 @@ def test_request_extra_json(self) -> None: assert data == {"baz": False} # `extra_json` takes priority over `json_data` when keys clash - request = self.client._build_request( + request = client._build_request( FinalRequestOptions( method="post", url="/foo", @@ -1137,8 +1169,8 @@ def test_request_extra_json(self) -> None: data = json.loads(request.content.decode("utf-8")) assert data == {"foo": "bar", "baz": None} - def test_request_extra_headers(self) -> None: - request = self.client._build_request( + def test_request_extra_headers(self, client: Opencode) -> None: + request = client._build_request( FinalRequestOptions( method="post", url="/foo", @@ -1148,7 +1180,7 @@ def test_request_extra_headers(self) -> None: assert request.headers.get("X-Foo") == "Foo" # `extra_headers` takes priority over `default_headers` when keys clash - request = self.client.with_options(default_headers={"X-Bar": "true"})._build_request( + request = client.with_options(default_headers={"X-Bar": "true"})._build_request( FinalRequestOptions( method="post", url="/foo", @@ -1159,8 +1191,8 @@ def test_request_extra_headers(self) -> None: ) assert request.headers.get("X-Bar") == "false" - def test_request_extra_query(self) -> None: - request = self.client._build_request( + def test_request_extra_query(self, client: Opencode) -> None: + request = client._build_request( FinalRequestOptions( method="post", url="/foo", @@ -1173,7 +1205,7 @@ def test_request_extra_query(self) -> None: assert params == {"my_query_param": "Foo"} # if both `query` and `extra_query` are given, they are merged - request = self.client._build_request( + request = client._build_request( FinalRequestOptions( method="post", url="/foo", @@ -1187,7 +1219,7 @@ def test_request_extra_query(self) -> None: assert params == {"bar": "1", "foo": "2"} # `extra_query` takes priority over `query` when keys clash - request = self.client._build_request( + request = client._build_request( FinalRequestOptions( method="post", url="/foo", @@ -1230,7 +1262,7 @@ def test_multipart_repeating_array(self, async_client: AsyncOpencode) -> None: ] @pytest.mark.respx(base_url=base_url) - async def test_basic_union_response(self, respx_mock: MockRouter) -> None: + async def test_basic_union_response(self, respx_mock: MockRouter, async_client: AsyncOpencode) -> None: class Model1(BaseModel): name: str @@ -1239,12 +1271,12 @@ class Model2(BaseModel): respx_mock.get("/foo").mock(return_value=httpx.Response(200, json={"foo": "bar"})) - response = await self.client.get("/foo", cast_to=cast(Any, Union[Model1, Model2])) + response = await async_client.get("/foo", cast_to=cast(Any, Union[Model1, Model2])) assert isinstance(response, Model2) assert response.foo == "bar" @pytest.mark.respx(base_url=base_url) - async def test_union_response_different_types(self, respx_mock: MockRouter) -> None: + async def test_union_response_different_types(self, respx_mock: MockRouter, async_client: AsyncOpencode) -> None: """Union of objects with the same field name using a different type""" class Model1(BaseModel): @@ -1255,18 +1287,20 @@ class Model2(BaseModel): respx_mock.get("/foo").mock(return_value=httpx.Response(200, json={"foo": "bar"})) - response = await self.client.get("/foo", cast_to=cast(Any, Union[Model1, Model2])) + response = await async_client.get("/foo", cast_to=cast(Any, Union[Model1, Model2])) assert isinstance(response, Model2) assert response.foo == "bar" respx_mock.get("/foo").mock(return_value=httpx.Response(200, json={"foo": 1})) - response = await self.client.get("/foo", cast_to=cast(Any, Union[Model1, Model2])) + response = await async_client.get("/foo", cast_to=cast(Any, Union[Model1, Model2])) assert isinstance(response, Model1) assert response.foo == 1 @pytest.mark.respx(base_url=base_url) - async def test_non_application_json_content_type_for_json_data(self, respx_mock: MockRouter) -> None: + async def test_non_application_json_content_type_for_json_data( + self, respx_mock: MockRouter, async_client: AsyncOpencode + ) -> None: """ Response that sets Content-Type to something other than application/json but returns json data """ @@ -1282,11 +1316,11 @@ class Model(BaseModel): ) ) - response = await self.client.get("/foo", cast_to=Model) + response = await async_client.get("/foo", cast_to=Model) assert isinstance(response, Model) assert response.foo == 2 - def test_base_url_setter(self) -> None: + async def test_base_url_setter(self) -> None: client = AsyncOpencode(base_url="https://example.com/from_init", _strict_response_validation=True) assert client.base_url == "https://example.com/from_init/" @@ -1294,7 +1328,9 @@ def test_base_url_setter(self) -> None: assert client.base_url == "https://example.com/from_setter/" - def test_base_url_env(self) -> None: + await client.close() + + async def test_base_url_env(self) -> None: with update_env(OPENCODE_BASE_URL="http://localhost:5000/from/env"): client = AsyncOpencode(_strict_response_validation=True) assert client.base_url == "http://localhost:5000/from/env/" @@ -1311,7 +1347,7 @@ def test_base_url_env(self) -> None: ], ids=["standard", "custom http client"], ) - def test_base_url_trailing_slash(self, client: AsyncOpencode) -> None: + async def test_base_url_trailing_slash(self, client: AsyncOpencode) -> None: request = client._build_request( FinalRequestOptions( method="post", @@ -1320,6 +1356,7 @@ def test_base_url_trailing_slash(self, client: AsyncOpencode) -> None: ), ) assert request.url == "http://localhost:5000/custom/path/foo" + await client.close() @pytest.mark.parametrize( "client", @@ -1333,7 +1370,7 @@ def test_base_url_trailing_slash(self, client: AsyncOpencode) -> None: ], ids=["standard", "custom http client"], ) - def test_base_url_no_trailing_slash(self, client: AsyncOpencode) -> None: + async def test_base_url_no_trailing_slash(self, client: AsyncOpencode) -> None: request = client._build_request( FinalRequestOptions( method="post", @@ -1342,6 +1379,7 @@ def test_base_url_no_trailing_slash(self, client: AsyncOpencode) -> None: ), ) assert request.url == "http://localhost:5000/custom/path/foo" + await client.close() @pytest.mark.parametrize( "client", @@ -1355,7 +1393,7 @@ def test_base_url_no_trailing_slash(self, client: AsyncOpencode) -> None: ], ids=["standard", "custom http client"], ) - def test_absolute_request_url(self, client: AsyncOpencode) -> None: + async def test_absolute_request_url(self, client: AsyncOpencode) -> None: request = client._build_request( FinalRequestOptions( method="post", @@ -1364,37 +1402,37 @@ def test_absolute_request_url(self, client: AsyncOpencode) -> None: ), ) assert request.url == "https://myapi.com/foo" + await client.close() async def test_copied_client_does_not_close_http(self) -> None: - client = AsyncOpencode(base_url=base_url, _strict_response_validation=True) - assert not client.is_closed() + test_client = AsyncOpencode(base_url=base_url, _strict_response_validation=True) + assert not test_client.is_closed() - copied = client.copy() - assert copied is not client + copied = test_client.copy() + assert copied is not test_client del copied await asyncio.sleep(0.2) - assert not client.is_closed() + assert not test_client.is_closed() async def test_client_context_manager(self) -> None: - client = AsyncOpencode(base_url=base_url, _strict_response_validation=True) - async with client as c2: - assert c2 is client + test_client = AsyncOpencode(base_url=base_url, _strict_response_validation=True) + async with test_client as c2: + assert c2 is test_client assert not c2.is_closed() - assert not client.is_closed() - assert client.is_closed() + assert not test_client.is_closed() + assert test_client.is_closed() @pytest.mark.respx(base_url=base_url) - @pytest.mark.asyncio - async def test_client_response_validation_error(self, respx_mock: MockRouter) -> None: + async def test_client_response_validation_error(self, respx_mock: MockRouter, async_client: AsyncOpencode) -> None: class Model(BaseModel): foo: str respx_mock.get("/foo").mock(return_value=httpx.Response(200, json={"foo": {"invalid": True}})) with pytest.raises(APIResponseValidationError) as exc: - await self.client.get("/foo", cast_to=Model) + await async_client.get("/foo", cast_to=Model) assert isinstance(exc.value.__cause__, ValidationError) @@ -1403,19 +1441,17 @@ async def test_client_max_retries_validation(self) -> None: AsyncOpencode(base_url=base_url, _strict_response_validation=True, max_retries=cast(Any, None)) @pytest.mark.respx(base_url=base_url) - @pytest.mark.asyncio - async def test_default_stream_cls(self, respx_mock: MockRouter) -> None: + async def test_default_stream_cls(self, respx_mock: MockRouter, async_client: AsyncOpencode) -> None: class Model(BaseModel): name: str respx_mock.post("/foo").mock(return_value=httpx.Response(200, json={"foo": "bar"})) - stream = await self.client.post("/foo", cast_to=Model, stream=True, stream_cls=AsyncStream[Model]) + stream = await async_client.post("/foo", cast_to=Model, stream=True, stream_cls=AsyncStream[Model]) assert isinstance(stream, AsyncStream) await stream.response.aclose() @pytest.mark.respx(base_url=base_url) - @pytest.mark.asyncio async def test_received_text_for_expected_json(self, respx_mock: MockRouter) -> None: class Model(BaseModel): name: str @@ -1427,11 +1463,14 @@ class Model(BaseModel): with pytest.raises(APIResponseValidationError): await strict_client.get("/foo", cast_to=Model) - client = AsyncOpencode(base_url=base_url, _strict_response_validation=False) + non_strict_client = AsyncOpencode(base_url=base_url, _strict_response_validation=False) - response = await client.get("/foo", cast_to=Model) + response = await non_strict_client.get("/foo", cast_to=Model) assert isinstance(response, str) # type: ignore[unreachable] + await strict_client.close() + await non_strict_client.close() + @pytest.mark.parametrize( "remaining_retries,retry_after,timeout", [ @@ -1454,13 +1493,12 @@ class Model(BaseModel): ], ) @mock.patch("time.time", mock.MagicMock(return_value=1696004797)) - @pytest.mark.asyncio - async def test_parse_retry_after_header(self, remaining_retries: int, retry_after: str, timeout: float) -> None: - client = AsyncOpencode(base_url=base_url, _strict_response_validation=True) - + async def test_parse_retry_after_header( + self, remaining_retries: int, retry_after: str, timeout: float, async_client: AsyncOpencode + ) -> None: headers = httpx.Headers({"retry-after": retry_after}) options = FinalRequestOptions(method="get", url="/foo", max_retries=3) - calculated = client._calculate_retry_timeout(remaining_retries, options, headers) + calculated = async_client._calculate_retry_timeout(remaining_retries, options, headers) assert calculated == pytest.approx(timeout, 0.5 * 0.875) # pyright: ignore[reportUnknownMemberType] @mock.patch("opencode_ai._base_client.BaseClient._calculate_retry_timeout", _low_retry_timeout) @@ -1473,7 +1511,7 @@ async def test_retrying_timeout_errors_doesnt_leak( with pytest.raises(APITimeoutError): await async_client.session.with_streaming_response.list().__aenter__() - assert _get_open_connections(self.client) == 0 + assert _get_open_connections(async_client) == 0 @mock.patch("opencode_ai._base_client.BaseClient._calculate_retry_timeout", _low_retry_timeout) @pytest.mark.respx(base_url=base_url) @@ -1484,12 +1522,11 @@ async def test_retrying_status_errors_doesnt_leak( with pytest.raises(APIStatusError): await async_client.session.with_streaming_response.list().__aenter__() - assert _get_open_connections(self.client) == 0 + assert _get_open_connections(async_client) == 0 @pytest.mark.parametrize("failures_before_success", [0, 2, 4]) @mock.patch("opencode_ai._base_client.BaseClient._calculate_retry_timeout", _low_retry_timeout) @pytest.mark.respx(base_url=base_url) - @pytest.mark.asyncio @pytest.mark.parametrize("failure_mode", ["status", "exception"]) async def test_retries_taken( self, @@ -1521,7 +1558,6 @@ def retry_handler(_request: httpx.Request) -> httpx.Response: @pytest.mark.parametrize("failures_before_success", [0, 2, 4]) @mock.patch("opencode_ai._base_client.BaseClient._calculate_retry_timeout", _low_retry_timeout) @pytest.mark.respx(base_url=base_url) - @pytest.mark.asyncio async def test_omit_retry_count_header( self, async_client: AsyncOpencode, failures_before_success: int, respx_mock: MockRouter ) -> None: @@ -1545,7 +1581,6 @@ def retry_handler(_request: httpx.Request) -> httpx.Response: @pytest.mark.parametrize("failures_before_success", [0, 2, 4]) @mock.patch("opencode_ai._base_client.BaseClient._calculate_retry_timeout", _low_retry_timeout) @pytest.mark.respx(base_url=base_url) - @pytest.mark.asyncio async def test_overwrite_retry_count_header( self, async_client: AsyncOpencode, failures_before_success: int, respx_mock: MockRouter ) -> None: @@ -1593,26 +1628,26 @@ async def test_default_client_creation(self) -> None: ) @pytest.mark.respx(base_url=base_url) - async def test_follow_redirects(self, respx_mock: MockRouter) -> None: + async def test_follow_redirects(self, respx_mock: MockRouter, async_client: AsyncOpencode) -> None: # Test that the default follow_redirects=True allows following redirects respx_mock.post("/redirect").mock( return_value=httpx.Response(302, headers={"Location": f"{base_url}/redirected"}) ) respx_mock.get("/redirected").mock(return_value=httpx.Response(200, json={"status": "ok"})) - response = await self.client.post("/redirect", body={"key": "value"}, cast_to=httpx.Response) + response = await async_client.post("/redirect", body={"key": "value"}, cast_to=httpx.Response) assert response.status_code == 200 assert response.json() == {"status": "ok"} @pytest.mark.respx(base_url=base_url) - async def test_follow_redirects_disabled(self, respx_mock: MockRouter) -> None: + async def test_follow_redirects_disabled(self, respx_mock: MockRouter, async_client: AsyncOpencode) -> None: # Test that follow_redirects=False prevents following redirects respx_mock.post("/redirect").mock( return_value=httpx.Response(302, headers={"Location": f"{base_url}/redirected"}) ) with pytest.raises(APIStatusError) as exc_info: - await self.client.post( + await async_client.post( "/redirect", body={"key": "value"}, options={"follow_redirects": False}, cast_to=httpx.Response ) From 04093bbe319065b7869da83fbaa77e9a29038e9c Mon Sep 17 00:00:00 2001 From: "stainless-app[bot]" <142633134+stainless-app[bot]@users.noreply.github.com> Date: Tue, 4 Nov 2025 03:42:14 +0000 Subject: [PATCH 14/20] chore(internal): codegen related update --- src/opencode_ai/_utils/_utils.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/opencode_ai/_utils/_utils.py b/src/opencode_ai/_utils/_utils.py index 50d5926..eec7f4a 100644 --- a/src/opencode_ai/_utils/_utils.py +++ b/src/opencode_ai/_utils/_utils.py @@ -133,7 +133,7 @@ def is_given(obj: _T | NotGiven | Omit) -> TypeGuard[_T]: # Type safe methods for narrowing types with TypeVars. # The default narrowing for isinstance(obj, dict) is dict[unknown, unknown], # however this cause Pyright to rightfully report errors. As we know we don't -# care about the contained types we can safely use `object` in it's place. +# care about the contained types we can safely use `object` in its place. # # There are two separate functions defined, `is_*` and `is_*_t` for different use cases. # `is_*` is for when you're dealing with an unknown input From 568efcac0c2684cceaf8f24673f36b94b00be0e3 Mon Sep 17 00:00:00 2001 From: "stainless-app[bot]" <142633134+stainless-app[bot]@users.noreply.github.com> Date: Tue, 11 Nov 2025 03:37:10 +0000 Subject: [PATCH 15/20] chore(internal): codegen related update --- README.md | 4 ++-- pyproject.toml | 5 ++--- src/opencode_ai/_models.py | 11 ++++++++--- src/opencode_ai/_utils/_sync.py | 34 +++------------------------------ tests/test_models.py | 8 ++++---- 5 files changed, 19 insertions(+), 43 deletions(-) diff --git a/README.md b/README.md index dbac892..6dc0e2a 100644 --- a/README.md +++ b/README.md @@ -3,7 +3,7 @@ [![PyPI version](https://img.shields.io/pypi/v/opencode-ai.svg?label=pypi%20(stable))](https://pypi.org/project/opencode-ai/) -The Opencode Python library provides convenient access to the Opencode REST API from any Python 3.8+ +The Opencode Python library provides convenient access to the Opencode REST API from any Python 3.9+ application. The library includes type definitions for all request params and response fields, and offers both synchronous and asynchronous clients powered by [httpx](https://github.com/encode/httpx). @@ -392,7 +392,7 @@ print(opencode_ai.__version__) ## Requirements -Python 3.8 or higher. +Python 3.9 or higher. ## Contributing diff --git a/pyproject.toml b/pyproject.toml index ed74dd3..4a731f6 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -15,11 +15,10 @@ dependencies = [ "distro>=1.7.0, <2", "sniffio", ] -requires-python = ">= 3.8" +requires-python = ">= 3.9" classifiers = [ "Typing :: Typed", "Intended Audience :: Developers", - "Programming Language :: Python :: 3.8", "Programming Language :: Python :: 3.9", "Programming Language :: Python :: 3.10", "Programming Language :: Python :: 3.11", @@ -141,7 +140,7 @@ filterwarnings = [ # there are a couple of flags that are still disabled by # default in strict mode as they are experimental and niche. typeCheckingMode = "strict" -pythonVersion = "3.8" +pythonVersion = "3.9" exclude = [ "_dev", diff --git a/src/opencode_ai/_models.py b/src/opencode_ai/_models.py index 6a3cd1d..fcec2cf 100644 --- a/src/opencode_ai/_models.py +++ b/src/opencode_ai/_models.py @@ -2,6 +2,7 @@ import os import inspect +import weakref from typing import TYPE_CHECKING, Any, Type, Union, Generic, TypeVar, Callable, Optional, cast from datetime import date, datetime from typing_extensions import ( @@ -573,6 +574,9 @@ class CachedDiscriminatorType(Protocol): __discriminator__: DiscriminatorDetails +DISCRIMINATOR_CACHE: weakref.WeakKeyDictionary[type, DiscriminatorDetails] = weakref.WeakKeyDictionary() + + class DiscriminatorDetails: field_name: str """The name of the discriminator field in the variant class, e.g. @@ -615,8 +619,9 @@ def __init__( def _build_discriminated_union_meta(*, union: type, meta_annotations: tuple[Any, ...]) -> DiscriminatorDetails | None: - if isinstance(union, CachedDiscriminatorType): - return union.__discriminator__ + cached = DISCRIMINATOR_CACHE.get(union) + if cached is not None: + return cached discriminator_field_name: str | None = None @@ -669,7 +674,7 @@ def _build_discriminated_union_meta(*, union: type, meta_annotations: tuple[Any, discriminator_field=discriminator_field_name, discriminator_alias=discriminator_alias, ) - cast(CachedDiscriminatorType, union).__discriminator__ = details + DISCRIMINATOR_CACHE.setdefault(union, details) return details diff --git a/src/opencode_ai/_utils/_sync.py b/src/opencode_ai/_utils/_sync.py index ad7ec71..f6027c1 100644 --- a/src/opencode_ai/_utils/_sync.py +++ b/src/opencode_ai/_utils/_sync.py @@ -1,10 +1,8 @@ from __future__ import annotations -import sys import asyncio import functools -import contextvars -from typing import Any, TypeVar, Callable, Awaitable +from typing import TypeVar, Callable, Awaitable from typing_extensions import ParamSpec import anyio @@ -15,34 +13,11 @@ T_ParamSpec = ParamSpec("T_ParamSpec") -if sys.version_info >= (3, 9): - _asyncio_to_thread = asyncio.to_thread -else: - # backport of https://docs.python.org/3/library/asyncio-task.html#asyncio.to_thread - # for Python 3.8 support - async def _asyncio_to_thread( - func: Callable[T_ParamSpec, T_Retval], /, *args: T_ParamSpec.args, **kwargs: T_ParamSpec.kwargs - ) -> Any: - """Asynchronously run function *func* in a separate thread. - - Any *args and **kwargs supplied for this function are directly passed - to *func*. Also, the current :class:`contextvars.Context` is propagated, - allowing context variables from the main thread to be accessed in the - separate thread. - - Returns a coroutine that can be awaited to get the eventual result of *func*. - """ - loop = asyncio.events.get_running_loop() - ctx = contextvars.copy_context() - func_call = functools.partial(ctx.run, func, *args, **kwargs) - return await loop.run_in_executor(None, func_call) - - async def to_thread( func: Callable[T_ParamSpec, T_Retval], /, *args: T_ParamSpec.args, **kwargs: T_ParamSpec.kwargs ) -> T_Retval: if sniffio.current_async_library() == "asyncio": - return await _asyncio_to_thread(func, *args, **kwargs) + return await asyncio.to_thread(func, *args, **kwargs) return await anyio.to_thread.run_sync( functools.partial(func, *args, **kwargs), @@ -53,10 +28,7 @@ async def to_thread( def asyncify(function: Callable[T_ParamSpec, T_Retval]) -> Callable[T_ParamSpec, Awaitable[T_Retval]]: """ Take a blocking function and create an async one that receives the same - positional and keyword arguments. For python version 3.9 and above, it uses - asyncio.to_thread to run the function in a separate thread. For python version - 3.8, it uses locally defined copy of the asyncio.to_thread function which was - introduced in python 3.9. + positional and keyword arguments. Usage: diff --git a/tests/test_models.py b/tests/test_models.py index c4ab3f0..f5c177f 100644 --- a/tests/test_models.py +++ b/tests/test_models.py @@ -9,7 +9,7 @@ from opencode_ai._utils import PropertyInfo from opencode_ai._compat import PYDANTIC_V1, parse_obj, model_dump, model_json -from opencode_ai._models import BaseModel, construct_type +from opencode_ai._models import DISCRIMINATOR_CACHE, BaseModel, construct_type class BasicModel(BaseModel): @@ -809,7 +809,7 @@ class B(BaseModel): UnionType = cast(Any, Union[A, B]) - assert not hasattr(UnionType, "__discriminator__") + assert not DISCRIMINATOR_CACHE.get(UnionType) m = construct_type( value={"type": "b", "data": "foo"}, type_=cast(Any, Annotated[UnionType, PropertyInfo(discriminator="type")]) @@ -818,7 +818,7 @@ class B(BaseModel): assert m.type == "b" assert m.data == "foo" # type: ignore[comparison-overlap] - discriminator = UnionType.__discriminator__ + discriminator = DISCRIMINATOR_CACHE.get(UnionType) assert discriminator is not None m = construct_type( @@ -830,7 +830,7 @@ class B(BaseModel): # if the discriminator details object stays the same between invocations then # we hit the cache - assert UnionType.__discriminator__ is discriminator + assert DISCRIMINATOR_CACHE.get(UnionType) is discriminator @pytest.mark.skipif(PYDANTIC_V1, reason="TypeAliasType is not supported in Pydantic v1") From 0ccd3fe9df64a7bff4a66426d3610c03321aee37 Mon Sep 17 00:00:00 2001 From: "stainless-app[bot]" <142633134+stainless-app[bot]@users.noreply.github.com> Date: Wed, 12 Nov 2025 03:32:07 +0000 Subject: [PATCH 16/20] chore(internal): codegen related update --- src/opencode_ai/_models.py | 41 +++++++++++++++++++++++++++----------- 1 file changed, 29 insertions(+), 12 deletions(-) diff --git a/src/opencode_ai/_models.py b/src/opencode_ai/_models.py index fcec2cf..ca9500b 100644 --- a/src/opencode_ai/_models.py +++ b/src/opencode_ai/_models.py @@ -257,15 +257,16 @@ def model_dump( mode: Literal["json", "python"] | str = "python", include: IncEx | None = None, exclude: IncEx | None = None, + context: Any | None = None, by_alias: bool | None = None, exclude_unset: bool = False, exclude_defaults: bool = False, exclude_none: bool = False, + exclude_computed_fields: bool = False, round_trip: bool = False, warnings: bool | Literal["none", "warn", "error"] = True, - context: dict[str, Any] | None = None, - serialize_as_any: bool = False, fallback: Callable[[Any], Any] | None = None, + serialize_as_any: bool = False, ) -> dict[str, Any]: """Usage docs: https://docs.pydantic.dev/2.4/concepts/serialization/#modelmodel_dump @@ -273,16 +274,24 @@ def model_dump( Args: mode: The mode in which `to_python` should run. - If mode is 'json', the dictionary will only contain JSON serializable types. - If mode is 'python', the dictionary may contain any Python objects. - include: A list of fields to include in the output. - exclude: A list of fields to exclude from the output. + If mode is 'json', the output will only contain JSON serializable types. + If mode is 'python', the output may contain non-JSON-serializable Python objects. + include: A set of fields to include in the output. + exclude: A set of fields to exclude from the output. + context: Additional context to pass to the serializer. by_alias: Whether to use the field's alias in the dictionary key if defined. - exclude_unset: Whether to exclude fields that are unset or None from the output. - exclude_defaults: Whether to exclude fields that are set to their default value from the output. - exclude_none: Whether to exclude fields that have a value of `None` from the output. - round_trip: Whether to enable serialization and deserialization round-trip support. - warnings: Whether to log warnings when invalid fields are encountered. + exclude_unset: Whether to exclude fields that have not been explicitly set. + exclude_defaults: Whether to exclude fields that are set to their default value. + exclude_none: Whether to exclude fields that have a value of `None`. + exclude_computed_fields: Whether to exclude computed fields. + While this can be useful for round-tripping, it is usually recommended to use the dedicated + `round_trip` parameter instead. + round_trip: If True, dumped values should be valid as input for non-idempotent types such as Json[T]. + warnings: How to handle serialization errors. False/"none" ignores them, True/"warn" logs errors, + "error" raises a [`PydanticSerializationError`][pydantic_core.PydanticSerializationError]. + fallback: A function to call when an unknown value is encountered. If not provided, + a [`PydanticSerializationError`][pydantic_core.PydanticSerializationError] error is raised. + serialize_as_any: Whether to serialize fields with duck-typing serialization behavior. Returns: A dictionary representation of the model. @@ -299,6 +308,8 @@ def model_dump( raise ValueError("serialize_as_any is only supported in Pydantic v2") if fallback is not None: raise ValueError("fallback is only supported in Pydantic v2") + if exclude_computed_fields != False: + raise ValueError("exclude_computed_fields is only supported in Pydantic v2") dumped = super().dict( # pyright: ignore[reportDeprecated] include=include, exclude=exclude, @@ -315,15 +326,17 @@ def model_dump_json( self, *, indent: int | None = None, + ensure_ascii: bool = False, include: IncEx | None = None, exclude: IncEx | None = None, + context: Any | None = None, by_alias: bool | None = None, exclude_unset: bool = False, exclude_defaults: bool = False, exclude_none: bool = False, + exclude_computed_fields: bool = False, round_trip: bool = False, warnings: bool | Literal["none", "warn", "error"] = True, - context: dict[str, Any] | None = None, fallback: Callable[[Any], Any] | None = None, serialize_as_any: bool = False, ) -> str: @@ -355,6 +368,10 @@ def model_dump_json( raise ValueError("serialize_as_any is only supported in Pydantic v2") if fallback is not None: raise ValueError("fallback is only supported in Pydantic v2") + if ensure_ascii != False: + raise ValueError("ensure_ascii is only supported in Pydantic v2") + if exclude_computed_fields != False: + raise ValueError("exclude_computed_fields is only supported in Pydantic v2") return super().json( # type: ignore[reportDeprecated] indent=indent, include=include, From 875138a43a23834c7a1428e2a4d5c371af3e0299 Mon Sep 17 00:00:00 2001 From: "stainless-app[bot]" <142633134+stainless-app[bot]@users.noreply.github.com> Date: Sat, 22 Nov 2025 03:25:08 +0000 Subject: [PATCH 17/20] chore(internal): codegen related update --- pyproject.toml | 1 + 1 file changed, 1 insertion(+) diff --git a/pyproject.toml b/pyproject.toml index 4a731f6..707e4d4 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -24,6 +24,7 @@ classifiers = [ "Programming Language :: Python :: 3.11", "Programming Language :: Python :: 3.12", "Programming Language :: Python :: 3.13", + "Programming Language :: Python :: 3.14", "Operating System :: OS Independent", "Operating System :: POSIX", "Operating System :: MacOS", From ab5c4936b874a1e66f6719b03044aead20db01a1 Mon Sep 17 00:00:00 2001 From: "stainless-app[bot]" <142633134+stainless-app[bot]@users.noreply.github.com> Date: Fri, 28 Nov 2025 03:09:11 +0000 Subject: [PATCH 18/20] chore(internal): codegen related update --- pyproject.toml | 2 +- requirements-dev.lock | 4 +++- requirements.lock | 8 ++++---- src/opencode_ai/_streaming.py | 22 ++++++++++++---------- 4 files changed, 20 insertions(+), 16 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index 707e4d4..7002b9c 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -46,7 +46,7 @@ managed = true # version pins are in requirements-dev.lock dev-dependencies = [ "pyright==1.1.399", - "mypy", + "mypy==1.17", "respx", "pytest", "pytest-asyncio", diff --git a/requirements-dev.lock b/requirements-dev.lock index e68d6f3..cb60012 100644 --- a/requirements-dev.lock +++ b/requirements-dev.lock @@ -72,7 +72,7 @@ mdurl==0.1.2 multidict==6.4.4 # via aiohttp # via yarl -mypy==1.14.1 +mypy==1.17.0 mypy-extensions==1.0.0 # via mypy nodeenv==1.8.0 @@ -81,6 +81,8 @@ nox==2023.4.22 packaging==23.2 # via nox # via pytest +pathspec==0.12.1 + # via mypy platformdirs==3.11.0 # via virtualenv pluggy==1.5.0 diff --git a/requirements.lock b/requirements.lock index bd0c6f4..411320b 100644 --- a/requirements.lock +++ b/requirements.lock @@ -55,21 +55,21 @@ multidict==6.4.4 propcache==0.3.1 # via aiohttp # via yarl -pydantic==2.11.9 +pydantic==2.12.5 # via opencode-ai -pydantic-core==2.33.2 +pydantic-core==2.41.5 # via pydantic sniffio==1.3.0 # via anyio # via opencode-ai -typing-extensions==4.12.2 +typing-extensions==4.15.0 # via anyio # via multidict # via opencode-ai # via pydantic # via pydantic-core # via typing-inspection -typing-inspection==0.4.1 +typing-inspection==0.4.2 # via pydantic yarl==1.20.0 # via aiohttp diff --git a/src/opencode_ai/_streaming.py b/src/opencode_ai/_streaming.py index 5dc9d33..ae72550 100644 --- a/src/opencode_ai/_streaming.py +++ b/src/opencode_ai/_streaming.py @@ -54,11 +54,12 @@ def __stream__(self) -> Iterator[_T]: process_data = self._client._process_response_data iterator = self._iter_events() - for sse in iterator: - yield process_data(data=sse.json(), cast_to=cast_to, response=response) - - # As we might not fully consume the response stream, we need to close it explicitly - response.close() + try: + for sse in iterator: + yield process_data(data=sse.json(), cast_to=cast_to, response=response) + finally: + # Ensure the response is closed even if the consumer doesn't read all data + response.close() def __enter__(self) -> Self: return self @@ -117,11 +118,12 @@ async def __stream__(self) -> AsyncIterator[_T]: process_data = self._client._process_response_data iterator = self._iter_events() - async for sse in iterator: - yield process_data(data=sse.json(), cast_to=cast_to, response=response) - - # As we might not fully consume the response stream, we need to close it explicitly - await response.aclose() + try: + async for sse in iterator: + yield process_data(data=sse.json(), cast_to=cast_to, response=response) + finally: + # Ensure the response is closed even if the consumer doesn't read all data + await response.aclose() async def __aenter__(self) -> Self: return self From a59b154a2d44291e60f0b34f2a39a1849657b5a7 Mon Sep 17 00:00:00 2001 From: "stainless-app[bot]" <142633134+stainless-app[bot]@users.noreply.github.com> Date: Wed, 3 Dec 2025 03:57:15 +0000 Subject: [PATCH 19/20] chore(internal): codegen related update --- pyproject.toml | 14 +++--- requirements-dev.lock | 108 +++++++++++++++++++++++------------------- requirements.lock | 31 ++++++------ 3 files changed, 83 insertions(+), 70 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index 7002b9c..9c58134 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -7,14 +7,16 @@ license = "MIT" authors = [ { name = "Opencode", email = "support@sst.dev" }, ] + dependencies = [ - "httpx>=0.23.0, <1", - "pydantic>=1.9.0, <3", - "typing-extensions>=4.10, <5", - "anyio>=3.5.0, <5", - "distro>=1.7.0, <2", - "sniffio", + "httpx>=0.23.0, <1", + "pydantic>=1.9.0, <3", + "typing-extensions>=4.10, <5", + "anyio>=3.5.0, <5", + "distro>=1.7.0, <2", + "sniffio", ] + requires-python = ">= 3.9" classifiers = [ "Typing :: Typed", diff --git a/requirements-dev.lock b/requirements-dev.lock index cb60012..059e0e1 100644 --- a/requirements-dev.lock +++ b/requirements-dev.lock @@ -12,40 +12,45 @@ -e file:. aiohappyeyeballs==2.6.1 # via aiohttp -aiohttp==3.12.8 +aiohttp==3.13.2 # via httpx-aiohttp # via opencode-ai -aiosignal==1.3.2 +aiosignal==1.4.0 # via aiohttp -annotated-types==0.6.0 +annotated-types==0.7.0 # via pydantic -anyio==4.4.0 +anyio==4.12.0 # via httpx # via opencode-ai -argcomplete==3.1.2 +argcomplete==3.6.3 # via nox async-timeout==5.0.1 # via aiohttp -attrs==25.3.0 +attrs==25.4.0 # via aiohttp -certifi==2023.7.22 + # via nox +backports-asyncio-runner==1.2.0 + # via pytest-asyncio +certifi==2025.11.12 # via httpcore # via httpx -colorlog==6.7.0 +colorlog==6.10.1 + # via nox +dependency-groups==1.3.1 # via nox -dirty-equals==0.6.0 -distlib==0.3.7 +dirty-equals==0.11 +distlib==0.4.0 # via virtualenv -distro==1.8.0 +distro==1.9.0 # via opencode-ai -exceptiongroup==1.2.2 +exceptiongroup==1.3.1 # via anyio # via pytest -execnet==2.1.1 +execnet==2.1.2 # via pytest-xdist -filelock==3.12.4 +filelock==3.19.1 # via virtualenv -frozenlist==1.6.2 +frozenlist==1.8.0 # via aiohttp # via aiosignal h11==0.16.0 @@ -58,82 +63,87 @@ httpx==0.28.1 # via respx httpx-aiohttp==0.1.9 # via opencode-ai -idna==3.4 +humanize==4.13.0 + # via nox +idna==3.11 # via anyio # via httpx # via yarl -importlib-metadata==7.0.0 -iniconfig==2.0.0 +importlib-metadata==8.7.0 +iniconfig==2.1.0 # via pytest markdown-it-py==3.0.0 # via rich mdurl==0.1.2 # via markdown-it-py -multidict==6.4.4 +multidict==6.7.0 # via aiohttp # via yarl mypy==1.17.0 -mypy-extensions==1.0.0 +mypy-extensions==1.1.0 # via mypy -nodeenv==1.8.0 +nodeenv==1.9.1 # via pyright -nox==2023.4.22 -packaging==23.2 +nox==2025.11.12 +packaging==25.0 + # via dependency-groups # via nox # via pytest pathspec==0.12.1 # via mypy -platformdirs==3.11.0 +platformdirs==4.4.0 # via virtualenv -pluggy==1.5.0 +pluggy==1.6.0 # via pytest -propcache==0.3.1 +propcache==0.4.1 # via aiohttp # via yarl -pydantic==2.11.9 +pydantic==2.12.5 # via opencode-ai -pydantic-core==2.33.2 +pydantic-core==2.41.5 # via pydantic -pygments==2.18.0 +pygments==2.19.2 + # via pytest # via rich pyright==1.1.399 -pytest==8.3.3 +pytest==8.4.2 # via pytest-asyncio # via pytest-xdist -pytest-asyncio==0.24.0 -pytest-xdist==3.7.0 -python-dateutil==2.8.2 +pytest-asyncio==1.2.0 +pytest-xdist==3.8.0 +python-dateutil==2.9.0.post0 # via time-machine -pytz==2023.3.post1 - # via dirty-equals respx==0.22.0 -rich==13.7.1 -ruff==0.9.4 -setuptools==68.2.2 - # via nodeenv -six==1.16.0 +rich==14.2.0 +ruff==0.14.7 +six==1.17.0 # via python-dateutil -sniffio==1.3.0 - # via anyio +sniffio==1.3.1 # via opencode-ai -time-machine==2.9.0 -tomli==2.0.2 +time-machine==2.19.0 +tomli==2.3.0 + # via dependency-groups # via mypy + # via nox # via pytest -typing-extensions==4.12.2 +typing-extensions==4.15.0 + # via aiosignal # via anyio + # via exceptiongroup # via multidict # via mypy # via opencode-ai # via pydantic # via pydantic-core # via pyright + # via pytest-asyncio # via typing-inspection -typing-inspection==0.4.1 + # via virtualenv +typing-inspection==0.4.2 # via pydantic -virtualenv==20.24.5 +virtualenv==20.35.4 # via nox -yarl==1.20.0 +yarl==1.22.0 # via aiohttp -zipp==3.17.0 +zipp==3.23.0 # via importlib-metadata diff --git a/requirements.lock b/requirements.lock index 411320b..2a3b675 100644 --- a/requirements.lock +++ b/requirements.lock @@ -12,28 +12,28 @@ -e file:. aiohappyeyeballs==2.6.1 # via aiohttp -aiohttp==3.12.8 +aiohttp==3.13.2 # via httpx-aiohttp # via opencode-ai -aiosignal==1.3.2 +aiosignal==1.4.0 # via aiohttp -annotated-types==0.6.0 +annotated-types==0.7.0 # via pydantic -anyio==4.4.0 +anyio==4.12.0 # via httpx # via opencode-ai async-timeout==5.0.1 # via aiohttp -attrs==25.3.0 +attrs==25.4.0 # via aiohttp -certifi==2023.7.22 +certifi==2025.11.12 # via httpcore # via httpx -distro==1.8.0 +distro==1.9.0 # via opencode-ai -exceptiongroup==1.2.2 +exceptiongroup==1.3.1 # via anyio -frozenlist==1.6.2 +frozenlist==1.8.0 # via aiohttp # via aiosignal h11==0.16.0 @@ -45,25 +45,26 @@ httpx==0.28.1 # via opencode-ai httpx-aiohttp==0.1.9 # via opencode-ai -idna==3.4 +idna==3.11 # via anyio # via httpx # via yarl -multidict==6.4.4 +multidict==6.7.0 # via aiohttp # via yarl -propcache==0.3.1 +propcache==0.4.1 # via aiohttp # via yarl pydantic==2.12.5 # via opencode-ai pydantic-core==2.41.5 # via pydantic -sniffio==1.3.0 - # via anyio +sniffio==1.3.1 # via opencode-ai typing-extensions==4.15.0 + # via aiosignal # via anyio + # via exceptiongroup # via multidict # via opencode-ai # via pydantic @@ -71,5 +72,5 @@ typing-extensions==4.15.0 # via typing-inspection typing-inspection==0.4.2 # via pydantic -yarl==1.20.0 +yarl==1.22.0 # via aiohttp From 6c4c7013afc53e82fa29a9a36b6d2e77e88c8c7f Mon Sep 17 00:00:00 2001 From: "stainless-app[bot]" <142633134+stainless-app[bot]@users.noreply.github.com> Date: Wed, 3 Dec 2025 03:57:33 +0000 Subject: [PATCH 20/20] release: 0.1.0-alpha.37 --- .release-please-manifest.json | 2 +- CHANGELOG.md | 35 +++++++++++++++++++++++++++++++++++ pyproject.toml | 2 +- src/opencode_ai/_version.py | 2 +- 4 files changed, 38 insertions(+), 3 deletions(-) create mode 100644 CHANGELOG.md diff --git a/.release-please-manifest.json b/.release-please-manifest.json index a696b6a..154a697 100644 --- a/.release-please-manifest.json +++ b/.release-please-manifest.json @@ -1,3 +1,3 @@ { - ".": "0.1.0-alpha.36" + ".": "0.1.0-alpha.37" } \ No newline at end of file diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..ea302f0 --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,35 @@ +# Changelog + +## 0.1.0-alpha.37 (2025-12-03) + +Full Changelog: [v0.1.0-alpha.36...v0.1.0-alpha.37](https://github.com/sst/opencode-sdk-python/compare/v0.1.0-alpha.36...v0.1.0-alpha.37) + +### Features + +* **api:** manual updates ([979c43d](https://github.com/sst/opencode-sdk-python/commit/979c43dbc7f72a03ec5c16c02b800cccca95a24e)) +* improve future compat with pydantic v3 ([5d58a79](https://github.com/sst/opencode-sdk-python/commit/5d58a795ce9d8635b311c8eae4d5c719ecf5558a)) + + +### Bug Fixes + +* **client:** close streams without requiring full consumption ([81dc914](https://github.com/sst/opencode-sdk-python/commit/81dc9145e044aff113827b1bd7bc54f9aeb1db8e)) +* **compat:** compat with `pydantic<2.8.0` when using additional fields ([662246b](https://github.com/sst/opencode-sdk-python/commit/662246b39c07bcac1346f3033f36c4188a80196e)) + + +### Chores + +* bump `httpx-aiohttp` version to 0.1.9 ([92cb13d](https://github.com/sst/opencode-sdk-python/commit/92cb13de6acb1716afc4e51d0d865058d7eabbe0)) +* do not install brew dependencies in ./scripts/bootstrap by default ([74f0fa2](https://github.com/sst/opencode-sdk-python/commit/74f0fa24042592fdaa27e85a58ae7fc299a8e6e3)) +* **internal:** codegen related update ([a59b154](https://github.com/sst/opencode-sdk-python/commit/a59b154a2d44291e60f0b34f2a39a1849657b5a7)) +* **internal:** codegen related update ([ab5c493](https://github.com/sst/opencode-sdk-python/commit/ab5c4936b874a1e66f6719b03044aead20db01a1)) +* **internal:** codegen related update ([875138a](https://github.com/sst/opencode-sdk-python/commit/875138a43a23834c7a1428e2a4d5c371af3e0299)) +* **internal:** codegen related update ([0ccd3fe](https://github.com/sst/opencode-sdk-python/commit/0ccd3fe9df64a7bff4a66426d3610c03321aee37)) +* **internal:** codegen related update ([568efca](https://github.com/sst/opencode-sdk-python/commit/568efcac0c2684cceaf8f24673f36b94b00be0e3)) +* **internal:** codegen related update ([04093bb](https://github.com/sst/opencode-sdk-python/commit/04093bbe319065b7869da83fbaa77e9a29038e9c)) +* **internal:** codegen related update ([33f3521](https://github.com/sst/opencode-sdk-python/commit/33f3521d9b17fe39b2f13f24f6edd29e3ff26ef9)) +* **internal:** detect missing future annotations with ruff ([932d414](https://github.com/sst/opencode-sdk-python/commit/932d414bd019219a28c392790c5f031313a6b936)) +* **internal:** move mypy configurations to `pyproject.toml` file ([273dffa](https://github.com/sst/opencode-sdk-python/commit/273dffa4ac0653ed7bdb5455af4a5f640c48f91c)) +* **internal:** update pydantic dependency ([6ae1aed](https://github.com/sst/opencode-sdk-python/commit/6ae1aed3aab37a20707d677b943bdd8c5ee3d4bf)) +* sync repo ([9b3134a](https://github.com/sst/opencode-sdk-python/commit/9b3134a27e8d3db940bf76a6bd4b78321047208a)) +* **tests:** simplify `get_platform` test ([0a47e60](https://github.com/sst/opencode-sdk-python/commit/0a47e601d1cbcf879694ce130d9f2e1729a728bb)) +* **types:** change optional parameter type from NotGiven to Omit ([ed48f59](https://github.com/sst/opencode-sdk-python/commit/ed48f59b5316e425d3ae13e9c493848b37ffc03e)) diff --git a/pyproject.toml b/pyproject.toml index 9c58134..b42a659 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "opencode-ai" -version = "0.1.0-alpha.36" +version = "0.1.0-alpha.37" description = "The official Python library for the opencode API" dynamic = ["readme"] license = "MIT" diff --git a/src/opencode_ai/_version.py b/src/opencode_ai/_version.py index 3e57475..565754d 100644 --- a/src/opencode_ai/_version.py +++ b/src/opencode_ai/_version.py @@ -1,4 +1,4 @@ # File generated from our OpenAPI spec by Stainless. See CONTRIBUTING.md for details. __title__ = "opencode_ai" -__version__ = "0.1.0-alpha.36" # x-release-please-version +__version__ = "0.1.0-alpha.37" # x-release-please-version