diff --git a/.devcontainer/Dockerfile b/.devcontainer/Dockerfile new file mode 100644 index 00000000..ff261bad --- /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 00000000..c17fdc16 --- /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 00000000..1066505c --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,104 @@ +name: CI +on: + push: + branches: + - '**' + - '!integrated/**' + - '!stl-preview-head/**' + - '!stl-preview-base/**' + - '!generated' + - '!codegen/**' + - 'codegen/stl/**' + pull_request: + branches-ignore: + - 'stl-preview-head/**' + - 'stl-preview-base/**' + +jobs: + lint: + timeout-minutes: 10 + name: lint + runs-on: ${{ startsWith(github.repository, 'stainless-sdks/') && 'depot-ubuntu-24.04' || 'ubuntu-latest' }} + if: (github.event_name == 'push' || github.event.pull_request.head.repo.fork) && (github.event_name != 'push' || github.event.head_commit.message != 'codegen metadata') + steps: + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + + - 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) && (github.event_name != 'push' || github.event.head_commit.message != 'codegen metadata') + timeout-minutes: 10 + name: build + permissions: + contents: read + id-token: write + runs-on: ${{ startsWith(github.repository, 'stainless-sdks/') && 'depot-ubuntu-24.04' || 'ubuntu-latest' }} + steps: + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + + - 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/kernel-python' && + !startsWith(github.ref, 'refs/heads/stl/') + id: github-oidc + uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8.0.0 + with: + script: core.setOutput('github_token', await core.getIDToken()); + + - name: Upload tarball + if: |- + github.repository == 'stainless-sdks/kernel-python' && + !startsWith(github.ref, 'refs/heads/stl/') + 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: ${{ startsWith(github.repository, 'stainless-sdks/') && 'depot-ubuntu-24.04' || 'ubuntu-latest' }} + if: github.event_name == 'push' || github.event.pull_request.head.repo.fork + steps: + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + + - 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 00000000..2e7e7190 --- /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/kernel/kernel-python-sdk/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@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + + - 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.KERNEL_PYPI_TOKEN || secrets.PYPI_TOKEN }} diff --git a/.github/workflows/release-doctor.yml b/.github/workflows/release-doctor.yml new file mode 100644 index 00000000..057d4a7f --- /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 == 'kernel/kernel-python-sdk' && (github.event_name == 'push' || github.event_name == 'workflow_dispatch' || startsWith(github.head_ref, 'release-please') || github.head_ref == 'next') + + steps: + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + + - name: Check release environment + run: | + bash ./bin/check-release-environment + env: + PYPI_TOKEN: ${{ secrets.KERNEL_PYPI_TOKEN || secrets.PYPI_TOKEN }} diff --git a/.gitignore b/.gitignore new file mode 100644 index 00000000..3824f4c4 --- /dev/null +++ b/.gitignore @@ -0,0 +1,16 @@ +.prism.log +.stdy.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 00000000..43077b24 --- /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 00000000..29c535cb --- /dev/null +++ b/.release-please-manifest.json @@ -0,0 +1,3 @@ +{ + ".": "0.91.0" +} \ No newline at end of file diff --git a/.stats.yml b/.stats.yml new file mode 100644 index 00000000..fcd634ae --- /dev/null +++ b/.stats.yml @@ -0,0 +1,4 @@ +configured_endpoints: 127 +openapi_spec_url: https://storage.googleapis.com/stainless-sdk-openapi-specs/kernel/kernel-3fc18876d59a47ed6993892cc5f69d7461fe2ca6a94fad43905677955a5adc55.yml +openapi_spec_hash: 75d990158aeb0dac3d3ef8da476e86b9 +config_hash: 77ee715aa17061166f9a02b264a21b8d diff --git a/.vscode/settings.json b/.vscode/settings.json new file mode 100644 index 00000000..5b010307 --- /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 00000000..492ca37b --- /dev/null +++ b/Brewfile @@ -0,0 +1,2 @@ +brew "rye" + diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md new file mode 100644 index 00000000..b5bef2f2 --- /dev/null +++ b/CONTRIBUTING.md @@ -0,0 +1,121 @@ +## 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/kernel/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/kernel/kernel-python-sdk.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 + +```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/kernel/kernel-python-sdk/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 00000000..3b7d20d9 --- /dev/null +++ b/LICENSE @@ -0,0 +1,201 @@ + Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + + TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + + 1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + + 2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + + 3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + + 4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + + 5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + + 6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + + 7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + + 8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + + 9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + + END OF TERMS AND CONDITIONS + + APPENDIX: How to apply the Apache License to your work. + + To apply the Apache License to your work, attach the following + boilerplate notice, with the fields enclosed by brackets "[]" + replaced with your own identifying information. (Don't include + the brackets!) The text should be enclosed in the appropriate + comment syntax for the file format. We also recommend that a + file or class name and description of purpose be included on the + same "printed page" as the copyright notice for easier + identification within third-party archives. + + Copyright 2026 Kernel + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. diff --git a/README.md b/README.md index 6866e5bf..d7639da5 100644 --- a/README.md +++ b/README.md @@ -1 +1,493 @@ -# kernel-python \ No newline at end of file +# Kernel Python API library + + +[![PyPI version](https://img.shields.io/pypi/v/kernel.svg?label=pypi%20(stable))](https://pypi.org/project/kernel/) + +The Kernel Python library provides convenient access to the Kernel 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). + +It is generated with [Stainless](https://www.stainless.com/). + +## Documentation + +The REST API documentation can be found on [kernel.sh](https://kernel.sh/docs). The full API of this library can be found in [api.md](api.md). + +## Installation + +```sh +# install from PyPI +pip install kernel +``` + +## Usage + +The full API of this library can be found in [api.md](api.md). + +```python +import os +from kernel import Kernel + +client = Kernel( + api_key=os.environ.get("KERNEL_API_KEY"), # This is the default and can be omitted + # defaults to "production". + environment="development", +) + +browser = client.browsers.create( + stealth=True, +) +print(browser.session_id) +``` + +While you can provide an `api_key` keyword argument, +we recommend using [python-dotenv](https://pypi.org/project/python-dotenv/) +to add `KERNEL_API_KEY="My API Key"` to your `.env` file +so that your API Key is not stored in source control. + +## Async usage + +Simply import `AsyncKernel` instead of `Kernel` and use `await` with each API call: + +```python +import os +import asyncio +from kernel import AsyncKernel + +client = AsyncKernel( + api_key=os.environ.get("KERNEL_API_KEY"), # This is the default and can be omitted + # defaults to "production". + environment="development", +) + + +async def main() -> None: + browser = await client.browsers.create( + stealth=True, + ) + print(browser.session_id) + + +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 kernel[aiohttp] +``` + +Then you can enable it by instantiating the client with `http_client=DefaultAioHttpClient()`: + +```python +import os +import asyncio +from kernel import DefaultAioHttpClient +from kernel import AsyncKernel + + +async def main() -> None: + async with AsyncKernel( + api_key=os.environ.get("KERNEL_API_KEY"), # This is the default and can be omitted + http_client=DefaultAioHttpClient(), + ) as client: + browser = await client.browsers.create( + stealth=True, + ) + print(browser.session_id) + + +asyncio.run(main()) +``` + +## 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`. + +## Pagination + +List methods in the Kernel API are paginated. + +This library provides auto-paginating iterators with each list response, so you do not have to request successive pages manually: + +```python +from kernel import Kernel + +client = Kernel() + +all_deployments = [] +# Automatically fetches more pages as needed. +for deployment in client.deployments.list( + app_name="YOUR_APP", + limit=2, +): + # Do something with deployment here + all_deployments.append(deployment) +print(all_deployments) +``` + +Or, asynchronously: + +```python +import asyncio +from kernel import AsyncKernel + +client = AsyncKernel() + + +async def main() -> None: + all_deployments = [] + # Iterate through items across all pages, issuing requests as needed. + async for deployment in client.deployments.list( + app_name="YOUR_APP", + limit=2, + ): + all_deployments.append(deployment) + print(all_deployments) + + +asyncio.run(main()) +``` + +Alternatively, you can use the `.has_next_page()`, `.next_page_info()`, or `.get_next_page()` methods for more granular control working with pages: + +```python +first_page = await client.deployments.list( + app_name="YOUR_APP", + limit=2, +) +if first_page.has_next_page(): + print(f"will fetch next page using these details: {first_page.next_page_info()}") + next_page = await first_page.get_next_page() + print(f"number of items we just fetched: {len(next_page.items)}") + +# Remove `await` for non-async usage. +``` + +Or just work directly with the returned data: + +```python +first_page = await client.deployments.list( + app_name="YOUR_APP", + limit=2, +) + +print( + f"the current start offset for this page: {first_page.next_offset}" +) # => "the current start offset for this page: 1" +for deployment in first_page.items: + print(deployment.id) + +# Remove `await` for non-async usage. +``` + +## Nested params + +Nested parameters are dictionaries, typed using `TypedDict`, for example: + +```python +from kernel import Kernel + +client = Kernel() + +browser = client.browsers.create( + profile={}, +) +print(browser.profile) +``` + +## File uploads + +Request parameters that correspond to file uploads can be passed as `bytes`, or a [`PathLike`](https://docs.python.org/3/library/os.html#os.PathLike) instance or a tuple of `(filename, contents, media type)`. + +```python +from pathlib import Path +from kernel import Kernel + +client = Kernel() + +client.deployments.create( + file=Path("/path/to/file"), +) +``` + +The async client uses the exact same interface. If you pass a [`PathLike`](https://docs.python.org/3/library/os.html#os.PathLike) instance, the file contents will be read asynchronously automatically. + +## 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 `kernel.APIConnectionError` is raised. + +When the API returns a non-success status code (that is, 4xx or 5xx +response), a subclass of `kernel.APIStatusError` is raised, containing `status_code` and `response` properties. + +All errors inherit from `kernel.APIError`. + +```python +import kernel +from kernel import Kernel + +client = Kernel() + +try: + client.browsers.create( + stealth=True, + ) +except kernel.APIConnectionError as e: + print("The server could not be reached") + print(e.__cause__) # an underlying Exception, likely raised within httpx. +except kernel.RateLimitError as e: + print("A 429 status code was received; we should back off a bit.") +except kernel.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 kernel import Kernel + +# Configure the default for all requests: +client = Kernel( + # default is 2 + max_retries=0, +) + +# Or, configure per-request: +client.with_options(max_retries=5).browsers.create( + stealth=True, +) +``` + +### 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 kernel import Kernel + +# Configure the default for all requests: +client = Kernel( + # 20 seconds (default is 1 minute) + timeout=20.0, +) + +# More granular control: +client = Kernel( + timeout=httpx.Timeout(60.0, read=5.0, write=10.0, connect=2.0), +) + +# Override per-request: +client.with_options(timeout=5.0).browsers.create( + stealth=True, +) +``` + +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 `KERNEL_LOG` to `info`. + +```shell +$ export KERNEL_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 kernel import Kernel + +client = Kernel() +response = client.browsers.with_raw_response.create( + stealth=True, +) +print(response.headers.get('X-My-Header')) + +browser = response.parse() # get the object that `browsers.create()` would have returned +print(browser.session_id) +``` + +These methods return an [`APIResponse`](https://github.com/kernel/kernel-python-sdk/tree/main/src/kernel/_response.py) object. + +The async client returns an [`AsyncAPIResponse`](https://github.com/kernel/kernel-python-sdk/tree/main/src/kernel/_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.browsers.with_streaming_response.create( + stealth=True, +) 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 kernel import Kernel, DefaultHttpxClient + +client = Kernel( + # Or use the `KERNEL_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 kernel import Kernel + +with Kernel() 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/kernel/kernel-python-sdk/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 kernel +print(kernel.__version__) +``` + +## Requirements + +Python 3.9 or higher. + +## Contributing + +See [the contributing documentation](./CONTRIBUTING.md). diff --git a/SECURITY.md b/SECURITY.md new file mode 100644 index 00000000..0c6c32d1 --- /dev/null +++ b/SECURITY.md @@ -0,0 +1,23 @@ +# 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 Kernel, please follow the respective company's security reporting guidelines. + +--- + +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 00000000..edc133e0 --- /dev/null +++ b/api.md @@ -0,0 +1,502 @@ +# Shared Types + +```python +from kernel.types import ( + AppAction, + BrowserExtension, + BrowserProfile, + BrowserViewport, + ErrorDetail, + ErrorEvent, + ErrorModel, + HeartbeatEvent, + LogEvent, +) +``` + +# Deployments + +Types: + +```python +from kernel.types import ( + DeploymentStateEvent, + DeploymentCreateResponse, + DeploymentRetrieveResponse, + DeploymentListResponse, + DeploymentFollowResponse, +) +``` + +Methods: + +- client.deployments.create(\*\*params) -> DeploymentCreateResponse +- client.deployments.retrieve(id) -> DeploymentRetrieveResponse +- client.deployments.list(\*\*params) -> SyncOffsetPagination[DeploymentListResponse] +- client.deployments.delete(id) -> None +- client.deployments.follow(id, \*\*params) -> DeploymentFollowResponse + +# Apps + +Types: + +```python +from kernel.types import AppListResponse +``` + +Methods: + +- client.apps.list(\*\*params) -> SyncOffsetPagination[AppListResponse] + +# Invocations + +Types: + +```python +from kernel.types import ( + InvocationStateEvent, + InvocationCreateResponse, + InvocationRetrieveResponse, + InvocationUpdateResponse, + InvocationListResponse, + InvocationFollowResponse, + InvocationListBrowsersResponse, +) +``` + +Methods: + +- client.invocations.create(\*\*params) -> InvocationCreateResponse +- client.invocations.retrieve(id) -> InvocationRetrieveResponse +- client.invocations.update(id, \*\*params) -> InvocationUpdateResponse +- client.invocations.list(\*\*params) -> SyncOffsetPagination[InvocationListResponse] +- client.invocations.delete_browsers(id) -> None +- client.invocations.follow(id, \*\*params) -> InvocationFollowResponse +- client.invocations.list_browsers(id) -> InvocationListBrowsersResponse + +# Browsers + +Types: + +```python +from kernel.types import ( + BrowserPoolRef, + BrowserUsage, + Profile, + Tags, + BrowserCreateResponse, + BrowserRetrieveResponse, + BrowserUpdateResponse, + BrowserListResponse, + BrowserCurlResponse, +) +``` + +Methods: + +- client.browsers.create(\*\*params) -> BrowserCreateResponse +- client.browsers.retrieve(id_or_name, \*\*params) -> BrowserRetrieveResponse +- client.browsers.update(id_or_name, \*\*params) -> BrowserUpdateResponse +- client.browsers.list(\*\*params) -> SyncOffsetPagination[BrowserListResponse] +- client.browsers.curl(id, \*\*params) -> BrowserCurlResponse +- client.browsers.delete_by_id(id_or_name) -> None +- client.browsers.load_extensions(id, \*\*params) -> None + +## Telemetry + +Types: + +```python +from kernel.types.browsers import ( + BrowserAPICallEvent, + BrowserCallStack, + BrowserCaptchaSolveResultEvent, + BrowserCdpConnectEvent, + BrowserCdpDisconnectEvent, + BrowserConsoleErrorEvent, + BrowserConsoleLogEvent, + BrowserEventContext, + BrowserEventSource, + BrowserHTTPHeaders, + BrowserInteractionClickEvent, + BrowserInteractionKeyEvent, + BrowserInteractionScrollSettledEvent, + BrowserLiveViewConnectEvent, + BrowserLiveViewDisconnectEvent, + BrowserMonitorDisconnectedEvent, + BrowserMonitorInitFailedEvent, + BrowserMonitorReconnectFailedEvent, + BrowserMonitorReconnectedEvent, + BrowserMonitorScreenshotEvent, + BrowserNetworkIdleEvent, + BrowserNetworkLoadingFailedEvent, + BrowserNetworkRequestEvent, + BrowserNetworkResponseEvent, + BrowserPageDomContentLoadedEvent, + BrowserPageLayoutSettledEvent, + BrowserPageLayoutShiftEvent, + BrowserPageLcpEvent, + BrowserPageLoadEvent, + BrowserPageNavigationEvent, + BrowserPageNavigationSettledEvent, + BrowserPageTabOpenedEvent, + BrowserServiceCrashedEvent, + BrowserSystemOomKillEvent, + BrowserTelemetryCategoriesConfig, + BrowserTelemetryCategoryConfig, + BrowserTelemetryConfig, + BrowserTelemetryEvent, + TelemetryEventsResponse, + TelemetryStreamResponse, +) +``` + +Methods: + +- client.browsers.telemetry.events(id, \*\*params) -> SyncOffsetPagination[TelemetryEventsResponse] +- client.browsers.telemetry.stream(id, \*\*params) -> TelemetryStreamResponse + +## Replays + +Types: + +```python +from kernel.types.browsers import ReplayListResponse, ReplayStartResponse +``` + +Methods: + +- client.browsers.replays.list(id) -> ReplayListResponse +- client.browsers.replays.download(replay_id, \*, id) -> BinaryAPIResponse +- client.browsers.replays.start(id, \*\*params) -> ReplayStartResponse +- client.browsers.replays.stop(replay_id, \*, id) -> None + +## Fs + +Types: + +```python +from kernel.types.browsers import FFileInfoResponse, FListFilesResponse +``` + +Methods: + +- client.browsers.fs.create_directory(id, \*\*params) -> None +- client.browsers.fs.delete_directory(id, \*\*params) -> None +- client.browsers.fs.delete_file(id, \*\*params) -> None +- client.browsers.fs.download_dir_zip(id, \*\*params) -> BinaryAPIResponse +- client.browsers.fs.file_info(id, \*\*params) -> FFileInfoResponse +- client.browsers.fs.list_files(id, \*\*params) -> FListFilesResponse +- client.browsers.fs.move(id, \*\*params) -> None +- client.browsers.fs.read_file(id, \*\*params) -> BinaryAPIResponse +- client.browsers.fs.set_file_permissions(id, \*\*params) -> None +- client.browsers.fs.upload(id, \*\*params) -> None +- client.browsers.fs.upload_zip(id, \*\*params) -> None +- client.browsers.fs.write_file(id, contents, \*\*params) -> None + +### Watch + +Types: + +```python +from kernel.types.browsers.fs import WatchEventsResponse, WatchStartResponse +``` + +Methods: + +- client.browsers.fs.watch.events(watch_id, \*, id) -> WatchEventsResponse +- client.browsers.fs.watch.start(id, \*\*params) -> WatchStartResponse +- client.browsers.fs.watch.stop(watch_id, \*, id) -> None + +## Process + +Types: + +```python +from kernel.types.browsers import ( + ProcessExecResponse, + ProcessKillResponse, + ProcessResizeResponse, + ProcessSpawnResponse, + ProcessStatusResponse, + ProcessStdinResponse, + ProcessStdoutStreamResponse, +) +``` + +Methods: + +- client.browsers.process.exec(id, \*\*params) -> ProcessExecResponse +- client.browsers.process.kill(process_id, \*, id, \*\*params) -> ProcessKillResponse +- client.browsers.process.resize(process_id, \*, id, \*\*params) -> ProcessResizeResponse +- client.browsers.process.spawn(id, \*\*params) -> ProcessSpawnResponse +- client.browsers.process.status(process_id, \*, id) -> ProcessStatusResponse +- client.browsers.process.stdin(process_id, \*, id, \*\*params) -> ProcessStdinResponse +- client.browsers.process.stdout_stream(process_id, \*, id) -> ProcessStdoutStreamResponse + +## Logs + +Methods: + +- client.browsers.logs.stream(id, \*\*params) -> LogEvent + +## Computer + +Types: + +```python +from kernel.types.browsers import ( + ComputerGetMousePositionResponse, + ComputerReadClipboardResponse, + ComputerSetCursorVisibilityResponse, +) +``` + +Methods: + +- client.browsers.computer.batch(id, \*\*params) -> None +- client.browsers.computer.capture_screenshot(id, \*\*params) -> BinaryAPIResponse +- client.browsers.computer.click_mouse(id, \*\*params) -> None +- client.browsers.computer.drag_mouse(id, \*\*params) -> None +- client.browsers.computer.get_mouse_position(id) -> ComputerGetMousePositionResponse +- client.browsers.computer.move_mouse(id, \*\*params) -> None +- client.browsers.computer.press_key(id, \*\*params) -> None +- client.browsers.computer.read_clipboard(id) -> ComputerReadClipboardResponse +- client.browsers.computer.scroll(id, \*\*params) -> None +- client.browsers.computer.set_cursor_visibility(id, \*\*params) -> ComputerSetCursorVisibilityResponse +- client.browsers.computer.type_text(id, \*\*params) -> None +- client.browsers.computer.write_clipboard(id, \*\*params) -> None + +## Playwright + +Types: + +```python +from kernel.types.browsers import PlaywrightExecuteResponse +``` + +Methods: + +- client.browsers.playwright.execute(id, \*\*params) -> PlaywrightExecuteResponse + +# Profiles + +Methods: + +- client.profiles.create(\*\*params) -> Profile +- client.profiles.retrieve(id_or_name) -> Profile +- client.profiles.update(id_or_name, \*\*params) -> Profile +- client.profiles.list(\*\*params) -> SyncOffsetPagination[Profile] +- client.profiles.delete(id_or_name) -> None +- client.profiles.download(id_or_name, \*\*params) -> BinaryAPIResponse + +# Auth + +## Connections + +Types: + +```python +from kernel.types.auth import ( + LoginResponse, + ManagedAuth, + ManagedAuthCreateRequest, + ManagedAuthTimelineEvent, + ManagedAuthUpdateRequest, + SubmitFieldsRequest, + SubmitFieldsResponse, + ConnectionFollowResponse, +) +``` + +Methods: + +- client.auth.connections.create(\*\*params) -> ManagedAuth +- client.auth.connections.retrieve(id) -> ManagedAuth +- client.auth.connections.update(id, \*\*params) -> ManagedAuth +- client.auth.connections.list(\*\*params) -> SyncOffsetPagination[ManagedAuth] +- client.auth.connections.delete(id) -> None +- client.auth.connections.follow(id) -> ConnectionFollowResponse +- client.auth.connections.login(id, \*\*params) -> LoginResponse +- client.auth.connections.submit(id, \*\*params) -> SubmitFieldsResponse +- client.auth.connections.timeline(id, \*\*params) -> SyncOffsetPagination[ManagedAuthTimelineEvent] + +# Proxies + +Types: + +```python +from kernel.types import ( + ProxyCreateResponse, + ProxyRetrieveResponse, + ProxyUpdateResponse, + ProxyListResponse, + ProxyCheckResponse, +) +``` + +Methods: + +- client.proxies.create(\*\*params) -> ProxyCreateResponse +- client.proxies.retrieve(id) -> ProxyRetrieveResponse +- client.proxies.update(id, \*\*params) -> ProxyUpdateResponse +- client.proxies.list(\*\*params) -> SyncOffsetPagination[ProxyListResponse] +- client.proxies.delete(id) -> None +- client.proxies.check(id, \*\*params) -> ProxyCheckResponse + +# Extensions + +Types: + +```python +from kernel.types import ExtensionListResponse, ExtensionGetResponse, ExtensionUploadResponse +``` + +Methods: + +- client.extensions.list(\*\*params) -> SyncOffsetPagination[ExtensionListResponse] +- client.extensions.delete(id_or_name) -> None +- client.extensions.download(id_or_name) -> BinaryAPIResponse +- client.extensions.download_from_chrome_store(\*\*params) -> BinaryAPIResponse +- client.extensions.get(id_or_name) -> ExtensionGetResponse +- client.extensions.upload(\*\*params) -> ExtensionUploadResponse + +# BrowserPools + +Types: + +```python +from kernel.types import BrowserPool, BrowserPoolAcquireResponse +``` + +Methods: + +- client.browser_pools.create(\*\*params) -> BrowserPool +- client.browser_pools.retrieve(id_or_name) -> BrowserPool +- client.browser_pools.update(id_or_name, \*\*params) -> BrowserPool +- client.browser_pools.list(\*\*params) -> SyncOffsetPagination[BrowserPool] +- client.browser_pools.delete(id_or_name, \*\*params) -> None +- client.browser_pools.acquire(id_or_name, \*\*params) -> BrowserPoolAcquireResponse +- client.browser_pools.flush(id_or_name) -> None +- client.browser_pools.release(id_or_name, \*\*params) -> None + +# Credentials + +Types: + +```python +from kernel.types import ( + CreateCredentialRequest, + Credential, + UpdateCredentialRequest, + CredentialTotpCodeResponse, +) +``` + +Methods: + +- client.credentials.create(\*\*params) -> Credential +- client.credentials.retrieve(id_or_name) -> Credential +- client.credentials.update(id_or_name, \*\*params) -> Credential +- client.credentials.list(\*\*params) -> SyncOffsetPagination[Credential] +- client.credentials.delete(id_or_name) -> None +- client.credentials.totp_code(id_or_name) -> CredentialTotpCodeResponse + +# Projects + +Types: + +```python +from kernel.types import CreateProjectRequest, Project, UpdateProjectRequest +``` + +Methods: + +- client.projects.create(\*\*params) -> Project +- client.projects.retrieve(id) -> Project +- client.projects.update(id, \*\*params) -> Project +- client.projects.list(\*\*params) -> SyncOffsetPagination[Project] +- client.projects.delete(id) -> None + +## Limits + +Types: + +```python +from kernel.types.projects import ProjectLimits, UpdateProjectLimitsRequest +``` + +Methods: + +- client.projects.limits.retrieve(id) -> ProjectLimits +- client.projects.limits.update(id, \*\*params) -> ProjectLimits + +# Organization + +## Limits + +Types: + +```python +from kernel.types.organization import OrgLimits, UpdateOrgLimitsRequest +``` + +Methods: + +- client.organization.limits.retrieve() -> OrgLimits +- client.organization.limits.update(\*\*params) -> OrgLimits + +# AuditLogs + +Types: + +```python +from kernel.types import AuditLogEntry +``` + +Methods: + +- client.audit_logs.list(\*\*params) -> SyncPageTokenPagination[AuditLogEntry] +- client.audit_logs.export_chunk(\*\*params) -> BinaryAPIResponse + +# APIKeys + +Types: + +```python +from kernel.types import APIKey, CreatedAPIKey +``` + +Methods: + +- client.api_keys.create(\*\*params) -> CreatedAPIKey +- client.api_keys.retrieve(id, \*\*params) -> APIKey +- client.api_keys.update(id, \*\*params) -> APIKey +- client.api_keys.list(\*\*params) -> SyncOffsetPagination[APIKey] +- client.api_keys.delete(id) -> None +- client.api_keys.rotate(id, \*\*params) -> CreatedAPIKey + +# CredentialProviders + +Types: + +```python +from kernel.types import ( + CreateCredentialProviderRequest, + CredentialProvider, + CredentialProviderItem, + CredentialProviderTestResult, + UpdateCredentialProviderRequest, + CredentialProviderListItemsResponse, +) +``` + +Methods: + +- client.credential_providers.create(\*\*params) -> CredentialProvider +- client.credential_providers.retrieve(id) -> CredentialProvider +- client.credential_providers.update(id, \*\*params) -> CredentialProvider +- client.credential_providers.list(\*\*params) -> SyncOffsetPagination[CredentialProvider] +- client.credential_providers.delete(id) -> None +- client.credential_providers.list_items(id) -> CredentialProviderListItemsResponse +- client.credential_providers.test(id) -> CredentialProviderTestResult diff --git a/bin/check-release-environment b/bin/check-release-environment new file mode 100644 index 00000000..b845b0f4 --- /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 00000000..826054e9 --- /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 00000000..d8c73e93 --- /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/noxfile.py b/noxfile.py new file mode 100644 index 00000000..53bca7ff --- /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 00000000..1c078ba3 --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,269 @@ +[project] +name = "kernel" +version = "0.91.0" +description = "The official Python library for the kernel API" +dynamic = ["readme"] +license = "Apache-2.0" +authors = [ +{ name = "Kernel", email = "" }, +] + +dependencies = [ + "httpx>=0.23.0, <1", + "pydantic>=1.9.0, <3", + "typing-extensions>=4.14, <5", + "anyio>=3.5.0, <5", + "distro>=1.7.0, <2", + "sniffio", +] + +requires-python = ">= 3.9" +classifiers = [ + "Typing :: Typed", + "Intended Audience :: Developers", + "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", + "Programming Language :: Python :: 3.14", + "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 :: Apache Software License" +] + +[project.urls] +Homepage = "https://github.com/kernel/kernel-python-sdk" +Repository = "https://github.com/kernel/kernel-python-sdk" + +[project.optional-dependencies] +aiohttp = ["aiohttp", "httpx_aiohttp>=0.1.9"] + +[tool.rye] +managed = true +# version pins are in requirements-dev.lock +dev-dependencies = [ + "pyright==1.1.399", + "mypy==1.17", + "respx", + "pytest", + "pytest-asyncio", + "ruff", + "time-machine", + "nox", + "dirty-equals>=0.6.0", + "importlib-metadata>=6.7.0", + "rich>=13.7.1", + "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" = "bash -c 'python scripts/utils/ruffen-docs.py README.md $(find . -type f -name 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 kernel'" + +typecheck = { chain = [ + "typecheck:pyright", + "typecheck:mypy" +]} +"typecheck:pyright" = "pyright" +"typecheck:verify-types" = "pyright --verifytypes kernel --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/kernel"] + +[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/kernel/kernel-python-sdk/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.9" + +exclude = [ + "_dev", + ".venv", + ".nox", + ".git", +] + +reportImplicitOverride = true +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/kernel/_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" +target-version = "py38" + +[tool.ruff.format] +docstring-code-format = true + +[tool.ruff.lint] +select = [ + # isort + "I", + # bugbear rules + "B", + # remove unused imports + "F401", + # check for missing future annotations + "FA102", + # 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", +] + +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" + +[tool.ruff.lint.isort] +length-sort = true +length-sort-straight = true +combine-as-imports = true +extra-standard-library = ["typing_extensions"] +known-first-party = ["kernel", "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 00000000..942ec08a --- /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/kernel/_version.py" + ] +} \ No newline at end of file diff --git a/requirements-dev.lock b/requirements-dev.lock new file mode 100644 index 00000000..4a38b203 --- /dev/null +++ b/requirements-dev.lock @@ -0,0 +1,149 @@ +# 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.13.3 + # via httpx-aiohttp + # via kernel +aiosignal==1.4.0 + # via aiohttp +annotated-types==0.7.0 + # via pydantic +anyio==4.12.1 + # via httpx + # via kernel +argcomplete==3.6.3 + # via nox +async-timeout==5.0.1 + # via aiohttp +attrs==25.4.0 + # via aiohttp + # via nox +backports-asyncio-runner==1.2.0 + # via pytest-asyncio +certifi==2026.1.4 + # via httpcore + # via httpx +colorlog==6.10.1 + # via nox +dependency-groups==1.3.1 + # via nox +dirty-equals==0.11 +distlib==0.4.0 + # via virtualenv +distro==1.9.0 + # via kernel +exceptiongroup==1.3.1 + # via anyio + # via pytest +execnet==2.1.2 + # via pytest-xdist +filelock==3.19.1 + # via virtualenv +frozenlist==1.8.0 + # via aiohttp + # via aiosignal +h11==0.16.0 + # via httpcore +httpcore==1.0.9 + # via httpx +httpx==0.28.1 + # via httpx-aiohttp + # via kernel + # via respx +httpx-aiohttp==0.1.12 + # via kernel +humanize==4.13.0 + # via nox +idna==3.11 + # via anyio + # via httpx + # via yarl +importlib-metadata==8.7.1 +iniconfig==2.1.0 + # via pytest +markdown-it-py==3.0.0 + # via rich +mdurl==0.1.2 + # via markdown-it-py +multidict==6.7.0 + # via aiohttp + # via yarl +mypy==1.17.0 +mypy-extensions==1.1.0 + # via mypy +nodeenv==1.10.0 + # via pyright +nox==2025.11.12 +packaging==25.0 + # via dependency-groups + # via nox + # via pytest +pathspec==1.0.3 + # via mypy +platformdirs==4.4.0 + # via virtualenv +pluggy==1.6.0 + # via pytest +propcache==0.4.1 + # via aiohttp + # via yarl +pydantic==2.12.5 + # via kernel +pydantic-core==2.41.5 + # via pydantic +pygments==2.19.2 + # via pytest + # via rich +pyright==1.1.399 +pytest==8.4.2 + # via pytest-asyncio + # via pytest-xdist +pytest-asyncio==1.2.0 +pytest-xdist==3.8.0 +python-dateutil==2.9.0.post0 + # via time-machine +respx==0.22.0 +rich==14.2.0 +ruff==0.14.13 +six==1.17.0 + # via python-dateutil +sniffio==1.3.1 + # via kernel +time-machine==2.19.0 +tomli==2.4.0 + # via dependency-groups + # via mypy + # via nox + # via pytest +typing-extensions==4.15.0 + # via aiosignal + # via anyio + # via exceptiongroup + # via kernel + # via multidict + # via mypy + # via pydantic + # via pydantic-core + # via pyright + # via pytest-asyncio + # via typing-inspection + # via virtualenv +typing-inspection==0.4.2 + # via pydantic +virtualenv==20.36.1 + # via nox +yarl==1.22.0 + # via aiohttp +zipp==3.23.0 + # via importlib-metadata diff --git a/requirements.lock b/requirements.lock new file mode 100644 index 00000000..5f6c7ff4 --- /dev/null +++ b/requirements.lock @@ -0,0 +1,76 @@ +# 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.13.3 + # via httpx-aiohttp + # via kernel +aiosignal==1.4.0 + # via aiohttp +annotated-types==0.7.0 + # via pydantic +anyio==4.12.1 + # via httpx + # via kernel +async-timeout==5.0.1 + # via aiohttp +attrs==25.4.0 + # via aiohttp +certifi==2026.1.4 + # via httpcore + # via httpx +distro==1.9.0 + # via kernel +exceptiongroup==1.3.1 + # via anyio +frozenlist==1.8.0 + # via aiohttp + # via aiosignal +h11==0.16.0 + # via httpcore +httpcore==1.0.9 + # via httpx +httpx==0.28.1 + # via httpx-aiohttp + # via kernel +httpx-aiohttp==0.1.12 + # via kernel +idna==3.11 + # via anyio + # via httpx + # via yarl +multidict==6.7.0 + # via aiohttp + # via yarl +propcache==0.4.1 + # via aiohttp + # via yarl +pydantic==2.12.5 + # via kernel +pydantic-core==2.41.5 + # via pydantic +sniffio==1.3.1 + # via kernel +typing-extensions==4.15.0 + # via aiosignal + # via anyio + # via exceptiongroup + # via kernel + # via multidict + # via pydantic + # via pydantic-core + # via typing-inspection +typing-inspection==0.4.2 + # via pydantic +yarl==1.22.0 + # via aiohttp diff --git a/scripts/bootstrap b/scripts/bootstrap new file mode 100755 index 00000000..fe8451e4 --- /dev/null +++ b/scripts/bootstrap @@ -0,0 +1,27 @@ +#!/usr/bin/env bash + +set -e + +cd "$(dirname "$0")/.." + +if [ -f "Brewfile" ] && [ "$(uname -s)" = "Darwin" ] && [ "${SKIP_BREW:-}" != "1" ] && [ -t 0 ]; then + brew bundle check >/dev/null 2>&1 || { + echo -n "==> Install Homebrew dependencies? (y/N): " + read -r response + case "$response" in + [yY][eE][sS]|[yY]) + brew bundle + ;; + *) + ;; + esac + echo + } +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 00000000..667ec2d7 --- /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 00000000..7675e607 --- /dev/null +++ b/scripts/lint @@ -0,0 +1,16 @@ +#!/usr/bin/env bash + +set -e + +cd "$(dirname "$0")/.." + +if [ "$1" = "--fix" ]; then + echo "==> Running lints with --fix" + rye run fix:ruff +else + echo "==> Running lints" + rye run lint +fi + +echo "==> Making sure it imports" +rye run python -c 'import kernel' diff --git a/scripts/test b/scripts/test new file mode 100755 index 00000000..39729d09 --- /dev/null +++ b/scripts/test @@ -0,0 +1,15 @@ +#!/usr/bin/env bash + +set -e + +cd "$(dirname "$0")/.." + + + +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 00000000..0cf2bd2f --- /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 00000000..14b2cc82 --- /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/kernel-python/$SHA/$FILENAME'\033[0m" +else + echo -e "\033[31mFailed to upload artifact.\033[0m" + exit 1 +fi diff --git a/src/kernel/__init__.py b/src/kernel/__init__.py new file mode 100644 index 00000000..d1fdcc02 --- /dev/null +++ b/src/kernel/__init__.py @@ -0,0 +1,104 @@ +# 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, omit, not_given +from ._utils import file_from_path +from ._client import ( + ENVIRONMENTS, + Client, + Kernel, + Stream, + Timeout, + Transport, + AsyncClient, + AsyncKernel, + AsyncStream, + 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, + KernelError, + ConflictError, + NotFoundError, + 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", + "not_given", + "Omit", + "omit", + "KernelError", + "APIError", + "APIStatusError", + "APITimeoutError", + "APIConnectionError", + "APIResponseValidationError", + "BadRequestError", + "AuthenticationError", + "PermissionDeniedError", + "NotFoundError", + "ConflictError", + "UnprocessableEntityError", + "RateLimitError", + "InternalServerError", + "Timeout", + "RequestOptions", + "Client", + "AsyncClient", + "Stream", + "AsyncStream", + "Kernel", + "AsyncKernel", + "ENVIRONMENTS", + "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. +# kernel._exceptions.NotFoundError -> kernel.NotFoundError +__locals = locals() +for __name in __all__: + if not __name.startswith("__"): + try: + __locals[__name].__module__ = "kernel" + except (TypeError, AttributeError): + # Some of our exported symbols are builtins which we can't set attributes for. + pass diff --git a/src/kernel/_base_client.py b/src/kernel/_base_client.py new file mode 100644 index 00000000..2599dc41 --- /dev/null +++ b/src/kernel/_base_client.py @@ -0,0 +1,2131 @@ +from __future__ import annotations + +import sys +import json +import time +import uuid +import email +import asyncio +import inspect +import logging +import platform +import warnings +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 ( + Body, + Omit, + Query, + Headers, + Timeout, + NotGiven, + ResponseT, + AnyMapping, + PostParser, + BinaryTypes, + RequestFiles, + HttpxSendArgs, + RequestOptions, + AsyncBinaryTypes, + 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 +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, +) +from ._utils._json import openapi_dumps + +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 (not PYDANTIC_V1) 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 (not PYDANTIC_V1) 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 `kernel.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, + # Pydantic v1 can't dump every type we support in content, so we exclude it for now. + exclude={ + "content", + } + if PYDANTIC_V1 + else {}, + ), + ) + 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) + # preserve hard-coded query params from the url + if params and prepared_url.query: + params = {**dict(prepared_url.params.items()), **params} + prepared_url = prepared_url.copy_with(raw_path=prepared_url.raw_path.split(b"?", 1)[0]) + 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 options.content is not None and json_data is not None: + raise TypeError("Passing both `content` and `json_data` is not supported") + if options.content is not None and files is not None: + raise TypeError("Passing both `content` and `files` is not supported") + if options.content is not None: + kwargs["content"] = options.content + elif isinstance(json_data, bytes): + kwargs["content"] = json_data + elif not files: + # Don't set content when JSON is sent as multipart/form-data, + # since httpx's content param overrides other body arguments + kwargs["content"] = openapi_dumps(json_data) if is_given(json_data) and json_data is not None 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, + content: BinaryTypes | 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, + content: BinaryTypes | 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, + content: BinaryTypes | 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, + content: BinaryTypes | None = None, + options: RequestOptions = {}, + files: RequestFiles | None = None, + stream: bool = False, + stream_cls: type[_StreamT] | None = None, + ) -> ResponseT | _StreamT: + if body is not None and content is not None: + raise TypeError("Passing both `body` and `content` is not supported") + if files is not None and content is not None: + raise TypeError("Passing both `files` and `content` is not supported") + if isinstance(body, bytes): + warnings.warn( + "Passing raw bytes as `body` is deprecated and will be removed in a future version. " + "Please pass raw bytes via the `content` parameter instead.", + DeprecationWarning, + stacklevel=2, + ) + opts = FinalRequestOptions.construct( + method="post", url=path, json_data=body, content=content, 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, + content: BinaryTypes | None = None, + files: RequestFiles | None = None, + options: RequestOptions = {}, + ) -> ResponseT: + if body is not None and content is not None: + raise TypeError("Passing both `body` and `content` is not supported") + if files is not None and content is not None: + raise TypeError("Passing both `files` and `content` is not supported") + if isinstance(body, bytes): + warnings.warn( + "Passing raw bytes as `body` is deprecated and will be removed in a future version. " + "Please pass raw bytes via the `content` parameter instead.", + DeprecationWarning, + stacklevel=2, + ) + opts = FinalRequestOptions.construct( + method="patch", url=path, json_data=body, content=content, files=to_httpx_files(files), **options + ) + return self.request(cast_to, opts) + + def put( + self, + path: str, + *, + cast_to: Type[ResponseT], + body: Body | None = None, + content: BinaryTypes | None = None, + files: RequestFiles | None = None, + options: RequestOptions = {}, + ) -> ResponseT: + if body is not None and content is not None: + raise TypeError("Passing both `body` and `content` is not supported") + if files is not None and content is not None: + raise TypeError("Passing both `files` and `content` is not supported") + if isinstance(body, bytes): + warnings.warn( + "Passing raw bytes as `body` is deprecated and will be removed in a future version. " + "Please pass raw bytes via the `content` parameter instead.", + DeprecationWarning, + stacklevel=2, + ) + opts = FinalRequestOptions.construct( + method="put", url=path, json_data=body, content=content, 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, + content: BinaryTypes | None = None, + options: RequestOptions = {}, + ) -> ResponseT: + if body is not None and content is not None: + raise TypeError("Passing both `body` and `content` is not supported") + if isinstance(body, bytes): + warnings.warn( + "Passing raw bytes as `body` is deprecated and will be removed in a future version. " + "Please pass raw bytes via the `content` parameter instead.", + DeprecationWarning, + stacklevel=2, + ) + opts = FinalRequestOptions.construct(method="delete", url=path, json_data=body, content=content, **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, + content: AsyncBinaryTypes | 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, + content: AsyncBinaryTypes | 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, + content: AsyncBinaryTypes | 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, + content: AsyncBinaryTypes | None = None, + files: RequestFiles | None = None, + options: RequestOptions = {}, + stream: bool = False, + stream_cls: type[_AsyncStreamT] | None = None, + ) -> ResponseT | _AsyncStreamT: + if body is not None and content is not None: + raise TypeError("Passing both `body` and `content` is not supported") + if files is not None and content is not None: + raise TypeError("Passing both `files` and `content` is not supported") + if isinstance(body, bytes): + warnings.warn( + "Passing raw bytes as `body` is deprecated and will be removed in a future version. " + "Please pass raw bytes via the `content` parameter instead.", + DeprecationWarning, + stacklevel=2, + ) + opts = FinalRequestOptions.construct( + method="post", url=path, json_data=body, content=content, 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, + content: AsyncBinaryTypes | None = None, + files: RequestFiles | None = None, + options: RequestOptions = {}, + ) -> ResponseT: + if body is not None and content is not None: + raise TypeError("Passing both `body` and `content` is not supported") + if files is not None and content is not None: + raise TypeError("Passing both `files` and `content` is not supported") + if isinstance(body, bytes): + warnings.warn( + "Passing raw bytes as `body` is deprecated and will be removed in a future version. " + "Please pass raw bytes via the `content` parameter instead.", + DeprecationWarning, + stacklevel=2, + ) + opts = FinalRequestOptions.construct( + method="patch", + url=path, + json_data=body, + content=content, + files=await async_to_httpx_files(files), + **options, + ) + return await self.request(cast_to, opts) + + async def put( + self, + path: str, + *, + cast_to: Type[ResponseT], + body: Body | None = None, + content: AsyncBinaryTypes | None = None, + files: RequestFiles | None = None, + options: RequestOptions = {}, + ) -> ResponseT: + if body is not None and content is not None: + raise TypeError("Passing both `body` and `content` is not supported") + if files is not None and content is not None: + raise TypeError("Passing both `files` and `content` is not supported") + if isinstance(body, bytes): + warnings.warn( + "Passing raw bytes as `body` is deprecated and will be removed in a future version. " + "Please pass raw bytes via the `content` parameter instead.", + DeprecationWarning, + stacklevel=2, + ) + opts = FinalRequestOptions.construct( + method="put", url=path, json_data=body, content=content, 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, + content: AsyncBinaryTypes | None = None, + options: RequestOptions = {}, + ) -> ResponseT: + if body is not None and content is not None: + raise TypeError("Passing both `body` and `content` is not supported") + if isinstance(body, bytes): + warnings.warn( + "Passing raw bytes as `body` is deprecated and will be removed in a future version. " + "Please pass raw bytes via the `content` parameter instead.", + DeprecationWarning, + stacklevel=2, + ) + opts = FinalRequestOptions.construct(method="delete", url=path, json_data=body, content=content, **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/kernel/_client.py b/src/kernel/_client.py new file mode 100644 index 00000000..bd465720 --- /dev/null +++ b/src/kernel/_client.py @@ -0,0 +1,1181 @@ +# File generated from our OpenAPI spec by Stainless. See CONTRIBUTING.md for details. + +from __future__ import annotations + +import os +from typing import TYPE_CHECKING, Any, Dict, Mapping, cast +from typing_extensions import Self, Literal, override + +import httpx + +from . import _exceptions +from ._qs import Querystring +from ._types import ( + Omit, + Timeout, + NotGiven, + Transport, + ProxiesTypes, + RequestOptions, + not_given, +) +from ._utils import ( + is_given, + is_mapping_t, + get_async_library, +) +from ._compat import cached_property +from ._version import __version__ +from ._streaming import Stream as Stream, AsyncStream as AsyncStream +from ._exceptions import KernelError, APIStatusError +from ._base_client import ( + DEFAULT_MAX_RETRIES, + SyncAPIClient, + AsyncAPIClient, +) + +if TYPE_CHECKING: + from .resources import ( + apps, + auth, + proxies, + api_keys, + browsers, + profiles, + projects, + audit_logs, + extensions, + credentials, + deployments, + invocations, + organization, + browser_pools, + credential_providers, + ) + from .resources.apps import AppsResource, AsyncAppsResource + from .resources.proxies import ProxiesResource, AsyncProxiesResource + from .resources.api_keys import APIKeysResource, AsyncAPIKeysResource + from .resources.profiles import ProfilesResource, AsyncProfilesResource + from .resources.auth.auth import AuthResource, AsyncAuthResource + from .resources.audit_logs import AuditLogsResource, AsyncAuditLogsResource + from .resources.extensions import ExtensionsResource, AsyncExtensionsResource + from .resources.credentials import CredentialsResource, AsyncCredentialsResource + from .resources.deployments import DeploymentsResource, AsyncDeploymentsResource + from .resources.invocations import InvocationsResource, AsyncInvocationsResource + from .resources.browser_pools import BrowserPoolsResource, AsyncBrowserPoolsResource + from .resources.browsers.browsers import BrowsersResource, AsyncBrowsersResource + from .resources.projects.projects import ProjectsResource, AsyncProjectsResource + from .resources.credential_providers import CredentialProvidersResource, AsyncCredentialProvidersResource + from .resources.organization.organization import OrganizationResource, AsyncOrganizationResource + +__all__ = [ + "ENVIRONMENTS", + "Timeout", + "Transport", + "ProxiesTypes", + "RequestOptions", + "Kernel", + "AsyncKernel", + "Client", + "AsyncClient", +] + +ENVIRONMENTS: Dict[str, str] = { + "production": "https://api.onkernel.com/", + "development": "https://localhost:3001/", +} + + +class Kernel(SyncAPIClient): + # client options + api_key: str + project_id: str | None + + _environment: Literal["production", "development"] | NotGiven + + def __init__( + self, + *, + api_key: str | None = None, + project_id: str | None = None, + environment: Literal["production", "development"] | NotGiven = not_given, + base_url: str | httpx.URL | 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, + # 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 Kernel client instance. + + This automatically infers the `api_key` argument from the `KERNEL_API_KEY` environment variable if it is not provided. + """ + if api_key is None: + api_key = os.environ.get("KERNEL_API_KEY") + if api_key is None: + raise KernelError( + "The api_key client option must be set either by passing api_key to the client or by setting the KERNEL_API_KEY environment variable" + ) + self.api_key = api_key + + self.project_id = project_id + + self._environment = environment + + base_url_env = os.environ.get("KERNEL_BASE_URL") + if is_given(base_url) and base_url is not None: + # cast required because mypy doesn't understand the type narrowing + base_url = cast("str | httpx.URL", base_url) # pyright: ignore[reportUnnecessaryCast] + elif is_given(environment): + if base_url_env and base_url is not None: + raise ValueError( + "Ambiguous URL; The `KERNEL_BASE_URL` env var and the `environment` argument are given. If you want to use the environment, you must pass base_url=None", + ) + + try: + base_url = ENVIRONMENTS[environment] + except KeyError as exc: + raise ValueError(f"Unknown environment: {environment}") from exc + elif base_url_env is not None: + base_url = base_url_env + else: + self._environment = environment = "production" + + try: + base_url = ENVIRONMENTS[environment] + except KeyError as exc: + raise ValueError(f"Unknown environment: {environment}") from exc + + custom_headers_env = os.environ.get("KERNEL_CUSTOM_HEADERS") + if custom_headers_env is not None: + parsed: dict[str, str] = {} + for line in custom_headers_env.split("\n"): + colon = line.find(":") + if colon >= 0: + parsed[line[:colon].strip()] = line[colon + 1 :].strip() + default_headers = {**parsed, **(default_headers if is_mapping_t(default_headers) else {})} + + 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, + ) + + @cached_property + def deployments(self) -> DeploymentsResource: + """Create and manage app deployments and stream deployment events.""" + from .resources.deployments import DeploymentsResource + + return DeploymentsResource(self) + + @cached_property + def apps(self) -> AppsResource: + """List applications and versions.""" + from .resources.apps import AppsResource + + return AppsResource(self) + + @cached_property + def invocations(self) -> InvocationsResource: + """Invoke actions and stream or query invocation status and events.""" + from .resources.invocations import InvocationsResource + + return InvocationsResource(self) + + @cached_property + def browsers(self) -> BrowsersResource: + """Create and manage browser sessions.""" + from .resources.browsers import BrowsersResource + + return BrowsersResource(self) + + @cached_property + def profiles(self) -> ProfilesResource: + """Create, list, retrieve, and delete browser profiles.""" + from .resources.profiles import ProfilesResource + + return ProfilesResource(self) + + @cached_property + def auth(self) -> AuthResource: + from .resources.auth import AuthResource + + return AuthResource(self) + + @cached_property + def proxies(self) -> ProxiesResource: + """Create and manage proxy configurations for routing browser traffic.""" + from .resources.proxies import ProxiesResource + + return ProxiesResource(self) + + @cached_property + def extensions(self) -> ExtensionsResource: + """Create, list, retrieve, and delete browser extensions.""" + from .resources.extensions import ExtensionsResource + + return ExtensionsResource(self) + + @cached_property + def browser_pools(self) -> BrowserPoolsResource: + """Create and manage browser pools for acquiring and releasing browsers.""" + from .resources.browser_pools import BrowserPoolsResource + + return BrowserPoolsResource(self) + + @cached_property + def credentials(self) -> CredentialsResource: + """Create and manage credentials for authentication.""" + from .resources.credentials import CredentialsResource + + return CredentialsResource(self) + + @cached_property + def projects(self) -> ProjectsResource: + """ + Create and manage projects for resource isolation within an organization. + When projects are disabled for the organization, project operations return + `404` with code `projects_disabled`. + """ + from .resources.projects import ProjectsResource + + return ProjectsResource(self) + + @cached_property + def organization(self) -> OrganizationResource: + from .resources.organization import OrganizationResource + + return OrganizationResource(self) + + @cached_property + def audit_logs(self) -> AuditLogsResource: + """Read audit log records for the authenticated organization.""" + from .resources.audit_logs import AuditLogsResource + + return AuditLogsResource(self) + + @cached_property + def api_keys(self) -> APIKeysResource: + """Create and manage API keys for organization and project-scoped access.""" + from .resources.api_keys import APIKeysResource + + return APIKeysResource(self) + + @cached_property + def credential_providers(self) -> CredentialProvidersResource: + """Configure external credential providers like 1Password.""" + from .resources.credential_providers import CredentialProvidersResource + + return CredentialProvidersResource(self) + + @cached_property + def with_raw_response(self) -> KernelWithRawResponse: + return KernelWithRawResponse(self) + + @cached_property + def with_streaming_response(self) -> KernelWithStreamedResponse: + return KernelWithStreamedResponse(self) + + @property + @override + def qs(self) -> Querystring: + return Querystring(array_format="comma") + + @property + @override + def auth_headers(self) -> dict[str, str]: + api_key = self.api_key + return {"Authorization": f"Bearer {api_key}"} + + @property + @override + def default_headers(self) -> dict[str, str | Omit]: + return { + **super().default_headers, + "X-Stainless-Async": "false", + "X-Kernel-Project-Id": self.project_id if self.project_id is not None else Omit(), + **self._custom_headers, + } + + def copy( + self, + *, + api_key: str | None = None, + project_id: str | None = None, + environment: Literal["production", "development"] | None = None, + 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__( + api_key=api_key or self.api_key, + project_id=project_id or self.project_id, + base_url=base_url or self.base_url, + environment=environment or self._environment, + 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 AsyncKernel(AsyncAPIClient): + # client options + api_key: str + project_id: str | None + + _environment: Literal["production", "development"] | NotGiven + + def __init__( + self, + *, + api_key: str | None = None, + project_id: str | None = None, + environment: Literal["production", "development"] | NotGiven = not_given, + base_url: str | httpx.URL | 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, + # 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 AsyncKernel client instance. + + This automatically infers the `api_key` argument from the `KERNEL_API_KEY` environment variable if it is not provided. + """ + if api_key is None: + api_key = os.environ.get("KERNEL_API_KEY") + if api_key is None: + raise KernelError( + "The api_key client option must be set either by passing api_key to the client or by setting the KERNEL_API_KEY environment variable" + ) + self.api_key = api_key + + self.project_id = project_id + + self._environment = environment + + base_url_env = os.environ.get("KERNEL_BASE_URL") + if is_given(base_url) and base_url is not None: + # cast required because mypy doesn't understand the type narrowing + base_url = cast("str | httpx.URL", base_url) # pyright: ignore[reportUnnecessaryCast] + elif is_given(environment): + if base_url_env and base_url is not None: + raise ValueError( + "Ambiguous URL; The `KERNEL_BASE_URL` env var and the `environment` argument are given. If you want to use the environment, you must pass base_url=None", + ) + + try: + base_url = ENVIRONMENTS[environment] + except KeyError as exc: + raise ValueError(f"Unknown environment: {environment}") from exc + elif base_url_env is not None: + base_url = base_url_env + else: + self._environment = environment = "production" + + try: + base_url = ENVIRONMENTS[environment] + except KeyError as exc: + raise ValueError(f"Unknown environment: {environment}") from exc + + custom_headers_env = os.environ.get("KERNEL_CUSTOM_HEADERS") + if custom_headers_env is not None: + parsed: dict[str, str] = {} + for line in custom_headers_env.split("\n"): + colon = line.find(":") + if colon >= 0: + parsed[line[:colon].strip()] = line[colon + 1 :].strip() + default_headers = {**parsed, **(default_headers if is_mapping_t(default_headers) else {})} + + 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, + ) + + @cached_property + def deployments(self) -> AsyncDeploymentsResource: + """Create and manage app deployments and stream deployment events.""" + from .resources.deployments import AsyncDeploymentsResource + + return AsyncDeploymentsResource(self) + + @cached_property + def apps(self) -> AsyncAppsResource: + """List applications and versions.""" + from .resources.apps import AsyncAppsResource + + return AsyncAppsResource(self) + + @cached_property + def invocations(self) -> AsyncInvocationsResource: + """Invoke actions and stream or query invocation status and events.""" + from .resources.invocations import AsyncInvocationsResource + + return AsyncInvocationsResource(self) + + @cached_property + def browsers(self) -> AsyncBrowsersResource: + """Create and manage browser sessions.""" + from .resources.browsers import AsyncBrowsersResource + + return AsyncBrowsersResource(self) + + @cached_property + def profiles(self) -> AsyncProfilesResource: + """Create, list, retrieve, and delete browser profiles.""" + from .resources.profiles import AsyncProfilesResource + + return AsyncProfilesResource(self) + + @cached_property + def auth(self) -> AsyncAuthResource: + from .resources.auth import AsyncAuthResource + + return AsyncAuthResource(self) + + @cached_property + def proxies(self) -> AsyncProxiesResource: + """Create and manage proxy configurations for routing browser traffic.""" + from .resources.proxies import AsyncProxiesResource + + return AsyncProxiesResource(self) + + @cached_property + def extensions(self) -> AsyncExtensionsResource: + """Create, list, retrieve, and delete browser extensions.""" + from .resources.extensions import AsyncExtensionsResource + + return AsyncExtensionsResource(self) + + @cached_property + def browser_pools(self) -> AsyncBrowserPoolsResource: + """Create and manage browser pools for acquiring and releasing browsers.""" + from .resources.browser_pools import AsyncBrowserPoolsResource + + return AsyncBrowserPoolsResource(self) + + @cached_property + def credentials(self) -> AsyncCredentialsResource: + """Create and manage credentials for authentication.""" + from .resources.credentials import AsyncCredentialsResource + + return AsyncCredentialsResource(self) + + @cached_property + def projects(self) -> AsyncProjectsResource: + """ + Create and manage projects for resource isolation within an organization. + When projects are disabled for the organization, project operations return + `404` with code `projects_disabled`. + """ + from .resources.projects import AsyncProjectsResource + + return AsyncProjectsResource(self) + + @cached_property + def organization(self) -> AsyncOrganizationResource: + from .resources.organization import AsyncOrganizationResource + + return AsyncOrganizationResource(self) + + @cached_property + def audit_logs(self) -> AsyncAuditLogsResource: + """Read audit log records for the authenticated organization.""" + from .resources.audit_logs import AsyncAuditLogsResource + + return AsyncAuditLogsResource(self) + + @cached_property + def api_keys(self) -> AsyncAPIKeysResource: + """Create and manage API keys for organization and project-scoped access.""" + from .resources.api_keys import AsyncAPIKeysResource + + return AsyncAPIKeysResource(self) + + @cached_property + def credential_providers(self) -> AsyncCredentialProvidersResource: + """Configure external credential providers like 1Password.""" + from .resources.credential_providers import AsyncCredentialProvidersResource + + return AsyncCredentialProvidersResource(self) + + @cached_property + def with_raw_response(self) -> AsyncKernelWithRawResponse: + return AsyncKernelWithRawResponse(self) + + @cached_property + def with_streaming_response(self) -> AsyncKernelWithStreamedResponse: + return AsyncKernelWithStreamedResponse(self) + + @property + @override + def qs(self) -> Querystring: + return Querystring(array_format="comma") + + @property + @override + def auth_headers(self) -> dict[str, str]: + api_key = self.api_key + return {"Authorization": f"Bearer {api_key}"} + + @property + @override + def default_headers(self) -> dict[str, str | Omit]: + return { + **super().default_headers, + "X-Stainless-Async": f"async:{get_async_library()}", + "X-Kernel-Project-Id": self.project_id if self.project_id is not None else Omit(), + **self._custom_headers, + } + + def copy( + self, + *, + api_key: str | None = None, + project_id: str | None = None, + environment: Literal["production", "development"] | None = None, + 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__( + api_key=api_key or self.api_key, + project_id=project_id or self.project_id, + base_url=base_url or self.base_url, + environment=environment or self._environment, + 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 KernelWithRawResponse: + _client: Kernel + + def __init__(self, client: Kernel) -> None: + self._client = client + + @cached_property + def deployments(self) -> deployments.DeploymentsResourceWithRawResponse: + """Create and manage app deployments and stream deployment events.""" + from .resources.deployments import DeploymentsResourceWithRawResponse + + return DeploymentsResourceWithRawResponse(self._client.deployments) + + @cached_property + def apps(self) -> apps.AppsResourceWithRawResponse: + """List applications and versions.""" + from .resources.apps import AppsResourceWithRawResponse + + return AppsResourceWithRawResponse(self._client.apps) + + @cached_property + def invocations(self) -> invocations.InvocationsResourceWithRawResponse: + """Invoke actions and stream or query invocation status and events.""" + from .resources.invocations import InvocationsResourceWithRawResponse + + return InvocationsResourceWithRawResponse(self._client.invocations) + + @cached_property + def browsers(self) -> browsers.BrowsersResourceWithRawResponse: + """Create and manage browser sessions.""" + from .resources.browsers import BrowsersResourceWithRawResponse + + return BrowsersResourceWithRawResponse(self._client.browsers) + + @cached_property + def profiles(self) -> profiles.ProfilesResourceWithRawResponse: + """Create, list, retrieve, and delete browser profiles.""" + from .resources.profiles import ProfilesResourceWithRawResponse + + return ProfilesResourceWithRawResponse(self._client.profiles) + + @cached_property + def auth(self) -> auth.AuthResourceWithRawResponse: + from .resources.auth import AuthResourceWithRawResponse + + return AuthResourceWithRawResponse(self._client.auth) + + @cached_property + def proxies(self) -> proxies.ProxiesResourceWithRawResponse: + """Create and manage proxy configurations for routing browser traffic.""" + from .resources.proxies import ProxiesResourceWithRawResponse + + return ProxiesResourceWithRawResponse(self._client.proxies) + + @cached_property + def extensions(self) -> extensions.ExtensionsResourceWithRawResponse: + """Create, list, retrieve, and delete browser extensions.""" + from .resources.extensions import ExtensionsResourceWithRawResponse + + return ExtensionsResourceWithRawResponse(self._client.extensions) + + @cached_property + def browser_pools(self) -> browser_pools.BrowserPoolsResourceWithRawResponse: + """Create and manage browser pools for acquiring and releasing browsers.""" + from .resources.browser_pools import BrowserPoolsResourceWithRawResponse + + return BrowserPoolsResourceWithRawResponse(self._client.browser_pools) + + @cached_property + def credentials(self) -> credentials.CredentialsResourceWithRawResponse: + """Create and manage credentials for authentication.""" + from .resources.credentials import CredentialsResourceWithRawResponse + + return CredentialsResourceWithRawResponse(self._client.credentials) + + @cached_property + def projects(self) -> projects.ProjectsResourceWithRawResponse: + """ + Create and manage projects for resource isolation within an organization. + When projects are disabled for the organization, project operations return + `404` with code `projects_disabled`. + """ + from .resources.projects import ProjectsResourceWithRawResponse + + return ProjectsResourceWithRawResponse(self._client.projects) + + @cached_property + def organization(self) -> organization.OrganizationResourceWithRawResponse: + from .resources.organization import OrganizationResourceWithRawResponse + + return OrganizationResourceWithRawResponse(self._client.organization) + + @cached_property + def audit_logs(self) -> audit_logs.AuditLogsResourceWithRawResponse: + """Read audit log records for the authenticated organization.""" + from .resources.audit_logs import AuditLogsResourceWithRawResponse + + return AuditLogsResourceWithRawResponse(self._client.audit_logs) + + @cached_property + def api_keys(self) -> api_keys.APIKeysResourceWithRawResponse: + """Create and manage API keys for organization and project-scoped access.""" + from .resources.api_keys import APIKeysResourceWithRawResponse + + return APIKeysResourceWithRawResponse(self._client.api_keys) + + @cached_property + def credential_providers(self) -> credential_providers.CredentialProvidersResourceWithRawResponse: + """Configure external credential providers like 1Password.""" + from .resources.credential_providers import CredentialProvidersResourceWithRawResponse + + return CredentialProvidersResourceWithRawResponse(self._client.credential_providers) + + +class AsyncKernelWithRawResponse: + _client: AsyncKernel + + def __init__(self, client: AsyncKernel) -> None: + self._client = client + + @cached_property + def deployments(self) -> deployments.AsyncDeploymentsResourceWithRawResponse: + """Create and manage app deployments and stream deployment events.""" + from .resources.deployments import AsyncDeploymentsResourceWithRawResponse + + return AsyncDeploymentsResourceWithRawResponse(self._client.deployments) + + @cached_property + def apps(self) -> apps.AsyncAppsResourceWithRawResponse: + """List applications and versions.""" + from .resources.apps import AsyncAppsResourceWithRawResponse + + return AsyncAppsResourceWithRawResponse(self._client.apps) + + @cached_property + def invocations(self) -> invocations.AsyncInvocationsResourceWithRawResponse: + """Invoke actions and stream or query invocation status and events.""" + from .resources.invocations import AsyncInvocationsResourceWithRawResponse + + return AsyncInvocationsResourceWithRawResponse(self._client.invocations) + + @cached_property + def browsers(self) -> browsers.AsyncBrowsersResourceWithRawResponse: + """Create and manage browser sessions.""" + from .resources.browsers import AsyncBrowsersResourceWithRawResponse + + return AsyncBrowsersResourceWithRawResponse(self._client.browsers) + + @cached_property + def profiles(self) -> profiles.AsyncProfilesResourceWithRawResponse: + """Create, list, retrieve, and delete browser profiles.""" + from .resources.profiles import AsyncProfilesResourceWithRawResponse + + return AsyncProfilesResourceWithRawResponse(self._client.profiles) + + @cached_property + def auth(self) -> auth.AsyncAuthResourceWithRawResponse: + from .resources.auth import AsyncAuthResourceWithRawResponse + + return AsyncAuthResourceWithRawResponse(self._client.auth) + + @cached_property + def proxies(self) -> proxies.AsyncProxiesResourceWithRawResponse: + """Create and manage proxy configurations for routing browser traffic.""" + from .resources.proxies import AsyncProxiesResourceWithRawResponse + + return AsyncProxiesResourceWithRawResponse(self._client.proxies) + + @cached_property + def extensions(self) -> extensions.AsyncExtensionsResourceWithRawResponse: + """Create, list, retrieve, and delete browser extensions.""" + from .resources.extensions import AsyncExtensionsResourceWithRawResponse + + return AsyncExtensionsResourceWithRawResponse(self._client.extensions) + + @cached_property + def browser_pools(self) -> browser_pools.AsyncBrowserPoolsResourceWithRawResponse: + """Create and manage browser pools for acquiring and releasing browsers.""" + from .resources.browser_pools import AsyncBrowserPoolsResourceWithRawResponse + + return AsyncBrowserPoolsResourceWithRawResponse(self._client.browser_pools) + + @cached_property + def credentials(self) -> credentials.AsyncCredentialsResourceWithRawResponse: + """Create and manage credentials for authentication.""" + from .resources.credentials import AsyncCredentialsResourceWithRawResponse + + return AsyncCredentialsResourceWithRawResponse(self._client.credentials) + + @cached_property + def projects(self) -> projects.AsyncProjectsResourceWithRawResponse: + """ + Create and manage projects for resource isolation within an organization. + When projects are disabled for the organization, project operations return + `404` with code `projects_disabled`. + """ + from .resources.projects import AsyncProjectsResourceWithRawResponse + + return AsyncProjectsResourceWithRawResponse(self._client.projects) + + @cached_property + def organization(self) -> organization.AsyncOrganizationResourceWithRawResponse: + from .resources.organization import AsyncOrganizationResourceWithRawResponse + + return AsyncOrganizationResourceWithRawResponse(self._client.organization) + + @cached_property + def audit_logs(self) -> audit_logs.AsyncAuditLogsResourceWithRawResponse: + """Read audit log records for the authenticated organization.""" + from .resources.audit_logs import AsyncAuditLogsResourceWithRawResponse + + return AsyncAuditLogsResourceWithRawResponse(self._client.audit_logs) + + @cached_property + def api_keys(self) -> api_keys.AsyncAPIKeysResourceWithRawResponse: + """Create and manage API keys for organization and project-scoped access.""" + from .resources.api_keys import AsyncAPIKeysResourceWithRawResponse + + return AsyncAPIKeysResourceWithRawResponse(self._client.api_keys) + + @cached_property + def credential_providers(self) -> credential_providers.AsyncCredentialProvidersResourceWithRawResponse: + """Configure external credential providers like 1Password.""" + from .resources.credential_providers import AsyncCredentialProvidersResourceWithRawResponse + + return AsyncCredentialProvidersResourceWithRawResponse(self._client.credential_providers) + + +class KernelWithStreamedResponse: + _client: Kernel + + def __init__(self, client: Kernel) -> None: + self._client = client + + @cached_property + def deployments(self) -> deployments.DeploymentsResourceWithStreamingResponse: + """Create and manage app deployments and stream deployment events.""" + from .resources.deployments import DeploymentsResourceWithStreamingResponse + + return DeploymentsResourceWithStreamingResponse(self._client.deployments) + + @cached_property + def apps(self) -> apps.AppsResourceWithStreamingResponse: + """List applications and versions.""" + from .resources.apps import AppsResourceWithStreamingResponse + + return AppsResourceWithStreamingResponse(self._client.apps) + + @cached_property + def invocations(self) -> invocations.InvocationsResourceWithStreamingResponse: + """Invoke actions and stream or query invocation status and events.""" + from .resources.invocations import InvocationsResourceWithStreamingResponse + + return InvocationsResourceWithStreamingResponse(self._client.invocations) + + @cached_property + def browsers(self) -> browsers.BrowsersResourceWithStreamingResponse: + """Create and manage browser sessions.""" + from .resources.browsers import BrowsersResourceWithStreamingResponse + + return BrowsersResourceWithStreamingResponse(self._client.browsers) + + @cached_property + def profiles(self) -> profiles.ProfilesResourceWithStreamingResponse: + """Create, list, retrieve, and delete browser profiles.""" + from .resources.profiles import ProfilesResourceWithStreamingResponse + + return ProfilesResourceWithStreamingResponse(self._client.profiles) + + @cached_property + def auth(self) -> auth.AuthResourceWithStreamingResponse: + from .resources.auth import AuthResourceWithStreamingResponse + + return AuthResourceWithStreamingResponse(self._client.auth) + + @cached_property + def proxies(self) -> proxies.ProxiesResourceWithStreamingResponse: + """Create and manage proxy configurations for routing browser traffic.""" + from .resources.proxies import ProxiesResourceWithStreamingResponse + + return ProxiesResourceWithStreamingResponse(self._client.proxies) + + @cached_property + def extensions(self) -> extensions.ExtensionsResourceWithStreamingResponse: + """Create, list, retrieve, and delete browser extensions.""" + from .resources.extensions import ExtensionsResourceWithStreamingResponse + + return ExtensionsResourceWithStreamingResponse(self._client.extensions) + + @cached_property + def browser_pools(self) -> browser_pools.BrowserPoolsResourceWithStreamingResponse: + """Create and manage browser pools for acquiring and releasing browsers.""" + from .resources.browser_pools import BrowserPoolsResourceWithStreamingResponse + + return BrowserPoolsResourceWithStreamingResponse(self._client.browser_pools) + + @cached_property + def credentials(self) -> credentials.CredentialsResourceWithStreamingResponse: + """Create and manage credentials for authentication.""" + from .resources.credentials import CredentialsResourceWithStreamingResponse + + return CredentialsResourceWithStreamingResponse(self._client.credentials) + + @cached_property + def projects(self) -> projects.ProjectsResourceWithStreamingResponse: + """ + Create and manage projects for resource isolation within an organization. + When projects are disabled for the organization, project operations return + `404` with code `projects_disabled`. + """ + from .resources.projects import ProjectsResourceWithStreamingResponse + + return ProjectsResourceWithStreamingResponse(self._client.projects) + + @cached_property + def organization(self) -> organization.OrganizationResourceWithStreamingResponse: + from .resources.organization import OrganizationResourceWithStreamingResponse + + return OrganizationResourceWithStreamingResponse(self._client.organization) + + @cached_property + def audit_logs(self) -> audit_logs.AuditLogsResourceWithStreamingResponse: + """Read audit log records for the authenticated organization.""" + from .resources.audit_logs import AuditLogsResourceWithStreamingResponse + + return AuditLogsResourceWithStreamingResponse(self._client.audit_logs) + + @cached_property + def api_keys(self) -> api_keys.APIKeysResourceWithStreamingResponse: + """Create and manage API keys for organization and project-scoped access.""" + from .resources.api_keys import APIKeysResourceWithStreamingResponse + + return APIKeysResourceWithStreamingResponse(self._client.api_keys) + + @cached_property + def credential_providers(self) -> credential_providers.CredentialProvidersResourceWithStreamingResponse: + """Configure external credential providers like 1Password.""" + from .resources.credential_providers import CredentialProvidersResourceWithStreamingResponse + + return CredentialProvidersResourceWithStreamingResponse(self._client.credential_providers) + + +class AsyncKernelWithStreamedResponse: + _client: AsyncKernel + + def __init__(self, client: AsyncKernel) -> None: + self._client = client + + @cached_property + def deployments(self) -> deployments.AsyncDeploymentsResourceWithStreamingResponse: + """Create and manage app deployments and stream deployment events.""" + from .resources.deployments import AsyncDeploymentsResourceWithStreamingResponse + + return AsyncDeploymentsResourceWithStreamingResponse(self._client.deployments) + + @cached_property + def apps(self) -> apps.AsyncAppsResourceWithStreamingResponse: + """List applications and versions.""" + from .resources.apps import AsyncAppsResourceWithStreamingResponse + + return AsyncAppsResourceWithStreamingResponse(self._client.apps) + + @cached_property + def invocations(self) -> invocations.AsyncInvocationsResourceWithStreamingResponse: + """Invoke actions and stream or query invocation status and events.""" + from .resources.invocations import AsyncInvocationsResourceWithStreamingResponse + + return AsyncInvocationsResourceWithStreamingResponse(self._client.invocations) + + @cached_property + def browsers(self) -> browsers.AsyncBrowsersResourceWithStreamingResponse: + """Create and manage browser sessions.""" + from .resources.browsers import AsyncBrowsersResourceWithStreamingResponse + + return AsyncBrowsersResourceWithStreamingResponse(self._client.browsers) + + @cached_property + def profiles(self) -> profiles.AsyncProfilesResourceWithStreamingResponse: + """Create, list, retrieve, and delete browser profiles.""" + from .resources.profiles import AsyncProfilesResourceWithStreamingResponse + + return AsyncProfilesResourceWithStreamingResponse(self._client.profiles) + + @cached_property + def auth(self) -> auth.AsyncAuthResourceWithStreamingResponse: + from .resources.auth import AsyncAuthResourceWithStreamingResponse + + return AsyncAuthResourceWithStreamingResponse(self._client.auth) + + @cached_property + def proxies(self) -> proxies.AsyncProxiesResourceWithStreamingResponse: + """Create and manage proxy configurations for routing browser traffic.""" + from .resources.proxies import AsyncProxiesResourceWithStreamingResponse + + return AsyncProxiesResourceWithStreamingResponse(self._client.proxies) + + @cached_property + def extensions(self) -> extensions.AsyncExtensionsResourceWithStreamingResponse: + """Create, list, retrieve, and delete browser extensions.""" + from .resources.extensions import AsyncExtensionsResourceWithStreamingResponse + + return AsyncExtensionsResourceWithStreamingResponse(self._client.extensions) + + @cached_property + def browser_pools(self) -> browser_pools.AsyncBrowserPoolsResourceWithStreamingResponse: + """Create and manage browser pools for acquiring and releasing browsers.""" + from .resources.browser_pools import AsyncBrowserPoolsResourceWithStreamingResponse + + return AsyncBrowserPoolsResourceWithStreamingResponse(self._client.browser_pools) + + @cached_property + def credentials(self) -> credentials.AsyncCredentialsResourceWithStreamingResponse: + """Create and manage credentials for authentication.""" + from .resources.credentials import AsyncCredentialsResourceWithStreamingResponse + + return AsyncCredentialsResourceWithStreamingResponse(self._client.credentials) + + @cached_property + def projects(self) -> projects.AsyncProjectsResourceWithStreamingResponse: + """ + Create and manage projects for resource isolation within an organization. + When projects are disabled for the organization, project operations return + `404` with code `projects_disabled`. + """ + from .resources.projects import AsyncProjectsResourceWithStreamingResponse + + return AsyncProjectsResourceWithStreamingResponse(self._client.projects) + + @cached_property + def organization(self) -> organization.AsyncOrganizationResourceWithStreamingResponse: + from .resources.organization import AsyncOrganizationResourceWithStreamingResponse + + return AsyncOrganizationResourceWithStreamingResponse(self._client.organization) + + @cached_property + def audit_logs(self) -> audit_logs.AsyncAuditLogsResourceWithStreamingResponse: + """Read audit log records for the authenticated organization.""" + from .resources.audit_logs import AsyncAuditLogsResourceWithStreamingResponse + + return AsyncAuditLogsResourceWithStreamingResponse(self._client.audit_logs) + + @cached_property + def api_keys(self) -> api_keys.AsyncAPIKeysResourceWithStreamingResponse: + """Create and manage API keys for organization and project-scoped access.""" + from .resources.api_keys import AsyncAPIKeysResourceWithStreamingResponse + + return AsyncAPIKeysResourceWithStreamingResponse(self._client.api_keys) + + @cached_property + def credential_providers(self) -> credential_providers.AsyncCredentialProvidersResourceWithStreamingResponse: + """Configure external credential providers like 1Password.""" + from .resources.credential_providers import AsyncCredentialProvidersResourceWithStreamingResponse + + return AsyncCredentialProvidersResourceWithStreamingResponse(self._client.credential_providers) + + +Client = Kernel + +AsyncClient = AsyncKernel diff --git a/src/kernel/_compat.py b/src/kernel/_compat.py new file mode 100644 index 00000000..e6690a4f --- /dev/null +++ b/src/kernel/_compat.py @@ -0,0 +1,226 @@ +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, TypedDict + +import pydantic +from pydantic.fields import FieldInfo + +from ._types import IncEx, StrBytesIntFloat + +_T = TypeVar("_T") +_ModelT = TypeVar("_ModelT", bound=pydantic.BaseModel) + +# --------------- Pydantic v2, v3 compatibility --------------- + +# Pyright incorrectly reports some of our functions as overriding a method when they don't +# pyright: reportIncompatibleMethodOverride=false + +PYDANTIC_V1 = pydantic.VERSION.startswith("1.") + +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: + # 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.datetime_parse import parse_date as parse_date, parse_datetime as parse_datetime + else: + 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, + ) + + +# refactored config +if TYPE_CHECKING: + from pydantic import ConfigDict as 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_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_V1: + return field.required # type: ignore + return field.is_required() + + +def field_get_default(field: FieldInfo) -> Any: + value = field.get_default() + 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_V1: + return field.outer_type_ # type: ignore + return field.annotation + + +def get_model_config(model: type[pydantic.BaseModel]) -> Any: + 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_V1: + return model.__fields__ # type: ignore + return model.model_fields + + +def model_copy(model: _ModelT, *, deep: bool = False) -> _ModelT: + 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_V1: + return model.json(indent=indent) # type: ignore + return model.model_dump_json(indent=indent) + + +class _ModelDumpKwargs(TypedDict, total=False): + by_alias: bool + + +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", + by_alias: bool | None = None, +) -> dict[str, Any]: + if (not PYDANTIC_V1) or hasattr(model, "model_dump"): + kwargs: _ModelDumpKwargs = {} + if by_alias is not None: + kwargs["by_alias"] = by_alias + return model.model_dump( + mode=mode, + exclude=exclude, + exclude_unset=exclude_unset, + exclude_defaults=exclude_defaults, + # warnings are not supported in Pydantic v1 + warnings=True if PYDANTIC_V1 else warnings, + **kwargs, + ) + return cast( + "dict[str, Any]", + model.dict( # pyright: ignore[reportDeprecated, reportUnnecessaryCast] + exclude=exclude, exclude_unset=exclude_unset, exclude_defaults=exclude_defaults, by_alias=bool(by_alias) + ), + ) + + +def model_parse(model: type[_ModelT], data: Any) -> _ModelT: + if PYDANTIC_V1: + return model.parse_obj(data) # pyright: ignore[reportDeprecated] + return model.model_validate(data) + + +# generic models +if TYPE_CHECKING: + + class GenericModel(pydantic.BaseModel): ... + +else: + 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): ... + + +# 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/kernel/_constants.py b/src/kernel/_constants.py new file mode 100644 index 00000000..6ddf2c71 --- /dev/null +++ b/src/kernel/_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/kernel/_exceptions.py b/src/kernel/_exceptions.py new file mode 100644 index 00000000..53cd14ce --- /dev/null +++ b/src/kernel/_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 KernelError(Exception): + pass + + +class APIError(KernelError): + 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/kernel/_files.py b/src/kernel/_files.py new file mode 100644 index 00000000..3fc9f62e --- /dev/null +++ b/src/kernel/_files.py @@ -0,0 +1,173 @@ +from __future__ import annotations + +import io +import os +import pathlib +from typing import Sequence, cast, overload +from typing_extensions import TypeVar, TypeGuard + +import anyio + +from ._types import ( + FileTypes, + FileContent, + RequestFiles, + HttpxFileTypes, + Base64FileInput, + HttpxFileContent, + HttpxRequestFiles, +) +from ._utils import is_list, is_mapping, is_tuple_t, is_mapping_t, is_sequence_t + +_T = TypeVar("_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. See https://github.com/kernel/kernel-python-sdk/tree/main#file-uploads" + ) 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(f"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 + + +def deepcopy_with_paths(item: _T, paths: Sequence[Sequence[str]]) -> _T: + """Copy only the containers along the given paths. + + Used to guard against mutation by extract_files without copying the entire structure. + Only dicts and lists that lie on a path are copied; everything else + is returned by reference. + + For example, given paths=[["foo", "files", "file"]] and the structure: + { + "foo": { + "bar": {"baz": {}}, + "files": {"file": } + } + } + The root dict, "foo", and "files" are copied (they lie on the path). + "bar" and "baz" are returned by reference (off the path). + """ + return _deepcopy_with_paths(item, paths, 0) + + +def _deepcopy_with_paths(item: _T, paths: Sequence[Sequence[str]], index: int) -> _T: + if not paths: + return item + if is_mapping(item): + key_to_paths: dict[str, list[Sequence[str]]] = {} + for path in paths: + if index < len(path): + key_to_paths.setdefault(path[index], []).append(path) + + # if no path continues through this mapping, it won't be mutated and copying it is redundant + if not key_to_paths: + return item + + result = dict(item) + for key, subpaths in key_to_paths.items(): + if key in result: + result[key] = _deepcopy_with_paths(result[key], subpaths, index + 1) + return cast(_T, result) + if is_list(item): + array_paths = [path for path in paths if index < len(path) and path[index] == ""] + + # if no path expects a list here, nothing will be mutated inside it - return by reference + if not array_paths: + return cast(_T, item) + return cast(_T, [_deepcopy_with_paths(entry, array_paths, index + 1) for entry in item]) + return item diff --git a/src/kernel/_models.py b/src/kernel/_models.py new file mode 100644 index 00000000..8c5ab260 --- /dev/null +++ b/src/kernel/_models.py @@ -0,0 +1,952 @@ +from __future__ import annotations + +import os +import inspect +import weakref +from typing import ( + IO, + TYPE_CHECKING, + Any, + Type, + Union, + Generic, + TypeVar, + Callable, + Iterable, + Optional, + AsyncIterable, + cast, +) +from datetime import date, datetime +from typing_extensions import ( + List, + Unpack, + Literal, + ClassVar, + Protocol, + Required, + Annotated, + ParamSpec, + TypeAlias, + 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_V1, + 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 import GetCoreSchemaHandler, ValidatorFunctionWrapHandler + from pydantic_core import CoreSchema, core_schema + from pydantic_core.core_schema import ModelField, ModelSchema, LiteralSchema, ModelFieldsSchema +else: + try: + from pydantic_core import CoreSchema, core_schema + except ImportError: + CoreSchema = None + core_schema = None + +__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_V1: + + @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 + else: + model_config: ClassVar[ConfigDict] = ConfigDict( + extra="allow", defer_build=coerce_boolean(os.environ.get("DEFER_PYDANTIC_BUILD", "true")) + ) + + 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_V1: + _fields_set.add(key) + fields_values[key] = parsed + else: + _extra[key] = parsed + + object.__setattr__(m, "__dict__", fields_values) + + 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 + + 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 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 + # 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, + 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, + 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 + + 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 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 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. + """ + 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") + 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, + by_alias=by_alias if by_alias is not None else False, + 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, + 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, + 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 + + 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") + 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, + exclude=exclude, + by_alias=by_alias if by_alias is not None else False, + exclude_unset=exclude_unset, + exclude_defaults=exclude_defaults, + exclude_none=exclude_none, + ) + + +class _EagerIterable(list[_T], Generic[_T]): + """ + Accepts any Iterable[T] input (including generators), consumes it + eagerly, and validates all items upfront. + + Validation preserves the original container type where possible + (e.g. a set[T] stays a set[T]). Serialization (model_dump / JSON) + always emits a list — round-tripping through model_dump() will not + restore the original container type. + """ + + @classmethod + def __get_pydantic_core_schema__( + cls, + source_type: Any, + handler: GetCoreSchemaHandler, + ) -> CoreSchema: + (item_type,) = get_args(source_type) or (Any,) + item_schema: CoreSchema = handler.generate_schema(item_type) + list_of_items_schema: CoreSchema = core_schema.list_schema(item_schema) + + return core_schema.no_info_wrap_validator_function( + cls._validate, + list_of_items_schema, + serialization=core_schema.plain_serializer_function_ser_schema( + cls._serialize, + info_arg=False, + ), + ) + + @staticmethod + def _validate(v: Iterable[_T], handler: "ValidatorFunctionWrapHandler") -> Any: + original_type: type[Any] = type(v) + + # Normalize to list so list_schema can validate each item + if isinstance(v, list): + items: list[_T] = v + else: + try: + items = list(v) + except TypeError as e: + raise TypeError("Value is not iterable") from e + + # Validate items against the inner schema + validated: list[_T] = handler(items) + + # Reconstruct original container type + if original_type is list: + return validated + # str(list) produces the list's repr, not a string built from items, + # so skip reconstruction for str and its subclasses. + if issubclass(original_type, str): + return validated + try: + return original_type(validated) + except (TypeError, ValueError): + # If the type cannot be reconstructed, just return the validated list + return validated + + @staticmethod + def _serialize(v: Iterable[_T]) -> list[_T]: + """Always serialize as a list so Pydantic's JSON encoder is happy.""" + if isinstance(v, list): + return v + return list(v) + + +EagerIterable: TypeAlias = Annotated[Iterable[_T], _EagerIterable] + + +def _construct_field(value: object, field: FieldInfo, key: str) -> object: + if value is None: + return field_get_default(field) + + 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}") + + return construct_type(value=value, type_=type_, metadata=getattr(field, "metadata", None)) + + +def _get_extra_fields_type(cls: type[pydantic.BaseModel]) -> type | None: + if PYDANTIC_V1: + # 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 + + +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. + + ```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: + cached = DISCRIMINATOR_CACHE.get(union) + if cached is not None: + return cached + + 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_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_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 + else: + 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 + + if not mapping: + return None + + details = DiscriminatorDetails( + mapping=mapping, + discriminator_field=discriminator_field_name, + discriminator_alias=discriminator_alias, + ) + DISCRIMINATOR_CACHE.setdefault(union, 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 not PYDANTIC_V1: + 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 + content: Union[bytes, bytearray, IO[bytes], Iterable[bytes], AsyncIterable[bytes], None] + 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 + + content: Union[bytes, bytearray, IO[bytes], Iterable[bytes], AsyncIterable[bytes], 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_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): + 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_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 + model_construct = construct diff --git a/src/kernel/_qs.py b/src/kernel/_qs.py new file mode 100644 index 00000000..4127c19c --- /dev/null +++ b/src/kernel/_qs.py @@ -0,0 +1,149 @@ +from __future__ import annotations + +from typing import Any, List, Tuple, Union, Mapping, TypeVar +from urllib.parse import parse_qs, urlencode +from typing_extensions import get_args + +from ._types import NotGiven, ArrayFormat, NestedFormat, not_given +from ._utils import flatten + +_T = TypeVar("_T") + +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: ArrayFormat | NotGiven = not_given, + nested_format: NestedFormat | NotGiven = 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: ArrayFormat | NotGiven = not_given, + nested_format: NestedFormat | NotGiven = 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": + items = [] + for i, item in enumerate(value): + items.extend(self._stringify_item(f"{key}[{i}]", item, opts)) + return items + 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: 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/kernel/_resource.py b/src/kernel/_resource.py new file mode 100644 index 00000000..eb51ab58 --- /dev/null +++ b/src/kernel/_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 Kernel, AsyncKernel + + +class SyncAPIResource: + _client: Kernel + + def __init__(self, client: Kernel) -> 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: AsyncKernel + + def __init__(self, client: AsyncKernel) -> 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/kernel/_response.py b/src/kernel/_response.py new file mode 100644 index 00000000..a6c5fa30 --- /dev/null +++ b/src/kernel/_response.py @@ -0,0 +1,833 @@ +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 KernelError, 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), + options=self._options, + ), + ) + + 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), + options=self._options, + ), + ) + + 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), + options=self._options, + ), + ) + + 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 kernel 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 kernel 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 kernel 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 `kernel._streaming` for reference", + ) + + +class StreamAlreadyConsumed(KernelError): + """ + 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/kernel/_streaming.py b/src/kernel/_streaming.py new file mode 100644 index 00000000..5520edb5 --- /dev/null +++ b/src/kernel/_streaming.py @@ -0,0 +1,338 @@ +# 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, Optional, 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 Kernel, AsyncKernel + from ._models import FinalRequestOptions + + +_T = TypeVar("_T") + + +class Stream(Generic[_T]): + """Provides the core interface to iterate over a synchronous stream response.""" + + response: httpx.Response + _options: Optional[FinalRequestOptions] = None + _decoder: SSEBytesDecoder + + def __init__( + self, + *, + cast_to: type[_T], + response: httpx.Response, + client: Kernel, + options: Optional[FinalRequestOptions] = None, + ) -> None: + self.response = response + self._cast_to = cast_to + self._client = client + self._options = options + 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() + + 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 + + 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 + _options: Optional[FinalRequestOptions] = None + _decoder: SSEDecoder | SSEBytesDecoder + + def __init__( + self, + *, + cast_to: type[_T], + response: httpx.Response, + client: AsyncKernel, + options: Optional[FinalRequestOptions] = None, + ) -> None: + self.response = response + self._cast_to = cast_to + self._client = client + self._options = options + 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() + + 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 + + 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/kernel/_types.py b/src/kernel/_types.py new file mode 100644 index 00000000..d924c204 --- /dev/null +++ b/src/kernel/_types.py @@ -0,0 +1,273 @@ +from __future__ import annotations + +from os import PathLike +from typing import ( + IO, + TYPE_CHECKING, + Any, + Dict, + List, + Type, + Tuple, + Union, + Mapping, + TypeVar, + Callable, + Iterable, + Iterator, + Optional, + Sequence, + AsyncIterable, +) +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") + +ArrayFormat = Literal["comma", "repeat", "indices", "brackets"] +NestedFormat = Literal["dots", "brackets"] + + +# 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. + + +# Used for sending raw binary data / streaming data in request bodies +# e.g. for file uploads without multipart encoding +BinaryTypes = Union[bytes, bytearray, IO[bytes], Iterable[bytes]] +AsyncBinaryTypes = Union[bytes, bytearray, IO[bytes], AsyncIterable[bytes]] + +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 kernel 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: + """ + 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 create(timeout: Timeout | None | NotGiven = not_given): ... + + + create(timeout=1) # 1s timeout + create(timeout=None) # No timeout + create() # Default timeout behavior + ``` + """ + + def __bool__(self) -> Literal[False]: + return False + + @override + def __repr__(self) -> str: + return "NOT_GIVEN" + + +not_given = NotGiven() +# for backwards compatibility: +NOT_GIVEN = NotGiven() + + +class Omit: + """ + 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 + 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 + + +omit = Omit() + + +@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 + # + # Note: index() and count() methods are intentionally omitted to allow pyright to properly + # infer TypedDict types when dict literals are used in lists assigned to SequenceNotStr. + 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 __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/kernel/_utils/__init__.py b/src/kernel/_utils/__init__.py new file mode 100644 index 00000000..1c090e51 --- /dev/null +++ b/src/kernel/_utils/__init__.py @@ -0,0 +1,64 @@ +from ._path import path_template as path_template +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, + 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, + strip_not_given as strip_not_given, + 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 ._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, + 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, +) +from ._datetime_parse import parse_date as parse_date, parse_datetime as parse_datetime diff --git a/src/kernel/_utils/_compat.py b/src/kernel/_utils/_compat.py new file mode 100644 index 00000000..2c70b299 --- /dev/null +++ b/src/kernel/_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 # type: ignore[comparison-overlap] + + +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/kernel/_utils/_datetime_parse.py b/src/kernel/_utils/_datetime_parse.py new file mode 100644 index 00000000..7cb9d9e6 --- /dev/null +++ b/src/kernel/_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/kernel/_utils/_json.py b/src/kernel/_utils/_json.py new file mode 100644 index 00000000..60584214 --- /dev/null +++ b/src/kernel/_utils/_json.py @@ -0,0 +1,35 @@ +import json +from typing import Any +from datetime import datetime +from typing_extensions import override + +import pydantic + +from .._compat import model_dump + + +def openapi_dumps(obj: Any) -> bytes: + """ + Serialize an object to UTF-8 encoded JSON bytes. + + Extends the standard json.dumps with support for additional types + commonly used in the SDK, such as `datetime`, `pydantic.BaseModel`, etc. + """ + return json.dumps( + obj, + cls=_CustomEncoder, + # Uses the same defaults as httpx's JSON serialization + ensure_ascii=False, + separators=(",", ":"), + allow_nan=False, + ).encode() + + +class _CustomEncoder(json.JSONEncoder): + @override + def default(self, o: Any) -> Any: + if isinstance(o, datetime): + return o.isoformat() + if isinstance(o, pydantic.BaseModel): + return model_dump(o, exclude_unset=True, mode="json", by_alias=True) + return super().default(o) diff --git a/src/kernel/_utils/_logs.py b/src/kernel/_utils/_logs.py new file mode 100644 index 00000000..4eff94ba --- /dev/null +++ b/src/kernel/_utils/_logs.py @@ -0,0 +1,25 @@ +import os +import logging + +logger: logging.Logger = logging.getLogger("kernel") +httpx_logger: logging.Logger = logging.getLogger("httpx") + + +def _basic_config() -> None: + # e.g. [2023-10-05 14:12:26 - kernel._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("KERNEL_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/kernel/_utils/_path.py b/src/kernel/_utils/_path.py new file mode 100644 index 00000000..4d6e1e4c --- /dev/null +++ b/src/kernel/_utils/_path.py @@ -0,0 +1,127 @@ +from __future__ import annotations + +import re +from typing import ( + Any, + Mapping, + Callable, +) +from urllib.parse import quote + +# Matches '.' or '..' where each dot is either literal or percent-encoded (%2e / %2E). +_DOT_SEGMENT_RE = re.compile(r"^(?:\.|%2[eE]){1,2}$") + +_PLACEHOLDER_RE = re.compile(r"\{(\w+)\}") + + +def _quote_path_segment_part(value: str) -> str: + """Percent-encode `value` for use in a URI path segment. + + Considers characters not in `pchar` set from RFC 3986 §3.3 to be unsafe. + https://datatracker.ietf.org/doc/html/rfc3986#section-3.3 + """ + # quote() already treats unreserved characters (letters, digits, and -._~) + # as safe, so we only need to add sub-delims, ':', and '@'. + # Notably, unlike the default `safe` for quote(), / is unsafe and must be quoted. + return quote(value, safe="!$&'()*+,;=:@") + + +def _quote_query_part(value: str) -> str: + """Percent-encode `value` for use in a URI query string. + + Considers &, = and characters not in `query` set from RFC 3986 §3.4 to be unsafe. + https://datatracker.ietf.org/doc/html/rfc3986#section-3.4 + """ + return quote(value, safe="!$'()*+,;:@/?") + + +def _quote_fragment_part(value: str) -> str: + """Percent-encode `value` for use in a URI fragment. + + Considers characters not in `fragment` set from RFC 3986 §3.5 to be unsafe. + https://datatracker.ietf.org/doc/html/rfc3986#section-3.5 + """ + return quote(value, safe="!$&'()*+,;=:@/?") + + +def _interpolate( + template: str, + values: Mapping[str, Any], + quoter: Callable[[str], str], +) -> str: + """Replace {name} placeholders in `template`, quoting each value with `quoter`. + + Placeholder names are looked up in `values`. + + Raises: + KeyError: If a placeholder is not found in `values`. + """ + # re.split with a capturing group returns alternating + # [text, name, text, name, ..., text] elements. + parts = _PLACEHOLDER_RE.split(template) + + for i in range(1, len(parts), 2): + name = parts[i] + if name not in values: + raise KeyError(f"a value for placeholder {{{name}}} was not provided") + val = values[name] + if val is None: + parts[i] = "null" + elif isinstance(val, bool): + parts[i] = "true" if val else "false" + else: + parts[i] = quoter(str(values[name])) + + return "".join(parts) + + +def path_template(template: str, /, **kwargs: Any) -> str: + """Interpolate {name} placeholders in `template` from keyword arguments. + + Args: + template: The template string containing {name} placeholders. + **kwargs: Keyword arguments to interpolate into the template. + + Returns: + The template with placeholders interpolated and percent-encoded. + + Safe characters for percent-encoding are dependent on the URI component. + Placeholders in path and fragment portions are percent-encoded where the `segment` + and `fragment` sets from RFC 3986 respectively are considered safe. + Placeholders in the query portion are percent-encoded where the `query` set from + RFC 3986 §3.3 is considered safe except for = and & characters. + + Raises: + KeyError: If a placeholder is not found in `kwargs`. + ValueError: If resulting path contains /./ or /../ segments (including percent-encoded dot-segments). + """ + # Split the template into path, query, and fragment portions. + fragment_template: str | None = None + query_template: str | None = None + + rest = template + if "#" in rest: + rest, fragment_template = rest.split("#", 1) + if "?" in rest: + rest, query_template = rest.split("?", 1) + path_template = rest + + # Interpolate each portion with the appropriate quoting rules. + path_result = _interpolate(path_template, kwargs, _quote_path_segment_part) + + # Reject dot-segments (. and ..) in the final assembled path. The check + # runs after interpolation so that adjacent placeholders or a mix of static + # text and placeholders that together form a dot-segment are caught. + # Also reject percent-encoded dot-segments to protect against incorrectly + # implemented normalization in servers/proxies. + for segment in path_result.split("/"): + if _DOT_SEGMENT_RE.match(segment): + raise ValueError(f"Constructed path {path_result!r} contains dot-segment {segment!r} which is not allowed") + + result = path_result + if query_template is not None: + result += "?" + _interpolate(query_template, kwargs, _quote_query_part) + if fragment_template is not None: + result += "#" + _interpolate(fragment_template, kwargs, _quote_fragment_part) + + return result diff --git a/src/kernel/_utils/_proxy.py b/src/kernel/_utils/_proxy.py new file mode 100644 index 00000000..0f239a33 --- /dev/null +++ b/src/kernel/_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/kernel/_utils/_reflection.py b/src/kernel/_utils/_reflection.py new file mode 100644 index 00000000..89aa712a --- /dev/null +++ b/src/kernel/_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/kernel/_utils/_resources_proxy.py b/src/kernel/_utils/_resources_proxy.py new file mode 100644 index 00000000..006a6390 --- /dev/null +++ b/src/kernel/_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 `kernel.resources` module. + + This is used so that we can lazily import `kernel.resources` only when + needed *and* so that users can just import `kernel` and reference `kernel.resources` + """ + + @override + def __load__(self) -> Any: + import importlib + + mod = importlib.import_module("kernel.resources") + return mod + + +resources = ResourcesProxy().__as_proxied__() diff --git a/src/kernel/_utils/_streams.py b/src/kernel/_utils/_streams.py new file mode 100644 index 00000000..f4a0208f --- /dev/null +++ b/src/kernel/_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/kernel/_utils/_sync.py b/src/kernel/_utils/_sync.py new file mode 100644 index 00000000..f6027c18 --- /dev/null +++ b/src/kernel/_utils/_sync.py @@ -0,0 +1,58 @@ +from __future__ import annotations + +import asyncio +import functools +from typing import 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") + + +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. + + 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/kernel/_utils/_transform.py b/src/kernel/_utils/_transform.py new file mode 100644 index 00000000..52075492 --- /dev/null +++ b/src/kernel/_utils/_transform.py @@ -0,0 +1,457 @@ +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 ._compat import get_origin, is_typeddict +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, +) + +_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. + """ + from .._compat import model_dump + + 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 omitted 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. + """ + from .._compat import model_dump + + 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 omitted 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/kernel/_utils/_typing.py b/src/kernel/_utils/_typing.py new file mode 100644 index 00000000..193109f3 --- /dev/null +++ b/src/kernel/_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/kernel/_utils/_utils.py b/src/kernel/_utils/_utils.py new file mode 100644 index 00000000..199cd231 --- /dev/null +++ b/src/kernel/_utils/_utils.py @@ -0,0 +1,433 @@ +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, get_args + +import sniffio + +from .._types import Omit, NotGiven, FileTypes, ArrayFormat, HeadersLike + +_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]], + array_format: ArrayFormat = "brackets", +) -> list[tuple[str, FileTypes]]: + """Recursively extract files from the given dictionary based on specified paths. + + A path may look like this ['foo', 'files', '', 'data']. + + ``array_format`` controls how ```` segments contribute to the emitted + field name. Supported values: ``"brackets"`` (``foo[]``), ``"repeat"`` and + ``"comma"`` (``foo``), ``"indices"`` (``foo[0]``, ``foo[1]``). + + 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, array_format=array_format)) + return files + + +def _array_suffix(array_format: ArrayFormat, array_index: int) -> str: + if array_format == "brackets": + return "[]" + if array_format == "indices": + return f"[{array_index}]" + if array_format == "repeat" or array_format == "comma": + # Both repeat the bare field name for each file part; there is no + # meaningful way to comma-join binary parts. + return "" + raise NotImplementedError( + f"Unknown array_format value: {array_format}, choose from {', '.join(get_args(ArrayFormat))}" + ) + + +def _extract_items( + obj: object, + path: Sequence[str], + *, + index: int, + flattened_key: str | None, + array_format: ArrayFormat, +) -> list[tuple[str, FileTypes]]: + try: + key = path[index] + except IndexError: + if not is_given(obj): + # 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 array_index, entry in enumerate(obj): + suffix = _array_suffix(array_format, array_index) + emitted_key = (flattened_key + suffix) if flattened_key else suffix + assert_is_file_content(entry, key=emitted_key) + files.append((emitted_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: + # Remove the field if there are no more dict keys in the path, + # only "" traversal markers or end. + if all(p == "" for p in 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, + array_format=array_format, + ) + 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 "") + _array_suffix(array_format, array_index) + ), + array_format=array_format, + ) + for array_index, item in enumerate(obj) + ] + ) + + # Something unexpected was passed, just ignore it. + return [] + + +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. +# 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 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 +# `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) + + +# 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/kernel/_version.py b/src/kernel/_version.py new file mode 100644 index 00000000..06739f24 --- /dev/null +++ b/src/kernel/_version.py @@ -0,0 +1,4 @@ +# File generated from our OpenAPI spec by Stainless. See CONTRIBUTING.md for details. + +__title__ = "kernel" +__version__ = "0.91.0" # x-release-please-version diff --git a/src/kernel/lib/.keep b/src/kernel/lib/.keep new file mode 100644 index 00000000..5e2c99fd --- /dev/null +++ b/src/kernel/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/kernel/pagination.py b/src/kernel/pagination.py new file mode 100644 index 00000000..b9e69daa --- /dev/null +++ b/src/kernel/pagination.py @@ -0,0 +1,182 @@ +# File generated from our OpenAPI spec by Stainless. See CONTRIBUTING.md for details. + +from typing import Any, List, Type, Generic, Mapping, TypeVar, Optional, cast +from typing_extensions import override + +from httpx import Response + +from ._utils import is_mapping, maybe_coerce_boolean, maybe_coerce_integer +from ._models import BaseModel +from ._base_client import BasePage, PageInfo, BaseSyncPage, BaseAsyncPage + +__all__ = ["SyncPageTokenPagination", "AsyncPageTokenPagination", "SyncOffsetPagination", "AsyncOffsetPagination"] + +_BaseModelT = TypeVar("_BaseModelT", bound=BaseModel) + +_T = TypeVar("_T") + + +class SyncPageTokenPagination(BaseSyncPage[_T], BasePage[_T], Generic[_T]): + items: List[_T] + next_page_token: Optional[str] = None + has_more: Optional[bool] = None + + @override + def _get_page_items(self) -> List[_T]: + items = self.items + if not items: + return [] + return items + + @override + def has_next_page(self) -> bool: + has_more = self.has_more + if has_more is not None and has_more is False: + return False + + return super().has_next_page() + + @override + def next_page_info(self) -> Optional[PageInfo]: + next_page_token = self.next_page_token + if not next_page_token: + return None + + return PageInfo(params={"page_token": next_page_token}) + + @classmethod + def build(cls: Type[_BaseModelT], *, response: Response, data: object) -> _BaseModelT: # noqa: ARG003 + return cls.construct( + None, + **{ + **(cast(Mapping[str, Any], data) if is_mapping(data) else {"items": data}), + "next_page_token": response.headers.get("X-Next-Page-Token"), + "has_more": maybe_coerce_boolean(response.headers.get("X-Has-More")), + }, + ) + + +class AsyncPageTokenPagination(BaseAsyncPage[_T], BasePage[_T], Generic[_T]): + items: List[_T] + next_page_token: Optional[str] = None + has_more: Optional[bool] = None + + @override + def _get_page_items(self) -> List[_T]: + items = self.items + if not items: + return [] + return items + + @override + def has_next_page(self) -> bool: + has_more = self.has_more + if has_more is not None and has_more is False: + return False + + return super().has_next_page() + + @override + def next_page_info(self) -> Optional[PageInfo]: + next_page_token = self.next_page_token + if not next_page_token: + return None + + return PageInfo(params={"page_token": next_page_token}) + + @classmethod + def build(cls: Type[_BaseModelT], *, response: Response, data: object) -> _BaseModelT: # noqa: ARG003 + return cls.construct( + None, + **{ + **(cast(Mapping[str, Any], data) if is_mapping(data) else {"items": data}), + "next_page_token": response.headers.get("X-Next-Page-Token"), + "has_more": maybe_coerce_boolean(response.headers.get("X-Has-More")), + }, + ) + + +class SyncOffsetPagination(BaseSyncPage[_T], BasePage[_T], Generic[_T]): + items: List[_T] + has_more: Optional[bool] = None + next_offset: Optional[int] = None + + @override + def _get_page_items(self) -> List[_T]: + items = self.items + if not items: + return [] + return items + + @override + def has_next_page(self) -> bool: + has_more = self.has_more + if has_more is not None and has_more is False: + return False + + return super().has_next_page() + + @override + def next_page_info(self) -> Optional[PageInfo]: + next_offset = self.next_offset + if next_offset is None: + return None # type: ignore[unreachable] + + length = len(self._get_page_items()) + current_count = next_offset + length + + return PageInfo(params={"offset": current_count}) + + @classmethod + def build(cls: Type[_BaseModelT], *, response: Response, data: object) -> _BaseModelT: # noqa: ARG003 + return cls.construct( + None, + **{ + **(cast(Mapping[str, Any], data) if is_mapping(data) else {"items": data}), + "has_more": maybe_coerce_boolean(response.headers.get("X-Has-More")), + "next_offset": maybe_coerce_integer(response.headers.get("X-Next-Offset")), + }, + ) + + +class AsyncOffsetPagination(BaseAsyncPage[_T], BasePage[_T], Generic[_T]): + items: List[_T] + has_more: Optional[bool] = None + next_offset: Optional[int] = None + + @override + def _get_page_items(self) -> List[_T]: + items = self.items + if not items: + return [] + return items + + @override + def has_next_page(self) -> bool: + has_more = self.has_more + if has_more is not None and has_more is False: + return False + + return super().has_next_page() + + @override + def next_page_info(self) -> Optional[PageInfo]: + next_offset = self.next_offset + if next_offset is None: + return None # type: ignore[unreachable] + + length = len(self._get_page_items()) + current_count = next_offset + length + + return PageInfo(params={"offset": current_count}) + + @classmethod + def build(cls: Type[_BaseModelT], *, response: Response, data: object) -> _BaseModelT: # noqa: ARG003 + return cls.construct( + None, + **{ + **(cast(Mapping[str, Any], data) if is_mapping(data) else {"items": data}), + "has_more": maybe_coerce_boolean(response.headers.get("X-Has-More")), + "next_offset": maybe_coerce_integer(response.headers.get("X-Next-Offset")), + }, + ) diff --git a/src/kernel/py.typed b/src/kernel/py.typed new file mode 100644 index 00000000..e69de29b diff --git a/src/kernel/resources/__init__.py b/src/kernel/resources/__init__.py new file mode 100644 index 00000000..586c499a --- /dev/null +++ b/src/kernel/resources/__init__.py @@ -0,0 +1,215 @@ +# File generated from our OpenAPI spec by Stainless. See CONTRIBUTING.md for details. + +from .apps import ( + AppsResource, + AsyncAppsResource, + AppsResourceWithRawResponse, + AsyncAppsResourceWithRawResponse, + AppsResourceWithStreamingResponse, + AsyncAppsResourceWithStreamingResponse, +) +from .auth import ( + AuthResource, + AsyncAuthResource, + AuthResourceWithRawResponse, + AsyncAuthResourceWithRawResponse, + AuthResourceWithStreamingResponse, + AsyncAuthResourceWithStreamingResponse, +) +from .proxies import ( + ProxiesResource, + AsyncProxiesResource, + ProxiesResourceWithRawResponse, + AsyncProxiesResourceWithRawResponse, + ProxiesResourceWithStreamingResponse, + AsyncProxiesResourceWithStreamingResponse, +) +from .api_keys import ( + APIKeysResource, + AsyncAPIKeysResource, + APIKeysResourceWithRawResponse, + AsyncAPIKeysResourceWithRawResponse, + APIKeysResourceWithStreamingResponse, + AsyncAPIKeysResourceWithStreamingResponse, +) +from .browsers import ( + BrowsersResource, + AsyncBrowsersResource, + BrowsersResourceWithRawResponse, + AsyncBrowsersResourceWithRawResponse, + BrowsersResourceWithStreamingResponse, + AsyncBrowsersResourceWithStreamingResponse, +) +from .profiles import ( + ProfilesResource, + AsyncProfilesResource, + ProfilesResourceWithRawResponse, + AsyncProfilesResourceWithRawResponse, + ProfilesResourceWithStreamingResponse, + AsyncProfilesResourceWithStreamingResponse, +) +from .projects import ( + ProjectsResource, + AsyncProjectsResource, + ProjectsResourceWithRawResponse, + AsyncProjectsResourceWithRawResponse, + ProjectsResourceWithStreamingResponse, + AsyncProjectsResourceWithStreamingResponse, +) +from .audit_logs import ( + AuditLogsResource, + AsyncAuditLogsResource, + AuditLogsResourceWithRawResponse, + AsyncAuditLogsResourceWithRawResponse, + AuditLogsResourceWithStreamingResponse, + AsyncAuditLogsResourceWithStreamingResponse, +) +from .extensions import ( + ExtensionsResource, + AsyncExtensionsResource, + ExtensionsResourceWithRawResponse, + AsyncExtensionsResourceWithRawResponse, + ExtensionsResourceWithStreamingResponse, + AsyncExtensionsResourceWithStreamingResponse, +) +from .credentials import ( + CredentialsResource, + AsyncCredentialsResource, + CredentialsResourceWithRawResponse, + AsyncCredentialsResourceWithRawResponse, + CredentialsResourceWithStreamingResponse, + AsyncCredentialsResourceWithStreamingResponse, +) +from .deployments import ( + DeploymentsResource, + AsyncDeploymentsResource, + DeploymentsResourceWithRawResponse, + AsyncDeploymentsResourceWithRawResponse, + DeploymentsResourceWithStreamingResponse, + AsyncDeploymentsResourceWithStreamingResponse, +) +from .invocations import ( + InvocationsResource, + AsyncInvocationsResource, + InvocationsResourceWithRawResponse, + AsyncInvocationsResourceWithRawResponse, + InvocationsResourceWithStreamingResponse, + AsyncInvocationsResourceWithStreamingResponse, +) +from .organization import ( + OrganizationResource, + AsyncOrganizationResource, + OrganizationResourceWithRawResponse, + AsyncOrganizationResourceWithRawResponse, + OrganizationResourceWithStreamingResponse, + AsyncOrganizationResourceWithStreamingResponse, +) +from .browser_pools import ( + BrowserPoolsResource, + AsyncBrowserPoolsResource, + BrowserPoolsResourceWithRawResponse, + AsyncBrowserPoolsResourceWithRawResponse, + BrowserPoolsResourceWithStreamingResponse, + AsyncBrowserPoolsResourceWithStreamingResponse, +) +from .credential_providers import ( + CredentialProvidersResource, + AsyncCredentialProvidersResource, + CredentialProvidersResourceWithRawResponse, + AsyncCredentialProvidersResourceWithRawResponse, + CredentialProvidersResourceWithStreamingResponse, + AsyncCredentialProvidersResourceWithStreamingResponse, +) + +__all__ = [ + "DeploymentsResource", + "AsyncDeploymentsResource", + "DeploymentsResourceWithRawResponse", + "AsyncDeploymentsResourceWithRawResponse", + "DeploymentsResourceWithStreamingResponse", + "AsyncDeploymentsResourceWithStreamingResponse", + "AppsResource", + "AsyncAppsResource", + "AppsResourceWithRawResponse", + "AsyncAppsResourceWithRawResponse", + "AppsResourceWithStreamingResponse", + "AsyncAppsResourceWithStreamingResponse", + "InvocationsResource", + "AsyncInvocationsResource", + "InvocationsResourceWithRawResponse", + "AsyncInvocationsResourceWithRawResponse", + "InvocationsResourceWithStreamingResponse", + "AsyncInvocationsResourceWithStreamingResponse", + "BrowsersResource", + "AsyncBrowsersResource", + "BrowsersResourceWithRawResponse", + "AsyncBrowsersResourceWithRawResponse", + "BrowsersResourceWithStreamingResponse", + "AsyncBrowsersResourceWithStreamingResponse", + "ProfilesResource", + "AsyncProfilesResource", + "ProfilesResourceWithRawResponse", + "AsyncProfilesResourceWithRawResponse", + "ProfilesResourceWithStreamingResponse", + "AsyncProfilesResourceWithStreamingResponse", + "AuthResource", + "AsyncAuthResource", + "AuthResourceWithRawResponse", + "AsyncAuthResourceWithRawResponse", + "AuthResourceWithStreamingResponse", + "AsyncAuthResourceWithStreamingResponse", + "ProxiesResource", + "AsyncProxiesResource", + "ProxiesResourceWithRawResponse", + "AsyncProxiesResourceWithRawResponse", + "ProxiesResourceWithStreamingResponse", + "AsyncProxiesResourceWithStreamingResponse", + "ExtensionsResource", + "AsyncExtensionsResource", + "ExtensionsResourceWithRawResponse", + "AsyncExtensionsResourceWithRawResponse", + "ExtensionsResourceWithStreamingResponse", + "AsyncExtensionsResourceWithStreamingResponse", + "BrowserPoolsResource", + "AsyncBrowserPoolsResource", + "BrowserPoolsResourceWithRawResponse", + "AsyncBrowserPoolsResourceWithRawResponse", + "BrowserPoolsResourceWithStreamingResponse", + "AsyncBrowserPoolsResourceWithStreamingResponse", + "CredentialsResource", + "AsyncCredentialsResource", + "CredentialsResourceWithRawResponse", + "AsyncCredentialsResourceWithRawResponse", + "CredentialsResourceWithStreamingResponse", + "AsyncCredentialsResourceWithStreamingResponse", + "ProjectsResource", + "AsyncProjectsResource", + "ProjectsResourceWithRawResponse", + "AsyncProjectsResourceWithRawResponse", + "ProjectsResourceWithStreamingResponse", + "AsyncProjectsResourceWithStreamingResponse", + "OrganizationResource", + "AsyncOrganizationResource", + "OrganizationResourceWithRawResponse", + "AsyncOrganizationResourceWithRawResponse", + "OrganizationResourceWithStreamingResponse", + "AsyncOrganizationResourceWithStreamingResponse", + "AuditLogsResource", + "AsyncAuditLogsResource", + "AuditLogsResourceWithRawResponse", + "AsyncAuditLogsResourceWithRawResponse", + "AuditLogsResourceWithStreamingResponse", + "AsyncAuditLogsResourceWithStreamingResponse", + "APIKeysResource", + "AsyncAPIKeysResource", + "APIKeysResourceWithRawResponse", + "AsyncAPIKeysResourceWithRawResponse", + "APIKeysResourceWithStreamingResponse", + "AsyncAPIKeysResourceWithStreamingResponse", + "CredentialProvidersResource", + "AsyncCredentialProvidersResource", + "CredentialProvidersResourceWithRawResponse", + "AsyncCredentialProvidersResourceWithRawResponse", + "CredentialProvidersResourceWithStreamingResponse", + "AsyncCredentialProvidersResourceWithStreamingResponse", +] diff --git a/src/kernel/resources/api_keys.py b/src/kernel/resources/api_keys.py new file mode 100644 index 00000000..8cc1ed53 --- /dev/null +++ b/src/kernel/resources/api_keys.py @@ -0,0 +1,764 @@ +# File generated from our OpenAPI spec by Stainless. See CONTRIBUTING.md for details. + +from __future__ import annotations + +from typing import Optional +from typing_extensions import Literal + +import httpx + +from ..types import ( + api_key_list_params, + api_key_create_params, + api_key_rotate_params, + api_key_update_params, + api_key_retrieve_params, +) +from .._types import Body, Omit, Query, Headers, NoneType, NotGiven, omit, not_given +from .._utils import path_template, 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 ..pagination import SyncOffsetPagination, AsyncOffsetPagination +from .._base_client import AsyncPaginator, make_request_options +from ..types.api_key import APIKey +from ..types.created_api_key import CreatedAPIKey + +__all__ = ["APIKeysResource", "AsyncAPIKeysResource"] + + +class APIKeysResource(SyncAPIResource): + """Create and manage API keys for organization and project-scoped access.""" + + @cached_property + def with_raw_response(self) -> APIKeysResourceWithRawResponse: + """ + 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/kernel/kernel-python-sdk#accessing-raw-response-data-eg-headers + """ + return APIKeysResourceWithRawResponse(self) + + @cached_property + def with_streaming_response(self) -> APIKeysResourceWithStreamingResponse: + """ + An alternative to `.with_raw_response` that doesn't eagerly read the response body. + + For more information, see https://www.github.com/kernel/kernel-python-sdk#with_streaming_response + """ + return APIKeysResourceWithStreamingResponse(self) + + def create( + self, + *, + name: str, + days_to_expire: Optional[int] | Omit = omit, + project_id: Optional[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, + ) -> CreatedAPIKey: + """ + Create a new API key within the authenticated organization. + + Args: + name: Label for the API key (1-255 characters). API keys are not addressable by name. + + days_to_expire: Number of days until expiry, up to 3650. Use null for never. + + project_id: Unique project identifier + + 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( + "/org/api_keys", + body=maybe_transform( + { + "name": name, + "days_to_expire": days_to_expire, + "project_id": project_id, + }, + api_key_create_params.APIKeyCreateParams, + ), + options=make_request_options( + extra_headers=extra_headers, extra_query=extra_query, extra_body=extra_body, timeout=timeout + ), + cast_to=CreatedAPIKey, + ) + + def retrieve( + self, + id: str, + *, + include_deleted: 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, + ) -> APIKey: + """Retrieve an API key by ID for the authenticated organization. + + API keys are + masked. + + Args: + include_deleted: When true, return the API key even if it has been deleted (soft-deleted), for + audit purposes. Defaults to false, which returns 404 for a deleted key. + + 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( + path_template("/org/api_keys/{id}", id=id), + options=make_request_options( + extra_headers=extra_headers, + extra_query=extra_query, + extra_body=extra_body, + timeout=timeout, + query=maybe_transform( + {"include_deleted": include_deleted}, api_key_retrieve_params.APIKeyRetrieveParams + ), + ), + cast_to=APIKey, + ) + + def update( + self, + id: str, + *, + name: 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, + ) -> APIKey: + """ + Update an API key's name. + + Args: + name: New API key name + + 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( + path_template("/org/api_keys/{id}", id=id), + body=maybe_transform({"name": name}, api_key_update_params.APIKeyUpdateParams), + options=make_request_options( + extra_headers=extra_headers, extra_query=extra_query, extra_body=extra_body, timeout=timeout + ), + cast_to=APIKey, + ) + + def list( + self, + *, + include_deleted: bool | Omit = omit, + limit: int | Omit = omit, + name: str | Omit = omit, + offset: int | Omit = omit, + query: str | Omit = omit, + sort_by: Literal["created_at", "name", "expires_at"] | Omit = omit, + sort_direction: Literal["asc", "desc"] | Omit = omit, + status: Literal["active", "deleted", "all"] | 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, + ) -> SyncOffsetPagination[APIKey]: + """List API keys for the authenticated organization. + + API keys are masked. + + Args: + include_deleted: Deprecated: use status=all instead. When true, include deleted (soft-deleted) + API keys in the results for audit purposes. + + limit: Maximum number of results to return + + name: Exact-match filter on API key name using the database collation. In production, + matching is case- and accent-insensitive. Names are not required to be unique, + so multiple keys may match. When status=all or include_deleted=true is set, + soft-deleted keys with the same name may also match. + + offset: Number of results to skip + + query: Case-insensitive substring match against API key name, creator, and project. API + key identifiers and masked keys match by exact value or prefix. + + sort_by: Field to sort API keys by. + + sort_direction: Sort direction for API keys. + + status: Filter API keys by status. "active" returns keys that are not deleted (default; + expired-but-not-deleted keys are still included), "deleted" returns only + soft-deleted keys, "all" returns both. + + 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_api_list( + "/org/api_keys", + page=SyncOffsetPagination[APIKey], + options=make_request_options( + extra_headers=extra_headers, + extra_query=extra_query, + extra_body=extra_body, + timeout=timeout, + query=maybe_transform( + { + "include_deleted": include_deleted, + "limit": limit, + "name": name, + "offset": offset, + "query": query, + "sort_by": sort_by, + "sort_direction": sort_direction, + "status": status, + }, + api_key_list_params.APIKeyListParams, + ), + ), + model=APIKey, + ) + + 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, + ) -> None: + """Delete an API key. + + A key cannot delete itself; use a different key to delete + this one. + + 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}") + extra_headers = {"Accept": "*/*", **(extra_headers or {})} + return self._delete( + path_template("/org/api_keys/{id}", id=id), + options=make_request_options( + extra_headers=extra_headers, extra_query=extra_query, extra_body=extra_body, timeout=timeout + ), + cast_to=NoneType, + ) + + def rotate( + self, + id: str, + *, + days_to_expire: Optional[int] | Omit = omit, + expire_in_days: Optional[int] | 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, + ) -> CreatedAPIKey: + """Rotate an API key. + + Issues a new key that copies the name and project of the + rotated key, and schedules the rotated key to expire after a grace period so + in-flight callers can swap over. The new plaintext key is returned once. + + Args: + days_to_expire: Lifetime in days for the new key, up to 3650. Omit to reuse the rotated key's + original lifetime, or never-expires if it had none. + + expire_in_days: Grace period in days before the rotated key expires. Use 0 to expire it + immediately. Omit for the default grace period of 7 days. + + 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( + path_template("/org/api_keys/{id}/rotate", id=id), + body=maybe_transform( + { + "days_to_expire": days_to_expire, + "expire_in_days": expire_in_days, + }, + api_key_rotate_params.APIKeyRotateParams, + ), + options=make_request_options( + extra_headers=extra_headers, extra_query=extra_query, extra_body=extra_body, timeout=timeout + ), + cast_to=CreatedAPIKey, + ) + + +class AsyncAPIKeysResource(AsyncAPIResource): + """Create and manage API keys for organization and project-scoped access.""" + + @cached_property + def with_raw_response(self) -> AsyncAPIKeysResourceWithRawResponse: + """ + 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/kernel/kernel-python-sdk#accessing-raw-response-data-eg-headers + """ + return AsyncAPIKeysResourceWithRawResponse(self) + + @cached_property + def with_streaming_response(self) -> AsyncAPIKeysResourceWithStreamingResponse: + """ + An alternative to `.with_raw_response` that doesn't eagerly read the response body. + + For more information, see https://www.github.com/kernel/kernel-python-sdk#with_streaming_response + """ + return AsyncAPIKeysResourceWithStreamingResponse(self) + + async def create( + self, + *, + name: str, + days_to_expire: Optional[int] | Omit = omit, + project_id: Optional[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, + ) -> CreatedAPIKey: + """ + Create a new API key within the authenticated organization. + + Args: + name: Label for the API key (1-255 characters). API keys are not addressable by name. + + days_to_expire: Number of days until expiry, up to 3650. Use null for never. + + project_id: Unique project identifier + + 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( + "/org/api_keys", + body=await async_maybe_transform( + { + "name": name, + "days_to_expire": days_to_expire, + "project_id": project_id, + }, + api_key_create_params.APIKeyCreateParams, + ), + options=make_request_options( + extra_headers=extra_headers, extra_query=extra_query, extra_body=extra_body, timeout=timeout + ), + cast_to=CreatedAPIKey, + ) + + async def retrieve( + self, + id: str, + *, + include_deleted: 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, + ) -> APIKey: + """Retrieve an API key by ID for the authenticated organization. + + API keys are + masked. + + Args: + include_deleted: When true, return the API key even if it has been deleted (soft-deleted), for + audit purposes. Defaults to false, which returns 404 for a deleted key. + + 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( + path_template("/org/api_keys/{id}", id=id), + options=make_request_options( + extra_headers=extra_headers, + extra_query=extra_query, + extra_body=extra_body, + timeout=timeout, + query=await async_maybe_transform( + {"include_deleted": include_deleted}, api_key_retrieve_params.APIKeyRetrieveParams + ), + ), + cast_to=APIKey, + ) + + async def update( + self, + id: str, + *, + name: 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, + ) -> APIKey: + """ + Update an API key's name. + + Args: + name: New API key name + + 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( + path_template("/org/api_keys/{id}", id=id), + body=await async_maybe_transform({"name": name}, api_key_update_params.APIKeyUpdateParams), + options=make_request_options( + extra_headers=extra_headers, extra_query=extra_query, extra_body=extra_body, timeout=timeout + ), + cast_to=APIKey, + ) + + def list( + self, + *, + include_deleted: bool | Omit = omit, + limit: int | Omit = omit, + name: str | Omit = omit, + offset: int | Omit = omit, + query: str | Omit = omit, + sort_by: Literal["created_at", "name", "expires_at"] | Omit = omit, + sort_direction: Literal["asc", "desc"] | Omit = omit, + status: Literal["active", "deleted", "all"] | 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, + ) -> AsyncPaginator[APIKey, AsyncOffsetPagination[APIKey]]: + """List API keys for the authenticated organization. + + API keys are masked. + + Args: + include_deleted: Deprecated: use status=all instead. When true, include deleted (soft-deleted) + API keys in the results for audit purposes. + + limit: Maximum number of results to return + + name: Exact-match filter on API key name using the database collation. In production, + matching is case- and accent-insensitive. Names are not required to be unique, + so multiple keys may match. When status=all or include_deleted=true is set, + soft-deleted keys with the same name may also match. + + offset: Number of results to skip + + query: Case-insensitive substring match against API key name, creator, and project. API + key identifiers and masked keys match by exact value or prefix. + + sort_by: Field to sort API keys by. + + sort_direction: Sort direction for API keys. + + status: Filter API keys by status. "active" returns keys that are not deleted (default; + expired-but-not-deleted keys are still included), "deleted" returns only + soft-deleted keys, "all" returns both. + + 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_api_list( + "/org/api_keys", + page=AsyncOffsetPagination[APIKey], + options=make_request_options( + extra_headers=extra_headers, + extra_query=extra_query, + extra_body=extra_body, + timeout=timeout, + query=maybe_transform( + { + "include_deleted": include_deleted, + "limit": limit, + "name": name, + "offset": offset, + "query": query, + "sort_by": sort_by, + "sort_direction": sort_direction, + "status": status, + }, + api_key_list_params.APIKeyListParams, + ), + ), + model=APIKey, + ) + + 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, + ) -> None: + """Delete an API key. + + A key cannot delete itself; use a different key to delete + this one. + + 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}") + extra_headers = {"Accept": "*/*", **(extra_headers or {})} + return await self._delete( + path_template("/org/api_keys/{id}", id=id), + options=make_request_options( + extra_headers=extra_headers, extra_query=extra_query, extra_body=extra_body, timeout=timeout + ), + cast_to=NoneType, + ) + + async def rotate( + self, + id: str, + *, + days_to_expire: Optional[int] | Omit = omit, + expire_in_days: Optional[int] | 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, + ) -> CreatedAPIKey: + """Rotate an API key. + + Issues a new key that copies the name and project of the + rotated key, and schedules the rotated key to expire after a grace period so + in-flight callers can swap over. The new plaintext key is returned once. + + Args: + days_to_expire: Lifetime in days for the new key, up to 3650. Omit to reuse the rotated key's + original lifetime, or never-expires if it had none. + + expire_in_days: Grace period in days before the rotated key expires. Use 0 to expire it + immediately. Omit for the default grace period of 7 days. + + 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( + path_template("/org/api_keys/{id}/rotate", id=id), + body=await async_maybe_transform( + { + "days_to_expire": days_to_expire, + "expire_in_days": expire_in_days, + }, + api_key_rotate_params.APIKeyRotateParams, + ), + options=make_request_options( + extra_headers=extra_headers, extra_query=extra_query, extra_body=extra_body, timeout=timeout + ), + cast_to=CreatedAPIKey, + ) + + +class APIKeysResourceWithRawResponse: + def __init__(self, api_keys: APIKeysResource) -> None: + self._api_keys = api_keys + + self.create = to_raw_response_wrapper( + api_keys.create, + ) + self.retrieve = to_raw_response_wrapper( + api_keys.retrieve, + ) + self.update = to_raw_response_wrapper( + api_keys.update, + ) + self.list = to_raw_response_wrapper( + api_keys.list, + ) + self.delete = to_raw_response_wrapper( + api_keys.delete, + ) + self.rotate = to_raw_response_wrapper( + api_keys.rotate, + ) + + +class AsyncAPIKeysResourceWithRawResponse: + def __init__(self, api_keys: AsyncAPIKeysResource) -> None: + self._api_keys = api_keys + + self.create = async_to_raw_response_wrapper( + api_keys.create, + ) + self.retrieve = async_to_raw_response_wrapper( + api_keys.retrieve, + ) + self.update = async_to_raw_response_wrapper( + api_keys.update, + ) + self.list = async_to_raw_response_wrapper( + api_keys.list, + ) + self.delete = async_to_raw_response_wrapper( + api_keys.delete, + ) + self.rotate = async_to_raw_response_wrapper( + api_keys.rotate, + ) + + +class APIKeysResourceWithStreamingResponse: + def __init__(self, api_keys: APIKeysResource) -> None: + self._api_keys = api_keys + + self.create = to_streamed_response_wrapper( + api_keys.create, + ) + self.retrieve = to_streamed_response_wrapper( + api_keys.retrieve, + ) + self.update = to_streamed_response_wrapper( + api_keys.update, + ) + self.list = to_streamed_response_wrapper( + api_keys.list, + ) + self.delete = to_streamed_response_wrapper( + api_keys.delete, + ) + self.rotate = to_streamed_response_wrapper( + api_keys.rotate, + ) + + +class AsyncAPIKeysResourceWithStreamingResponse: + def __init__(self, api_keys: AsyncAPIKeysResource) -> None: + self._api_keys = api_keys + + self.create = async_to_streamed_response_wrapper( + api_keys.create, + ) + self.retrieve = async_to_streamed_response_wrapper( + api_keys.retrieve, + ) + self.update = async_to_streamed_response_wrapper( + api_keys.update, + ) + self.list = async_to_streamed_response_wrapper( + api_keys.list, + ) + self.delete = async_to_streamed_response_wrapper( + api_keys.delete, + ) + self.rotate = async_to_streamed_response_wrapper( + api_keys.rotate, + ) diff --git a/src/kernel/resources/apps.py b/src/kernel/resources/apps.py new file mode 100644 index 00000000..4290f3f6 --- /dev/null +++ b/src/kernel/resources/apps.py @@ -0,0 +1,224 @@ +# File generated from our OpenAPI spec by Stainless. See CONTRIBUTING.md for details. + +from __future__ import annotations + +import httpx + +from ..types import app_list_params +from .._types import Body, Omit, Query, Headers, NotGiven, omit, not_given +from .._utils import 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 ..pagination import SyncOffsetPagination, AsyncOffsetPagination +from .._base_client import AsyncPaginator, make_request_options +from ..types.app_list_response import AppListResponse + +__all__ = ["AppsResource", "AsyncAppsResource"] + + +class AppsResource(SyncAPIResource): + """List applications and versions.""" + + @cached_property + def with_raw_response(self) -> AppsResourceWithRawResponse: + """ + 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/kernel/kernel-python-sdk#accessing-raw-response-data-eg-headers + """ + return AppsResourceWithRawResponse(self) + + @cached_property + def with_streaming_response(self) -> AppsResourceWithStreamingResponse: + """ + An alternative to `.with_raw_response` that doesn't eagerly read the response body. + + For more information, see https://www.github.com/kernel/kernel-python-sdk#with_streaming_response + """ + return AppsResourceWithStreamingResponse(self) + + def list( + self, + *, + app_name: str | Omit = omit, + limit: int | Omit = omit, + offset: int | Omit = omit, + query: str | Omit = omit, + version: 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, + ) -> SyncOffsetPagination[AppListResponse]: + """List applications. + + Optionally filter by app name and/or version label. + + Args: + app_name: Filter results by application name. + + limit: Limit the number of apps to return. + + offset: Offset the number of apps to return. + + query: Search apps by name. + + version: Filter results by version label. + + 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_api_list( + "/apps", + page=SyncOffsetPagination[AppListResponse], + options=make_request_options( + extra_headers=extra_headers, + extra_query=extra_query, + extra_body=extra_body, + timeout=timeout, + query=maybe_transform( + { + "app_name": app_name, + "limit": limit, + "offset": offset, + "query": query, + "version": version, + }, + app_list_params.AppListParams, + ), + ), + model=AppListResponse, + ) + + +class AsyncAppsResource(AsyncAPIResource): + """List applications and versions.""" + + @cached_property + def with_raw_response(self) -> AsyncAppsResourceWithRawResponse: + """ + 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/kernel/kernel-python-sdk#accessing-raw-response-data-eg-headers + """ + return AsyncAppsResourceWithRawResponse(self) + + @cached_property + def with_streaming_response(self) -> AsyncAppsResourceWithStreamingResponse: + """ + An alternative to `.with_raw_response` that doesn't eagerly read the response body. + + For more information, see https://www.github.com/kernel/kernel-python-sdk#with_streaming_response + """ + return AsyncAppsResourceWithStreamingResponse(self) + + def list( + self, + *, + app_name: str | Omit = omit, + limit: int | Omit = omit, + offset: int | Omit = omit, + query: str | Omit = omit, + version: 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, + ) -> AsyncPaginator[AppListResponse, AsyncOffsetPagination[AppListResponse]]: + """List applications. + + Optionally filter by app name and/or version label. + + Args: + app_name: Filter results by application name. + + limit: Limit the number of apps to return. + + offset: Offset the number of apps to return. + + query: Search apps by name. + + version: Filter results by version label. + + 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_api_list( + "/apps", + page=AsyncOffsetPagination[AppListResponse], + options=make_request_options( + extra_headers=extra_headers, + extra_query=extra_query, + extra_body=extra_body, + timeout=timeout, + query=maybe_transform( + { + "app_name": app_name, + "limit": limit, + "offset": offset, + "query": query, + "version": version, + }, + app_list_params.AppListParams, + ), + ), + model=AppListResponse, + ) + + +class AppsResourceWithRawResponse: + def __init__(self, apps: AppsResource) -> None: + self._apps = apps + + self.list = to_raw_response_wrapper( + apps.list, + ) + + +class AsyncAppsResourceWithRawResponse: + def __init__(self, apps: AsyncAppsResource) -> None: + self._apps = apps + + self.list = async_to_raw_response_wrapper( + apps.list, + ) + + +class AppsResourceWithStreamingResponse: + def __init__(self, apps: AppsResource) -> None: + self._apps = apps + + self.list = to_streamed_response_wrapper( + apps.list, + ) + + +class AsyncAppsResourceWithStreamingResponse: + def __init__(self, apps: AsyncAppsResource) -> None: + self._apps = apps + + self.list = async_to_streamed_response_wrapper( + apps.list, + ) diff --git a/src/kernel/resources/audit_logs.py b/src/kernel/resources/audit_logs.py new file mode 100644 index 00000000..2970e153 --- /dev/null +++ b/src/kernel/resources/audit_logs.py @@ -0,0 +1,464 @@ +# File generated from our OpenAPI spec by Stainless. See CONTRIBUTING.md for details. + +from __future__ import annotations + +from typing import Union +from datetime import datetime +from typing_extensions import Literal + +import httpx + +from ..types import audit_log_list_params, audit_log_export_chunk_params +from .._types import Body, Omit, Query, Headers, NotGiven, SequenceNotStr, omit, not_given +from .._utils import maybe_transform, async_maybe_transform +from .._compat import cached_property +from .._resource import SyncAPIResource, AsyncAPIResource +from .._response import ( + BinaryAPIResponse, + AsyncBinaryAPIResponse, + StreamedBinaryAPIResponse, + AsyncStreamedBinaryAPIResponse, + to_raw_response_wrapper, + to_streamed_response_wrapper, + async_to_raw_response_wrapper, + to_custom_raw_response_wrapper, + async_to_streamed_response_wrapper, + to_custom_streamed_response_wrapper, + async_to_custom_raw_response_wrapper, + async_to_custom_streamed_response_wrapper, +) +from ..pagination import SyncPageTokenPagination, AsyncPageTokenPagination +from .._base_client import AsyncPaginator, make_request_options +from ..types.audit_log_entry import AuditLogEntry + +__all__ = ["AuditLogsResource", "AsyncAuditLogsResource"] + + +class AuditLogsResource(SyncAPIResource): + """Read audit log records for the authenticated organization.""" + + @cached_property + def with_raw_response(self) -> AuditLogsResourceWithRawResponse: + """ + 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/kernel/kernel-python-sdk#accessing-raw-response-data-eg-headers + """ + return AuditLogsResourceWithRawResponse(self) + + @cached_property + def with_streaming_response(self) -> AuditLogsResourceWithStreamingResponse: + """ + An alternative to `.with_raw_response` that doesn't eagerly read the response body. + + For more information, see https://www.github.com/kernel/kernel-python-sdk#with_streaming_response + """ + return AuditLogsResourceWithStreamingResponse(self) + + def list( + self, + *, + end: Union[str, datetime], + start: Union[str, datetime], + auth_strategy: str | Omit = omit, + exclude_method: SequenceNotStr[str] | Omit = omit, + limit: int | Omit = omit, + method: str | Omit = omit, + page_token: str | Omit = omit, + search: str | Omit = omit, + search_user_id: SequenceNotStr[str] | Omit = omit, + service: 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, + ) -> SyncPageTokenPagination[AuditLogEntry]: + """API for searching audit logs. + + Limited to at most 30 day search, returns up to + 100 records per page. Not recommended for bulk export. + + Args: + end: Upper bound (exclusive) for the audit record timestamp. + + start: Lower bound (inclusive) for the audit record timestamp. + + auth_strategy: Filter by authentication strategy. + + exclude_method: Filter out results by HTTP method. + + limit: Maximum number of results to return. + + method: Filter by HTTP method. + + page_token: Opaque page token from X-Next-Page-Token for the next page of older records. + + search: Free-text search over path, user ID, email, client IP, and status. + + search_user_id: Additional user IDs to OR into free-text search. + + service: Filter by service name. + + 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_api_list( + "/audit-logs", + page=SyncPageTokenPagination[AuditLogEntry], + options=make_request_options( + extra_headers=extra_headers, + extra_query=extra_query, + extra_body=extra_body, + timeout=timeout, + query=maybe_transform( + { + "end": end, + "start": start, + "auth_strategy": auth_strategy, + "exclude_method": exclude_method, + "limit": limit, + "method": method, + "page_token": page_token, + "search": search, + "search_user_id": search_user_id, + "service": service, + }, + audit_log_list_params.AuditLogListParams, + ), + ), + model=AuditLogEntry, + ) + + def export_chunk( + self, + *, + end: Union[str, datetime], + start: Union[str, datetime], + auth_strategy: str | Omit = omit, + cursor: str | Omit = omit, + exclude_method: SequenceNotStr[str] | Omit = omit, + format: Literal["jsonl", "jsonl.gz"] | Omit = omit, + limit: int | Omit = omit, + method: str | Omit = omit, + search: str | Omit = omit, + search_user_id: SequenceNotStr[str] | Omit = omit, + service: 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, + ) -> BinaryAPIResponse: + """ + Download an organization's audit log records for a time range as a file, for + archival, compliance, or offline analysis. For interactive browsing, use GET + /audit-logs. + + Args: + end: Upper bound (exclusive) for the audit record timestamp. + + start: Lower bound (inclusive) for the audit record timestamp. + + auth_strategy: Filter by authentication strategy. + + cursor: Opaque cursor from X-Next-Cursor for the next chunk of older records. + + exclude_method: Filter out results by HTTP method. + + format: Encoding for the returned chunk. + + limit: Maximum number of records to return in this chunk. + + method: Filter by HTTP method. + + search: Free-text search over path, user ID, email, client IP, and status. + + search_user_id: Additional user IDs to OR into free-text search. + + service: Filter by service name. + + 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": "application/octet-stream", **(extra_headers or {})} + return self._get( + "/audit-logs/export/chunk", + options=make_request_options( + extra_headers=extra_headers, + extra_query=extra_query, + extra_body=extra_body, + timeout=timeout, + query=maybe_transform( + { + "end": end, + "start": start, + "auth_strategy": auth_strategy, + "cursor": cursor, + "exclude_method": exclude_method, + "format": format, + "limit": limit, + "method": method, + "search": search, + "search_user_id": search_user_id, + "service": service, + }, + audit_log_export_chunk_params.AuditLogExportChunkParams, + ), + ), + cast_to=BinaryAPIResponse, + ) + + +class AsyncAuditLogsResource(AsyncAPIResource): + """Read audit log records for the authenticated organization.""" + + @cached_property + def with_raw_response(self) -> AsyncAuditLogsResourceWithRawResponse: + """ + 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/kernel/kernel-python-sdk#accessing-raw-response-data-eg-headers + """ + return AsyncAuditLogsResourceWithRawResponse(self) + + @cached_property + def with_streaming_response(self) -> AsyncAuditLogsResourceWithStreamingResponse: + """ + An alternative to `.with_raw_response` that doesn't eagerly read the response body. + + For more information, see https://www.github.com/kernel/kernel-python-sdk#with_streaming_response + """ + return AsyncAuditLogsResourceWithStreamingResponse(self) + + def list( + self, + *, + end: Union[str, datetime], + start: Union[str, datetime], + auth_strategy: str | Omit = omit, + exclude_method: SequenceNotStr[str] | Omit = omit, + limit: int | Omit = omit, + method: str | Omit = omit, + page_token: str | Omit = omit, + search: str | Omit = omit, + search_user_id: SequenceNotStr[str] | Omit = omit, + service: 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, + ) -> AsyncPaginator[AuditLogEntry, AsyncPageTokenPagination[AuditLogEntry]]: + """API for searching audit logs. + + Limited to at most 30 day search, returns up to + 100 records per page. Not recommended for bulk export. + + Args: + end: Upper bound (exclusive) for the audit record timestamp. + + start: Lower bound (inclusive) for the audit record timestamp. + + auth_strategy: Filter by authentication strategy. + + exclude_method: Filter out results by HTTP method. + + limit: Maximum number of results to return. + + method: Filter by HTTP method. + + page_token: Opaque page token from X-Next-Page-Token for the next page of older records. + + search: Free-text search over path, user ID, email, client IP, and status. + + search_user_id: Additional user IDs to OR into free-text search. + + service: Filter by service name. + + 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_api_list( + "/audit-logs", + page=AsyncPageTokenPagination[AuditLogEntry], + options=make_request_options( + extra_headers=extra_headers, + extra_query=extra_query, + extra_body=extra_body, + timeout=timeout, + query=maybe_transform( + { + "end": end, + "start": start, + "auth_strategy": auth_strategy, + "exclude_method": exclude_method, + "limit": limit, + "method": method, + "page_token": page_token, + "search": search, + "search_user_id": search_user_id, + "service": service, + }, + audit_log_list_params.AuditLogListParams, + ), + ), + model=AuditLogEntry, + ) + + async def export_chunk( + self, + *, + end: Union[str, datetime], + start: Union[str, datetime], + auth_strategy: str | Omit = omit, + cursor: str | Omit = omit, + exclude_method: SequenceNotStr[str] | Omit = omit, + format: Literal["jsonl", "jsonl.gz"] | Omit = omit, + limit: int | Omit = omit, + method: str | Omit = omit, + search: str | Omit = omit, + search_user_id: SequenceNotStr[str] | Omit = omit, + service: 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, + ) -> AsyncBinaryAPIResponse: + """ + Download an organization's audit log records for a time range as a file, for + archival, compliance, or offline analysis. For interactive browsing, use GET + /audit-logs. + + Args: + end: Upper bound (exclusive) for the audit record timestamp. + + start: Lower bound (inclusive) for the audit record timestamp. + + auth_strategy: Filter by authentication strategy. + + cursor: Opaque cursor from X-Next-Cursor for the next chunk of older records. + + exclude_method: Filter out results by HTTP method. + + format: Encoding for the returned chunk. + + limit: Maximum number of records to return in this chunk. + + method: Filter by HTTP method. + + search: Free-text search over path, user ID, email, client IP, and status. + + search_user_id: Additional user IDs to OR into free-text search. + + service: Filter by service name. + + 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": "application/octet-stream", **(extra_headers or {})} + return await self._get( + "/audit-logs/export/chunk", + options=make_request_options( + extra_headers=extra_headers, + extra_query=extra_query, + extra_body=extra_body, + timeout=timeout, + query=await async_maybe_transform( + { + "end": end, + "start": start, + "auth_strategy": auth_strategy, + "cursor": cursor, + "exclude_method": exclude_method, + "format": format, + "limit": limit, + "method": method, + "search": search, + "search_user_id": search_user_id, + "service": service, + }, + audit_log_export_chunk_params.AuditLogExportChunkParams, + ), + ), + cast_to=AsyncBinaryAPIResponse, + ) + + +class AuditLogsResourceWithRawResponse: + def __init__(self, audit_logs: AuditLogsResource) -> None: + self._audit_logs = audit_logs + + self.list = to_raw_response_wrapper( + audit_logs.list, + ) + self.export_chunk = to_custom_raw_response_wrapper( + audit_logs.export_chunk, + BinaryAPIResponse, + ) + + +class AsyncAuditLogsResourceWithRawResponse: + def __init__(self, audit_logs: AsyncAuditLogsResource) -> None: + self._audit_logs = audit_logs + + self.list = async_to_raw_response_wrapper( + audit_logs.list, + ) + self.export_chunk = async_to_custom_raw_response_wrapper( + audit_logs.export_chunk, + AsyncBinaryAPIResponse, + ) + + +class AuditLogsResourceWithStreamingResponse: + def __init__(self, audit_logs: AuditLogsResource) -> None: + self._audit_logs = audit_logs + + self.list = to_streamed_response_wrapper( + audit_logs.list, + ) + self.export_chunk = to_custom_streamed_response_wrapper( + audit_logs.export_chunk, + StreamedBinaryAPIResponse, + ) + + +class AsyncAuditLogsResourceWithStreamingResponse: + def __init__(self, audit_logs: AsyncAuditLogsResource) -> None: + self._audit_logs = audit_logs + + self.list = async_to_streamed_response_wrapper( + audit_logs.list, + ) + self.export_chunk = async_to_custom_streamed_response_wrapper( + audit_logs.export_chunk, + AsyncStreamedBinaryAPIResponse, + ) diff --git a/src/kernel/resources/auth/__init__.py b/src/kernel/resources/auth/__init__.py new file mode 100644 index 00000000..a863677f --- /dev/null +++ b/src/kernel/resources/auth/__init__.py @@ -0,0 +1,33 @@ +# File generated from our OpenAPI spec by Stainless. See CONTRIBUTING.md for details. + +from .auth import ( + AuthResource, + AsyncAuthResource, + AuthResourceWithRawResponse, + AsyncAuthResourceWithRawResponse, + AuthResourceWithStreamingResponse, + AsyncAuthResourceWithStreamingResponse, +) +from .connections import ( + ConnectionsResource, + AsyncConnectionsResource, + ConnectionsResourceWithRawResponse, + AsyncConnectionsResourceWithRawResponse, + ConnectionsResourceWithStreamingResponse, + AsyncConnectionsResourceWithStreamingResponse, +) + +__all__ = [ + "ConnectionsResource", + "AsyncConnectionsResource", + "ConnectionsResourceWithRawResponse", + "AsyncConnectionsResourceWithRawResponse", + "ConnectionsResourceWithStreamingResponse", + "AsyncConnectionsResourceWithStreamingResponse", + "AuthResource", + "AsyncAuthResource", + "AuthResourceWithRawResponse", + "AsyncAuthResourceWithRawResponse", + "AuthResourceWithStreamingResponse", + "AsyncAuthResourceWithStreamingResponse", +] diff --git a/src/kernel/resources/auth/auth.py b/src/kernel/resources/auth/auth.py new file mode 100644 index 00000000..f8744950 --- /dev/null +++ b/src/kernel/resources/auth/auth.py @@ -0,0 +1,108 @@ +# File generated from our OpenAPI spec by Stainless. See CONTRIBUTING.md for details. + +from __future__ import annotations + +from ..._compat import cached_property +from ..._resource import SyncAPIResource, AsyncAPIResource +from .connections import ( + ConnectionsResource, + AsyncConnectionsResource, + ConnectionsResourceWithRawResponse, + AsyncConnectionsResourceWithRawResponse, + ConnectionsResourceWithStreamingResponse, + AsyncConnectionsResourceWithStreamingResponse, +) + +__all__ = ["AuthResource", "AsyncAuthResource"] + + +class AuthResource(SyncAPIResource): + @cached_property + def connections(self) -> ConnectionsResource: + """Create and manage auth connections for automated credential capture and login.""" + return ConnectionsResource(self._client) + + @cached_property + def with_raw_response(self) -> AuthResourceWithRawResponse: + """ + 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/kernel/kernel-python-sdk#accessing-raw-response-data-eg-headers + """ + return AuthResourceWithRawResponse(self) + + @cached_property + def with_streaming_response(self) -> AuthResourceWithStreamingResponse: + """ + An alternative to `.with_raw_response` that doesn't eagerly read the response body. + + For more information, see https://www.github.com/kernel/kernel-python-sdk#with_streaming_response + """ + return AuthResourceWithStreamingResponse(self) + + +class AsyncAuthResource(AsyncAPIResource): + @cached_property + def connections(self) -> AsyncConnectionsResource: + """Create and manage auth connections for automated credential capture and login.""" + return AsyncConnectionsResource(self._client) + + @cached_property + def with_raw_response(self) -> AsyncAuthResourceWithRawResponse: + """ + 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/kernel/kernel-python-sdk#accessing-raw-response-data-eg-headers + """ + return AsyncAuthResourceWithRawResponse(self) + + @cached_property + def with_streaming_response(self) -> AsyncAuthResourceWithStreamingResponse: + """ + An alternative to `.with_raw_response` that doesn't eagerly read the response body. + + For more information, see https://www.github.com/kernel/kernel-python-sdk#with_streaming_response + """ + return AsyncAuthResourceWithStreamingResponse(self) + + +class AuthResourceWithRawResponse: + def __init__(self, auth: AuthResource) -> None: + self._auth = auth + + @cached_property + def connections(self) -> ConnectionsResourceWithRawResponse: + """Create and manage auth connections for automated credential capture and login.""" + return ConnectionsResourceWithRawResponse(self._auth.connections) + + +class AsyncAuthResourceWithRawResponse: + def __init__(self, auth: AsyncAuthResource) -> None: + self._auth = auth + + @cached_property + def connections(self) -> AsyncConnectionsResourceWithRawResponse: + """Create and manage auth connections for automated credential capture and login.""" + return AsyncConnectionsResourceWithRawResponse(self._auth.connections) + + +class AuthResourceWithStreamingResponse: + def __init__(self, auth: AuthResource) -> None: + self._auth = auth + + @cached_property + def connections(self) -> ConnectionsResourceWithStreamingResponse: + """Create and manage auth connections for automated credential capture and login.""" + return ConnectionsResourceWithStreamingResponse(self._auth.connections) + + +class AsyncAuthResourceWithStreamingResponse: + def __init__(self, auth: AsyncAuthResource) -> None: + self._auth = auth + + @cached_property + def connections(self) -> AsyncConnectionsResourceWithStreamingResponse: + """Create and manage auth connections for automated credential capture and login.""" + return AsyncConnectionsResourceWithStreamingResponse(self._auth.connections) diff --git a/src/kernel/resources/auth/connections.py b/src/kernel/resources/auth/connections.py new file mode 100644 index 00000000..f5f4529a --- /dev/null +++ b/src/kernel/resources/auth/connections.py @@ -0,0 +1,1387 @@ +# File generated from our OpenAPI spec by Stainless. See CONTRIBUTING.md for details. + +from __future__ import annotations + +from typing import Any, Dict, Optional, cast +from typing_extensions import Literal + +import httpx + +from ..._types import Body, Omit, Query, Headers, NoneType, NotGiven, SequenceNotStr, omit, not_given +from ..._utils import path_template, 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 ...pagination import SyncOffsetPagination, AsyncOffsetPagination +from ...types.auth import ( + connection_list_params, + connection_login_params, + connection_create_params, + connection_submit_params, + connection_update_params, + connection_timeline_params, +) +from ..._base_client import AsyncPaginator, make_request_options +from ...types.auth.managed_auth import ManagedAuth +from ...types.auth.login_response import LoginResponse +from ...types.auth.submit_fields_response import SubmitFieldsResponse +from ...types.auth.connection_follow_response import ConnectionFollowResponse +from ...types.auth.managed_auth_timeline_event import ManagedAuthTimelineEvent + +__all__ = ["ConnectionsResource", "AsyncConnectionsResource"] + + +class ConnectionsResource(SyncAPIResource): + """Create and manage auth connections for automated credential capture and login.""" + + @cached_property + def with_raw_response(self) -> ConnectionsResourceWithRawResponse: + """ + 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/kernel/kernel-python-sdk#accessing-raw-response-data-eg-headers + """ + return ConnectionsResourceWithRawResponse(self) + + @cached_property + def with_streaming_response(self) -> ConnectionsResourceWithStreamingResponse: + """ + An alternative to `.with_raw_response` that doesn't eagerly read the response body. + + For more information, see https://www.github.com/kernel/kernel-python-sdk#with_streaming_response + """ + return ConnectionsResourceWithStreamingResponse(self) + + def create( + self, + *, + domain: str, + profile_name: str, + allowed_domains: SequenceNotStr[str] | Omit = omit, + auto_reauth: bool | Omit = omit, + browser_telemetry: Optional[connection_create_params.BrowserTelemetry] | Omit = omit, + credential: connection_create_params.Credential | Omit = omit, + health_check_interval: int | Omit = omit, + health_checks: bool | Omit = omit, + login_url: str | Omit = omit, + proxy: connection_create_params.Proxy | Omit = omit, + record_session: bool | Omit = omit, + save_credentials: 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, + ) -> ManagedAuth: + """Creates an auth connection for a profile and domain combination. + + If the provided + profile_name does not exist, it is created automatically. Returns 409 Conflict + if an auth connection already exists for the given profile and domain. + + Args: + domain: Domain for authentication + + profile_name: Name of the profile to manage authentication for. If the profile does not exist, + it is created automatically. + + allowed_domains: Additional domains valid for this auth flow (besides the primary domain). Useful + when login pages redirect to different domains. + + The following SSO/OAuth provider domains are automatically allowed by default + and do not need to be specified: + + - Google: accounts.google.com + - Microsoft/Azure AD: login.microsoftonline.com, login.live.com + - Okta: _.okta.com, _.oktapreview.com + - Auth0: _.auth0.com, _.us.auth0.com, _.eu.auth0.com, _.au.auth0.com + - Apple: appleid.apple.com + - GitHub: github.com + - Facebook/Meta: www.facebook.com + - LinkedIn: www.linkedin.com + - Amazon Cognito: \\**.amazoncognito.com + - OneLogin: \\**.onelogin.com + - Ping Identity: _.pingone.com, _.pingidentity.com + + auto_reauth: Whether to permit automatic re-authentication when a scheduled health check + detects an expired session. This is an opt-in flag only — it does not check + whether re-auth is actually feasible. Even when true, re-auth only runs when the + system has what it needs to perform it (for example, saved credentials for the + required login fields), and only after a scheduled health check detects an + expired session — so this flag has no effect when `health_checks` is false. When + false, expired sessions are marked as `NEEDS_AUTH` instead of attempting + re-auth. Defaults to true. + + browser_telemetry: Browser telemetry configuration used by this connection's browser sessions by + default. Uses the exact create-browser configuration. Can be overridden + per-login. + + credential: + Reference to credentials for the auth connection. Use one of: + + - { name } for Kernel credentials + - { provider, path } for external provider item + - { provider, auto: true } for external provider domain lookup + + health_check_interval: Interval in seconds between automatic health checks. When set, the system + periodically verifies the authentication status and triggers re-authentication + if needed. Maximum is 86400 (24 hours). Default is 3600 (1 hour). The minimum + depends on your plan: Enterprise: 300 (5 minutes), Startup: 1200 (20 minutes), + Hobbyist: 3600 (1 hour). + + health_checks: Whether to enable periodic health checks. When false, the system will not + automatically verify authentication status, and `auto_reauth` has no effect on + the automatic flow (since re-auth is only triggered by a failed scheduled health + check). Defaults to true. + + login_url: Optional login page URL to skip discovery + + proxy: Proxy selection. Provide either id or name. The proxy must be in the same + project as the resource referencing it. When selecting by name, the name must + match exactly one active proxy in the project. Ambiguous names return a 400; use + id for stable references. + + record_session: Whether to record browser sessions for this connection by default. Useful for + debugging. Can be overridden per-login. Defaults to false. + + save_credentials: Whether to save credentials after every successful login. Defaults to true. + One-time codes (TOTP, SMS, etc.) are not saved. + + 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( + "/auth/connections", + body=maybe_transform( + { + "domain": domain, + "profile_name": profile_name, + "allowed_domains": allowed_domains, + "auto_reauth": auto_reauth, + "browser_telemetry": browser_telemetry, + "credential": credential, + "health_check_interval": health_check_interval, + "health_checks": health_checks, + "login_url": login_url, + "proxy": proxy, + "record_session": record_session, + "save_credentials": save_credentials, + }, + connection_create_params.ConnectionCreateParams, + ), + options=make_request_options( + extra_headers=extra_headers, extra_query=extra_query, extra_body=extra_body, timeout=timeout + ), + cast_to=ManagedAuth, + ) + + def retrieve( + 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, + ) -> ManagedAuth: + """Retrieve an auth connection by its ID. + + Includes current flow state if a login is + in progress. + + 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( + path_template("/auth/connections/{id}", id=id), + options=make_request_options( + extra_headers=extra_headers, extra_query=extra_query, extra_body=extra_body, timeout=timeout + ), + cast_to=ManagedAuth, + ) + + def update( + self, + id: str, + *, + allowed_domains: SequenceNotStr[str] | Omit = omit, + auto_reauth: bool | Omit = omit, + browser_telemetry: Optional[connection_update_params.BrowserTelemetry] | Omit = omit, + credential: connection_update_params.Credential | Omit = omit, + health_check_interval: int | Omit = omit, + health_checks: bool | Omit = omit, + login_url: str | Omit = omit, + proxy: connection_update_params.Proxy | Omit = omit, + record_session: bool | Omit = omit, + save_credentials: 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, + ) -> ManagedAuth: + """Update an auth connection's configuration. + + Only the fields provided will be + updated. + + Args: + allowed_domains: Additional domains valid for this auth flow (replaces existing list) + + auto_reauth: Whether automatic re-authentication is permitted for this connection. This is an + opt-in flag only — it does not check whether re-auth is actually feasible. Even + when true, re-auth only runs when the system has what it needs to perform it + (for example, saved credentials for the required login fields), and only after a + scheduled health check detects an expired session — so this flag has no effect + when `health_checks` is false. When false, expired sessions detected by a health + check are marked as `NEEDS_AUTH` instead of attempting re-auth. + + browser_telemetry: Browser telemetry configuration used by future browser sessions for this + connection. Uses the exact create-browser configuration. Set enabled to false to + disable telemetry. + + credential: + Reference to credentials for the auth connection. Use one of: + + - { name } for Kernel credentials + - { provider, path } for external provider item + - { provider, auto: true } for external provider domain lookup + + health_check_interval: Interval in seconds between automatic health checks + + health_checks: Whether periodic health checks are enabled. When set to false, the system will + not automatically verify authentication status, and `auto_reauth` has no effect + on the automatic flow (since re-auth is only triggered by a failed scheduled + health check). + + login_url: Login page URL. Set to empty string to clear. + + proxy: Proxy selection. Provide either id or name. The proxy must be in the same + project as the resource referencing it. When selecting by name, the name must + match exactly one active proxy in the project. Ambiguous names return a 400; use + id for stable references. + + record_session: Whether to record browser sessions for this connection by default + + save_credentials: Whether to save credentials after every successful login + + 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( + path_template("/auth/connections/{id}", id=id), + body=maybe_transform( + { + "allowed_domains": allowed_domains, + "auto_reauth": auto_reauth, + "browser_telemetry": browser_telemetry, + "credential": credential, + "health_check_interval": health_check_interval, + "health_checks": health_checks, + "login_url": login_url, + "proxy": proxy, + "record_session": record_session, + "save_credentials": save_credentials, + }, + connection_update_params.ConnectionUpdateParams, + ), + options=make_request_options( + extra_headers=extra_headers, extra_query=extra_query, extra_body=extra_body, timeout=timeout + ), + cast_to=ManagedAuth, + ) + + def list( + self, + *, + domain: str | Omit = omit, + limit: int | Omit = omit, + offset: int | Omit = omit, + profile_name: str | Omit = omit, + query: 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, + ) -> SyncOffsetPagination[ManagedAuth]: + """ + List auth connections with optional filters for profile_name and domain. + + Args: + domain: Filter by domain + + limit: Maximum number of results to return + + offset: Number of results to skip + + profile_name: Filter by profile name + + query: Search auth connections by ID, domain, or profile name. + + 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_api_list( + "/auth/connections", + page=SyncOffsetPagination[ManagedAuth], + options=make_request_options( + extra_headers=extra_headers, + extra_query=extra_query, + extra_body=extra_body, + timeout=timeout, + query=maybe_transform( + { + "domain": domain, + "limit": limit, + "offset": offset, + "profile_name": profile_name, + "query": query, + }, + connection_list_params.ConnectionListParams, + ), + ), + model=ManagedAuth, + ) + + 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, + ) -> None: + """Deletes an auth connection and terminates its workflow. + + This will: + + - Delete the auth connection record + - Terminate the Temporal workflow + - Cancel any in-progress login flows + + 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}") + extra_headers = {"Accept": "*/*", **(extra_headers or {})} + return self._delete( + path_template("/auth/connections/{id}", id=id), + options=make_request_options( + extra_headers=extra_headers, extra_query=extra_query, extra_body=extra_body, timeout=timeout + ), + cast_to=NoneType, + ) + + def follow( + 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, + ) -> Stream[ConnectionFollowResponse]: + """ + Establishes a Server-Sent Events (SSE) stream that delivers real-time login flow + state updates. The stream terminates automatically once the flow reaches a + terminal state (SUCCESS, FAILED, EXPIRED, CANCELED). + + 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}") + extra_headers = {"Accept": "text/event-stream", **(extra_headers or {})} + return self._get( + path_template("/auth/connections/{id}/events", id=id), + options=make_request_options( + extra_headers=extra_headers, extra_query=extra_query, extra_body=extra_body, timeout=timeout + ), + cast_to=cast( + Any, ConnectionFollowResponse + ), # Union types cannot be passed in as arguments in the type system + stream=True, + stream_cls=Stream[ConnectionFollowResponse], + ) + + def login( + self, + id: str, + *, + browser_telemetry: Optional[connection_login_params.BrowserTelemetry] | Omit = omit, + proxy: connection_login_params.Proxy | Omit = omit, + record_session: 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, + ) -> LoginResponse: + """Starts a login flow for the auth connection. + + Returns immediately with a hosted + URL for the user to complete authentication, or triggers automatic re-auth if + credentials are stored. + + Args: + browser_telemetry: Override the connection's default browser telemetry configuration for this + login. When omitted, the connection's browser_telemetry default is used. Uses + the exact create-browser configuration. + + proxy: Proxy selection. Provide either id or name. The proxy must be in the same + project as the resource referencing it. When selecting by name, the name must + match exactly one active proxy in the project. Ambiguous names return a 400; use + id for stable references. + + record_session: Override the connection's default for recording this login's browser session. + When omitted, the connection's record_session default is used. + + 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( + path_template("/auth/connections/{id}/login", id=id), + body=maybe_transform( + { + "browser_telemetry": browser_telemetry, + "proxy": proxy, + "record_session": record_session, + }, + connection_login_params.ConnectionLoginParams, + ), + options=make_request_options( + extra_headers=extra_headers, extra_query=extra_query, extra_body=extra_body, timeout=timeout + ), + cast_to=LoginResponse, + ) + + def submit( + self, + id: str, + *, + field_values: Dict[str, str] | Omit = omit, + fields: Dict[str, str] | Omit = omit, + mfa_option_id: str | Omit = omit, + selected_choice_id: str | Omit = omit, + sign_in_option_id: str | Omit = omit, + sso_button_selector: str | Omit = omit, + sso_provider: 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, + ) -> SubmitFieldsResponse: + """Submits field values for the login form. + + Poll the auth connection to track + progress and get results. + + Args: + field_values: Canonical map of field ID to submitted value. + + fields: Map of field name to value + + mfa_option_id: The MFA method type to select (when mfa_options were returned) + + selected_choice_id: Canonical choice ID selected by the user. + + sign_in_option_id: The sign-in option ID to select (when sign_in_options were returned) + + sso_button_selector: XPath selector for the SSO button to click (ODA). Use sso_provider instead for + CUA. + + sso_provider: SSO provider to click, matching the provider field from pending_sso_buttons + (e.g., "google", "github"). Cannot be used with sso_button_selector. + + 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( + path_template("/auth/connections/{id}/submit", id=id), + body=maybe_transform( + { + "field_values": field_values, + "fields": fields, + "mfa_option_id": mfa_option_id, + "selected_choice_id": selected_choice_id, + "sign_in_option_id": sign_in_option_id, + "sso_button_selector": sso_button_selector, + "sso_provider": sso_provider, + }, + connection_submit_params.ConnectionSubmitParams, + ), + options=make_request_options( + extra_headers=extra_headers, extra_query=extra_query, extra_body=extra_body, timeout=timeout + ), + cast_to=SubmitFieldsResponse, + ) + + def timeline( + self, + id: str, + *, + limit: int | Omit = omit, + offset: int | Omit = omit, + type: Literal["login", "reauth", "health_check"] | 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, + ) -> SyncOffsetPagination[ManagedAuthTimelineEvent]: + """ + Returns a chronological timeline of events for an auth connection — login + attempts, automatic re-auth attempts, and health checks. Events are returned + newest-first. + + Args: + limit: Maximum number of events to return + + offset: Number of events to skip + + type: Filter the timeline to a single event type. + + 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_api_list( + path_template("/auth/connections/{id}/timeline", id=id), + page=SyncOffsetPagination[ManagedAuthTimelineEvent], + options=make_request_options( + extra_headers=extra_headers, + extra_query=extra_query, + extra_body=extra_body, + timeout=timeout, + query=maybe_transform( + { + "limit": limit, + "offset": offset, + "type": type, + }, + connection_timeline_params.ConnectionTimelineParams, + ), + ), + model=ManagedAuthTimelineEvent, + ) + + +class AsyncConnectionsResource(AsyncAPIResource): + """Create and manage auth connections for automated credential capture and login.""" + + @cached_property + def with_raw_response(self) -> AsyncConnectionsResourceWithRawResponse: + """ + 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/kernel/kernel-python-sdk#accessing-raw-response-data-eg-headers + """ + return AsyncConnectionsResourceWithRawResponse(self) + + @cached_property + def with_streaming_response(self) -> AsyncConnectionsResourceWithStreamingResponse: + """ + An alternative to `.with_raw_response` that doesn't eagerly read the response body. + + For more information, see https://www.github.com/kernel/kernel-python-sdk#with_streaming_response + """ + return AsyncConnectionsResourceWithStreamingResponse(self) + + async def create( + self, + *, + domain: str, + profile_name: str, + allowed_domains: SequenceNotStr[str] | Omit = omit, + auto_reauth: bool | Omit = omit, + browser_telemetry: Optional[connection_create_params.BrowserTelemetry] | Omit = omit, + credential: connection_create_params.Credential | Omit = omit, + health_check_interval: int | Omit = omit, + health_checks: bool | Omit = omit, + login_url: str | Omit = omit, + proxy: connection_create_params.Proxy | Omit = omit, + record_session: bool | Omit = omit, + save_credentials: 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, + ) -> ManagedAuth: + """Creates an auth connection for a profile and domain combination. + + If the provided + profile_name does not exist, it is created automatically. Returns 409 Conflict + if an auth connection already exists for the given profile and domain. + + Args: + domain: Domain for authentication + + profile_name: Name of the profile to manage authentication for. If the profile does not exist, + it is created automatically. + + allowed_domains: Additional domains valid for this auth flow (besides the primary domain). Useful + when login pages redirect to different domains. + + The following SSO/OAuth provider domains are automatically allowed by default + and do not need to be specified: + + - Google: accounts.google.com + - Microsoft/Azure AD: login.microsoftonline.com, login.live.com + - Okta: _.okta.com, _.oktapreview.com + - Auth0: _.auth0.com, _.us.auth0.com, _.eu.auth0.com, _.au.auth0.com + - Apple: appleid.apple.com + - GitHub: github.com + - Facebook/Meta: www.facebook.com + - LinkedIn: www.linkedin.com + - Amazon Cognito: \\**.amazoncognito.com + - OneLogin: \\**.onelogin.com + - Ping Identity: _.pingone.com, _.pingidentity.com + + auto_reauth: Whether to permit automatic re-authentication when a scheduled health check + detects an expired session. This is an opt-in flag only — it does not check + whether re-auth is actually feasible. Even when true, re-auth only runs when the + system has what it needs to perform it (for example, saved credentials for the + required login fields), and only after a scheduled health check detects an + expired session — so this flag has no effect when `health_checks` is false. When + false, expired sessions are marked as `NEEDS_AUTH` instead of attempting + re-auth. Defaults to true. + + browser_telemetry: Browser telemetry configuration used by this connection's browser sessions by + default. Uses the exact create-browser configuration. Can be overridden + per-login. + + credential: + Reference to credentials for the auth connection. Use one of: + + - { name } for Kernel credentials + - { provider, path } for external provider item + - { provider, auto: true } for external provider domain lookup + + health_check_interval: Interval in seconds between automatic health checks. When set, the system + periodically verifies the authentication status and triggers re-authentication + if needed. Maximum is 86400 (24 hours). Default is 3600 (1 hour). The minimum + depends on your plan: Enterprise: 300 (5 minutes), Startup: 1200 (20 minutes), + Hobbyist: 3600 (1 hour). + + health_checks: Whether to enable periodic health checks. When false, the system will not + automatically verify authentication status, and `auto_reauth` has no effect on + the automatic flow (since re-auth is only triggered by a failed scheduled health + check). Defaults to true. + + login_url: Optional login page URL to skip discovery + + proxy: Proxy selection. Provide either id or name. The proxy must be in the same + project as the resource referencing it. When selecting by name, the name must + match exactly one active proxy in the project. Ambiguous names return a 400; use + id for stable references. + + record_session: Whether to record browser sessions for this connection by default. Useful for + debugging. Can be overridden per-login. Defaults to false. + + save_credentials: Whether to save credentials after every successful login. Defaults to true. + One-time codes (TOTP, SMS, etc.) are not saved. + + 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( + "/auth/connections", + body=await async_maybe_transform( + { + "domain": domain, + "profile_name": profile_name, + "allowed_domains": allowed_domains, + "auto_reauth": auto_reauth, + "browser_telemetry": browser_telemetry, + "credential": credential, + "health_check_interval": health_check_interval, + "health_checks": health_checks, + "login_url": login_url, + "proxy": proxy, + "record_session": record_session, + "save_credentials": save_credentials, + }, + connection_create_params.ConnectionCreateParams, + ), + options=make_request_options( + extra_headers=extra_headers, extra_query=extra_query, extra_body=extra_body, timeout=timeout + ), + cast_to=ManagedAuth, + ) + + async def retrieve( + 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, + ) -> ManagedAuth: + """Retrieve an auth connection by its ID. + + Includes current flow state if a login is + in progress. + + 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( + path_template("/auth/connections/{id}", id=id), + options=make_request_options( + extra_headers=extra_headers, extra_query=extra_query, extra_body=extra_body, timeout=timeout + ), + cast_to=ManagedAuth, + ) + + async def update( + self, + id: str, + *, + allowed_domains: SequenceNotStr[str] | Omit = omit, + auto_reauth: bool | Omit = omit, + browser_telemetry: Optional[connection_update_params.BrowserTelemetry] | Omit = omit, + credential: connection_update_params.Credential | Omit = omit, + health_check_interval: int | Omit = omit, + health_checks: bool | Omit = omit, + login_url: str | Omit = omit, + proxy: connection_update_params.Proxy | Omit = omit, + record_session: bool | Omit = omit, + save_credentials: 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, + ) -> ManagedAuth: + """Update an auth connection's configuration. + + Only the fields provided will be + updated. + + Args: + allowed_domains: Additional domains valid for this auth flow (replaces existing list) + + auto_reauth: Whether automatic re-authentication is permitted for this connection. This is an + opt-in flag only — it does not check whether re-auth is actually feasible. Even + when true, re-auth only runs when the system has what it needs to perform it + (for example, saved credentials for the required login fields), and only after a + scheduled health check detects an expired session — so this flag has no effect + when `health_checks` is false. When false, expired sessions detected by a health + check are marked as `NEEDS_AUTH` instead of attempting re-auth. + + browser_telemetry: Browser telemetry configuration used by future browser sessions for this + connection. Uses the exact create-browser configuration. Set enabled to false to + disable telemetry. + + credential: + Reference to credentials for the auth connection. Use one of: + + - { name } for Kernel credentials + - { provider, path } for external provider item + - { provider, auto: true } for external provider domain lookup + + health_check_interval: Interval in seconds between automatic health checks + + health_checks: Whether periodic health checks are enabled. When set to false, the system will + not automatically verify authentication status, and `auto_reauth` has no effect + on the automatic flow (since re-auth is only triggered by a failed scheduled + health check). + + login_url: Login page URL. Set to empty string to clear. + + proxy: Proxy selection. Provide either id or name. The proxy must be in the same + project as the resource referencing it. When selecting by name, the name must + match exactly one active proxy in the project. Ambiguous names return a 400; use + id for stable references. + + record_session: Whether to record browser sessions for this connection by default + + save_credentials: Whether to save credentials after every successful login + + 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( + path_template("/auth/connections/{id}", id=id), + body=await async_maybe_transform( + { + "allowed_domains": allowed_domains, + "auto_reauth": auto_reauth, + "browser_telemetry": browser_telemetry, + "credential": credential, + "health_check_interval": health_check_interval, + "health_checks": health_checks, + "login_url": login_url, + "proxy": proxy, + "record_session": record_session, + "save_credentials": save_credentials, + }, + connection_update_params.ConnectionUpdateParams, + ), + options=make_request_options( + extra_headers=extra_headers, extra_query=extra_query, extra_body=extra_body, timeout=timeout + ), + cast_to=ManagedAuth, + ) + + def list( + self, + *, + domain: str | Omit = omit, + limit: int | Omit = omit, + offset: int | Omit = omit, + profile_name: str | Omit = omit, + query: 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, + ) -> AsyncPaginator[ManagedAuth, AsyncOffsetPagination[ManagedAuth]]: + """ + List auth connections with optional filters for profile_name and domain. + + Args: + domain: Filter by domain + + limit: Maximum number of results to return + + offset: Number of results to skip + + profile_name: Filter by profile name + + query: Search auth connections by ID, domain, or profile name. + + 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_api_list( + "/auth/connections", + page=AsyncOffsetPagination[ManagedAuth], + options=make_request_options( + extra_headers=extra_headers, + extra_query=extra_query, + extra_body=extra_body, + timeout=timeout, + query=maybe_transform( + { + "domain": domain, + "limit": limit, + "offset": offset, + "profile_name": profile_name, + "query": query, + }, + connection_list_params.ConnectionListParams, + ), + ), + model=ManagedAuth, + ) + + 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, + ) -> None: + """Deletes an auth connection and terminates its workflow. + + This will: + + - Delete the auth connection record + - Terminate the Temporal workflow + - Cancel any in-progress login flows + + 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}") + extra_headers = {"Accept": "*/*", **(extra_headers or {})} + return await self._delete( + path_template("/auth/connections/{id}", id=id), + options=make_request_options( + extra_headers=extra_headers, extra_query=extra_query, extra_body=extra_body, timeout=timeout + ), + cast_to=NoneType, + ) + + async def follow( + 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, + ) -> AsyncStream[ConnectionFollowResponse]: + """ + Establishes a Server-Sent Events (SSE) stream that delivers real-time login flow + state updates. The stream terminates automatically once the flow reaches a + terminal state (SUCCESS, FAILED, EXPIRED, CANCELED). + + 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}") + extra_headers = {"Accept": "text/event-stream", **(extra_headers or {})} + return await self._get( + path_template("/auth/connections/{id}/events", id=id), + options=make_request_options( + extra_headers=extra_headers, extra_query=extra_query, extra_body=extra_body, timeout=timeout + ), + cast_to=cast( + Any, ConnectionFollowResponse + ), # Union types cannot be passed in as arguments in the type system + stream=True, + stream_cls=AsyncStream[ConnectionFollowResponse], + ) + + async def login( + self, + id: str, + *, + browser_telemetry: Optional[connection_login_params.BrowserTelemetry] | Omit = omit, + proxy: connection_login_params.Proxy | Omit = omit, + record_session: 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, + ) -> LoginResponse: + """Starts a login flow for the auth connection. + + Returns immediately with a hosted + URL for the user to complete authentication, or triggers automatic re-auth if + credentials are stored. + + Args: + browser_telemetry: Override the connection's default browser telemetry configuration for this + login. When omitted, the connection's browser_telemetry default is used. Uses + the exact create-browser configuration. + + proxy: Proxy selection. Provide either id or name. The proxy must be in the same + project as the resource referencing it. When selecting by name, the name must + match exactly one active proxy in the project. Ambiguous names return a 400; use + id for stable references. + + record_session: Override the connection's default for recording this login's browser session. + When omitted, the connection's record_session default is used. + + 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( + path_template("/auth/connections/{id}/login", id=id), + body=await async_maybe_transform( + { + "browser_telemetry": browser_telemetry, + "proxy": proxy, + "record_session": record_session, + }, + connection_login_params.ConnectionLoginParams, + ), + options=make_request_options( + extra_headers=extra_headers, extra_query=extra_query, extra_body=extra_body, timeout=timeout + ), + cast_to=LoginResponse, + ) + + async def submit( + self, + id: str, + *, + field_values: Dict[str, str] | Omit = omit, + fields: Dict[str, str] | Omit = omit, + mfa_option_id: str | Omit = omit, + selected_choice_id: str | Omit = omit, + sign_in_option_id: str | Omit = omit, + sso_button_selector: str | Omit = omit, + sso_provider: 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, + ) -> SubmitFieldsResponse: + """Submits field values for the login form. + + Poll the auth connection to track + progress and get results. + + Args: + field_values: Canonical map of field ID to submitted value. + + fields: Map of field name to value + + mfa_option_id: The MFA method type to select (when mfa_options were returned) + + selected_choice_id: Canonical choice ID selected by the user. + + sign_in_option_id: The sign-in option ID to select (when sign_in_options were returned) + + sso_button_selector: XPath selector for the SSO button to click (ODA). Use sso_provider instead for + CUA. + + sso_provider: SSO provider to click, matching the provider field from pending_sso_buttons + (e.g., "google", "github"). Cannot be used with sso_button_selector. + + 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( + path_template("/auth/connections/{id}/submit", id=id), + body=await async_maybe_transform( + { + "field_values": field_values, + "fields": fields, + "mfa_option_id": mfa_option_id, + "selected_choice_id": selected_choice_id, + "sign_in_option_id": sign_in_option_id, + "sso_button_selector": sso_button_selector, + "sso_provider": sso_provider, + }, + connection_submit_params.ConnectionSubmitParams, + ), + options=make_request_options( + extra_headers=extra_headers, extra_query=extra_query, extra_body=extra_body, timeout=timeout + ), + cast_to=SubmitFieldsResponse, + ) + + def timeline( + self, + id: str, + *, + limit: int | Omit = omit, + offset: int | Omit = omit, + type: Literal["login", "reauth", "health_check"] | 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, + ) -> AsyncPaginator[ManagedAuthTimelineEvent, AsyncOffsetPagination[ManagedAuthTimelineEvent]]: + """ + Returns a chronological timeline of events for an auth connection — login + attempts, automatic re-auth attempts, and health checks. Events are returned + newest-first. + + Args: + limit: Maximum number of events to return + + offset: Number of events to skip + + type: Filter the timeline to a single event type. + + 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_api_list( + path_template("/auth/connections/{id}/timeline", id=id), + page=AsyncOffsetPagination[ManagedAuthTimelineEvent], + options=make_request_options( + extra_headers=extra_headers, + extra_query=extra_query, + extra_body=extra_body, + timeout=timeout, + query=maybe_transform( + { + "limit": limit, + "offset": offset, + "type": type, + }, + connection_timeline_params.ConnectionTimelineParams, + ), + ), + model=ManagedAuthTimelineEvent, + ) + + +class ConnectionsResourceWithRawResponse: + def __init__(self, connections: ConnectionsResource) -> None: + self._connections = connections + + self.create = to_raw_response_wrapper( + connections.create, + ) + self.retrieve = to_raw_response_wrapper( + connections.retrieve, + ) + self.update = to_raw_response_wrapper( + connections.update, + ) + self.list = to_raw_response_wrapper( + connections.list, + ) + self.delete = to_raw_response_wrapper( + connections.delete, + ) + self.follow = to_raw_response_wrapper( + connections.follow, + ) + self.login = to_raw_response_wrapper( + connections.login, + ) + self.submit = to_raw_response_wrapper( + connections.submit, + ) + self.timeline = to_raw_response_wrapper( + connections.timeline, + ) + + +class AsyncConnectionsResourceWithRawResponse: + def __init__(self, connections: AsyncConnectionsResource) -> None: + self._connections = connections + + self.create = async_to_raw_response_wrapper( + connections.create, + ) + self.retrieve = async_to_raw_response_wrapper( + connections.retrieve, + ) + self.update = async_to_raw_response_wrapper( + connections.update, + ) + self.list = async_to_raw_response_wrapper( + connections.list, + ) + self.delete = async_to_raw_response_wrapper( + connections.delete, + ) + self.follow = async_to_raw_response_wrapper( + connections.follow, + ) + self.login = async_to_raw_response_wrapper( + connections.login, + ) + self.submit = async_to_raw_response_wrapper( + connections.submit, + ) + self.timeline = async_to_raw_response_wrapper( + connections.timeline, + ) + + +class ConnectionsResourceWithStreamingResponse: + def __init__(self, connections: ConnectionsResource) -> None: + self._connections = connections + + self.create = to_streamed_response_wrapper( + connections.create, + ) + self.retrieve = to_streamed_response_wrapper( + connections.retrieve, + ) + self.update = to_streamed_response_wrapper( + connections.update, + ) + self.list = to_streamed_response_wrapper( + connections.list, + ) + self.delete = to_streamed_response_wrapper( + connections.delete, + ) + self.follow = to_streamed_response_wrapper( + connections.follow, + ) + self.login = to_streamed_response_wrapper( + connections.login, + ) + self.submit = to_streamed_response_wrapper( + connections.submit, + ) + self.timeline = to_streamed_response_wrapper( + connections.timeline, + ) + + +class AsyncConnectionsResourceWithStreamingResponse: + def __init__(self, connections: AsyncConnectionsResource) -> None: + self._connections = connections + + self.create = async_to_streamed_response_wrapper( + connections.create, + ) + self.retrieve = async_to_streamed_response_wrapper( + connections.retrieve, + ) + self.update = async_to_streamed_response_wrapper( + connections.update, + ) + self.list = async_to_streamed_response_wrapper( + connections.list, + ) + self.delete = async_to_streamed_response_wrapper( + connections.delete, + ) + self.follow = async_to_streamed_response_wrapper( + connections.follow, + ) + self.login = async_to_streamed_response_wrapper( + connections.login, + ) + self.submit = async_to_streamed_response_wrapper( + connections.submit, + ) + self.timeline = async_to_streamed_response_wrapper( + connections.timeline, + ) diff --git a/src/kernel/resources/browser_pools.py b/src/kernel/resources/browser_pools.py new file mode 100644 index 00000000..d71fc272 --- /dev/null +++ b/src/kernel/resources/browser_pools.py @@ -0,0 +1,1371 @@ +# File generated from our OpenAPI spec by Stainless. See CONTRIBUTING.md for details. + +from __future__ import annotations + +from typing import Dict, Iterable, Optional + +import httpx + +from ..types import ( + browser_pool_list_params, + browser_pool_create_params, + browser_pool_delete_params, + browser_pool_update_params, + browser_pool_acquire_params, + browser_pool_release_params, +) +from .._types import Body, Omit, Query, Headers, NoneType, NotGiven, omit, not_given +from .._utils import path_template, 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 ..pagination import SyncOffsetPagination, AsyncOffsetPagination +from .._base_client import AsyncPaginator, make_request_options +from ..types.tags_param import TagsParam +from ..types.browser_pool import BrowserPool +from ..types.browser_pool_acquire_response import BrowserPoolAcquireResponse +from ..types.shared_params.browser_viewport import BrowserViewport +from ..types.shared_params.browser_extension import BrowserExtension + +__all__ = ["BrowserPoolsResource", "AsyncBrowserPoolsResource"] + + +class BrowserPoolsResource(SyncAPIResource): + """Create and manage browser pools for acquiring and releasing browsers.""" + + @cached_property + def with_raw_response(self) -> BrowserPoolsResourceWithRawResponse: + """ + 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/kernel/kernel-python-sdk#accessing-raw-response-data-eg-headers + """ + return BrowserPoolsResourceWithRawResponse(self) + + @cached_property + def with_streaming_response(self) -> BrowserPoolsResourceWithStreamingResponse: + """ + An alternative to `.with_raw_response` that doesn't eagerly read the response body. + + For more information, see https://www.github.com/kernel/kernel-python-sdk#with_streaming_response + """ + return BrowserPoolsResourceWithStreamingResponse(self) + + def create( + self, + *, + size: int, + chrome_policy: Dict[str, object] | Omit = omit, + extensions: Iterable[BrowserExtension] | Omit = omit, + fill_rate_per_minute: int | Omit = omit, + headless: bool | Omit = omit, + kiosk_mode: bool | Omit = omit, + name: str | Omit = omit, + profile: browser_pool_create_params.Profile | Omit = omit, + proxy_id: str | Omit = omit, + refresh_on_profile_update: bool | Omit = omit, + start_url: str | Omit = omit, + stealth: bool | Omit = omit, + telemetry: Optional[browser_pool_create_params.Telemetry] | Omit = omit, + timeout_seconds: int | Omit = omit, + viewport: BrowserViewport | 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, + ) -> BrowserPool: + """Create a new browser pool with the specified configuration and size. + + Pooled + browsers load their profile read-only: any save_changes on the profile is + ignored (not rejected), so pooled browsers never persist changes back to the + profile. + + Args: + size: Number of browsers to maintain in the pool. The maximum size is determined by + your organization's pooled sessions limit (the sum of all pool sizes cannot + exceed your limit). + + chrome_policy: Custom Chrome enterprise policy overrides applied to all browsers in this pool. + Keys are Chrome enterprise policy names; values must match their expected types. + Blocked: kernel-managed policies (extensions, proxy, CDP/automation). See + https://chromeenterprise.google/policies/ The serialized JSON payload is capped + at 5 MiB. + + extensions: List of browser extensions to load into the session. Provide each by id or name. + + fill_rate_per_minute: Percentage of the pool to fill per minute. Defaults to 25. The cap is 25 for + most organizations but can be raised per-organization, so only the lower bound + is enforced here. + + headless: If true, launches the browser using a headless image. Defaults to false. + + kiosk_mode: If true, launches the browser in kiosk mode to hide address bar and tabs in live + view. Defaults to false. + + name: Optional name for the browser pool. Must be unique within the project. + + profile: Profile configuration for browsers in a pool. Provide either id or name. + Profiles must be created beforehand. Unlike single browser sessions, pools load + the profile read-only and never persist changes back to it, so save_changes is + omitted here. Any save_changes value sent on a pool profile is silently ignored + rather than rejected. + + proxy_id: Optional proxy to associate to the browser session. Must reference a proxy in + the same project as the browser session. + + refresh_on_profile_update: When true, flush idle browsers when the profile the pool uses is updated, so + pool browsers pick up the latest profile data. When a profile is provided during + creation, this defaults to true. Requires a profile to be set on the pool. + + start_url: Optional URL to navigate to when a new browser is warmed into the pool. + Best-effort: failures to navigate do not fail pool fill. Only applied to + newly-warmed browsers; browsers reused via release/acquire keep whatever URL the + previous lease left them on. Accepts any URL Chromium can resolve, including + chrome:// pages. + + stealth: If true, launches the browser in stealth mode to reduce detection by anti-bot + mechanisms. Defaults to false. + + telemetry: Telemetry configuration applied to browsers warmed into this pool. Set enabled + to true to start capture using the default set, or provide browser category + settings. If omitted, null, set to an empty object ({}), set to enabled: false + without browser category settings, or all four CDP categories are explicitly + disabled, no telemetry is configured on the pool. Only applied to newly-warmed + browsers. + + timeout_seconds: Default idle timeout in seconds for browsers acquired from this pool before they + are destroyed. Defaults to 600 seconds. Minimum 10, maximum 259200 (72 hours). + + viewport: Initial browser window size in pixels with optional refresh rate. If omitted, + image defaults apply (1920x1080@25). For GPU images, the default is + 1920x1080@60. Arbitrary viewport dimensions and refresh rates are accepted. + Known-good presets include: 2560x1440@10, 1920x1080@25, 1920x1200@25, + 1440x900@25, 1280x800@60, 1024x768@60, 1200x800@60, 768x1024@60, 390x844@60. For + GPU images, recommended presets use one of these resolutions with refresh rates + 60, 30, 25, or 10: 800x600, 960x720, 1024x576, 1024x768, 1152x648, 1200x800, + 1280x720, 1368x768, 1440x900, 1600x900, 1920x1080, 1920x1200, 390x844, 360x250, + 768x1024, 800x1600. Viewports outside this list may exhibit unstable live view + or recording behavior. If refresh_rate is not provided, it will be automatically + determined based on the resolution (higher resolutions use lower refresh rates + to keep bandwidth reasonable). + + 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( + "/browser_pools", + body=maybe_transform( + { + "size": size, + "chrome_policy": chrome_policy, + "extensions": extensions, + "fill_rate_per_minute": fill_rate_per_minute, + "headless": headless, + "kiosk_mode": kiosk_mode, + "name": name, + "profile": profile, + "proxy_id": proxy_id, + "refresh_on_profile_update": refresh_on_profile_update, + "start_url": start_url, + "stealth": stealth, + "telemetry": telemetry, + "timeout_seconds": timeout_seconds, + "viewport": viewport, + }, + browser_pool_create_params.BrowserPoolCreateParams, + ), + options=make_request_options( + extra_headers=extra_headers, extra_query=extra_query, extra_body=extra_body, timeout=timeout + ), + cast_to=BrowserPool, + ) + + def retrieve( + self, + id_or_name: 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, + ) -> BrowserPool: + """ + Retrieve details for a single browser pool by its ID or name. + + 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_or_name: + raise ValueError(f"Expected a non-empty value for `id_or_name` but received {id_or_name!r}") + return self._get( + path_template("/browser_pools/{id_or_name}", id_or_name=id_or_name), + options=make_request_options( + extra_headers=extra_headers, extra_query=extra_query, extra_body=extra_body, timeout=timeout + ), + cast_to=BrowserPool, + ) + + def update( + self, + id_or_name: str, + *, + chrome_policy: Dict[str, object] | Omit = omit, + discard_all_idle: bool | Omit = omit, + extensions: Iterable[BrowserExtension] | Omit = omit, + fill_rate_per_minute: int | Omit = omit, + headless: bool | Omit = omit, + kiosk_mode: bool | Omit = omit, + name: str | Omit = omit, + profile: browser_pool_update_params.Profile | Omit = omit, + proxy_id: str | Omit = omit, + refresh_on_profile_update: bool | Omit = omit, + size: int | Omit = omit, + start_url: str | Omit = omit, + stealth: bool | Omit = omit, + telemetry: Optional[browser_pool_update_params.Telemetry] | Omit = omit, + timeout_seconds: int | Omit = omit, + viewport: BrowserViewport | 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, + ) -> BrowserPool: + """Updates the configuration used to create browsers in the pool. + + As with creation, + save_changes on the pool profile is ignored (not rejected); pooled browsers + never persist changes back to the profile. To clear the profile reference, send + `profile: { "id": "" }`. Clearing the profile also disables + `refresh_on_profile_update`. + + Args: + chrome_policy: If provided, replaces the custom Chrome enterprise policy overrides applied to + all browsers in this pool. Empty object clears any previously-set policy. Keys + are Chrome enterprise policy names; values must match their expected types. + Blocked: kernel-managed policies (extensions, proxy, CDP/automation). See + https://chromeenterprise.google/policies/ The serialized JSON payload is capped + at 5 MiB. + + discard_all_idle: Whether to discard all idle browsers and rebuild them immediately with the new + configuration. Defaults to false. Only browsers that are idle when the update + runs are rebuilt. A browser that is in use during the update keeps its original + configuration, and if it is later released with `reuse: true` it returns to the + pool with that stale configuration until it is discarded (by this flag on a + later update, or by flushing the pool). + + extensions: If provided, replaces the extension list. Empty array clears all + previously-selected extensions. Omit this field to leave extensions unchanged. + + fill_rate_per_minute: If provided, replaces the percentage of the pool to fill per minute. The cap is + 25 for most organizations but can be raised per-organization, so only the lower + bound is enforced here. + + headless: If provided, replaces whether browsers launch using a headless image. + + kiosk_mode: If provided, replaces whether browsers launch in kiosk mode. + + name: If provided, replaces the pool name. Empty string is a no-op; the pool name + cannot be cleared or reset to empty once assigned. + + profile: Profile configuration for browsers in a pool. Provide either id or name. + Profiles must be created beforehand. Unlike single browser sessions, pools load + the profile read-only and never persist changes back to it, so save_changes is + omitted here. Any save_changes value sent on a pool profile is silently ignored + rather than rejected. + + proxy_id: Empty string clears the previously-selected proxy. Omit this field to leave the + proxy unchanged. + + refresh_on_profile_update: If provided, replaces whether idle browsers are flushed when the profile the + pool uses is updated. When the pool's profile reference is changed (including + newly attached) and this field is omitted, it defaults to true. Re-sending the + same profile reference leaves this setting unchanged. Clearing the profile also + disables this setting. Requires a profile to be set on the pool. + + size: If provided, replaces the number of browsers to maintain in the pool. The + maximum size is determined by your organization's pooled sessions limit (the sum + of all pool sizes cannot exceed your limit). + + start_url: If provided, replaces the URL to navigate to when a new browser is warmed into + the pool. Empty string clears the previously-set URL. Omit this field to leave + it unchanged. + + stealth: If provided, replaces whether browsers launch in stealth mode. + + telemetry: If provided, updates the pool's telemetry configuration. Omit, set to null, or + set to an empty object ({}) to leave the existing configuration unchanged. Set + enabled to true to enable capture using the default set. Set enabled to false to + clear the pool's telemetry. Provide browser category settings for per-category + updates, merged onto the pool's current configuration. Only applied to browsers + warmed after the update; browsers already in the pool keep their configuration + until discarded. + + timeout_seconds: If provided, replaces the default idle timeout in seconds for browsers acquired + from this pool before they are destroyed. Minimum 10, maximum 259200 (72 hours). + + viewport: Initial browser window size in pixels with optional refresh rate. If omitted, + image defaults apply (1920x1080@25). For GPU images, the default is + 1920x1080@60. Arbitrary viewport dimensions and refresh rates are accepted. + Known-good presets include: 2560x1440@10, 1920x1080@25, 1920x1200@25, + 1440x900@25, 1280x800@60, 1024x768@60, 1200x800@60, 768x1024@60, 390x844@60. For + GPU images, recommended presets use one of these resolutions with refresh rates + 60, 30, 25, or 10: 800x600, 960x720, 1024x576, 1024x768, 1152x648, 1200x800, + 1280x720, 1368x768, 1440x900, 1600x900, 1920x1080, 1920x1200, 390x844, 360x250, + 768x1024, 800x1600. Viewports outside this list may exhibit unstable live view + or recording behavior. If refresh_rate is not provided, it will be automatically + determined based on the resolution (higher resolutions use lower refresh rates + to keep bandwidth reasonable). + + 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_or_name: + raise ValueError(f"Expected a non-empty value for `id_or_name` but received {id_or_name!r}") + return self._patch( + path_template("/browser_pools/{id_or_name}", id_or_name=id_or_name), + body=maybe_transform( + { + "chrome_policy": chrome_policy, + "discard_all_idle": discard_all_idle, + "extensions": extensions, + "fill_rate_per_minute": fill_rate_per_minute, + "headless": headless, + "kiosk_mode": kiosk_mode, + "name": name, + "profile": profile, + "proxy_id": proxy_id, + "refresh_on_profile_update": refresh_on_profile_update, + "size": size, + "start_url": start_url, + "stealth": stealth, + "telemetry": telemetry, + "timeout_seconds": timeout_seconds, + "viewport": viewport, + }, + browser_pool_update_params.BrowserPoolUpdateParams, + ), + options=make_request_options( + extra_headers=extra_headers, extra_query=extra_query, extra_body=extra_body, timeout=timeout + ), + cast_to=BrowserPool, + ) + + def list( + self, + *, + limit: int | Omit = omit, + name: str | Omit = omit, + offset: int | Omit = omit, + query: 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, + ) -> SyncOffsetPagination[BrowserPool]: + """ + List browser pools in the resolved project. + + Args: + limit: Limit the number of browser pools to return. + + name: Exact-match filter on browser pool name using the database collation. In + production, matching is case- and accent-insensitive. During the default-project + migration, unscoped requests prefer a concrete default-project browser pool over + a legacy unscoped browser pool with the same name. + + offset: Offset the number of browser pools to return. + + query: Case-insensitive substring match against browser pool name. IDs match by exact + value. + + 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_api_list( + "/browser_pools", + page=SyncOffsetPagination[BrowserPool], + options=make_request_options( + extra_headers=extra_headers, + extra_query=extra_query, + extra_body=extra_body, + timeout=timeout, + query=maybe_transform( + { + "limit": limit, + "name": name, + "offset": offset, + "query": query, + }, + browser_pool_list_params.BrowserPoolListParams, + ), + ), + model=BrowserPool, + ) + + def delete( + self, + id_or_name: str, + *, + force: 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, + ) -> None: + """Delete a browser pool and all browsers in it. + + By default, deletion is blocked if + browsers are currently leased. Use force=true to terminate leased browsers. + + Args: + force: If true, force delete even if browsers are currently leased. Leased browsers + will be terminated. + + 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_or_name: + raise ValueError(f"Expected a non-empty value for `id_or_name` but received {id_or_name!r}") + extra_headers = {"Accept": "*/*", **(extra_headers or {})} + return self._delete( + path_template("/browser_pools/{id_or_name}", id_or_name=id_or_name), + body=maybe_transform({"force": force}, browser_pool_delete_params.BrowserPoolDeleteParams), + options=make_request_options( + extra_headers=extra_headers, extra_query=extra_query, extra_body=extra_body, timeout=timeout + ), + cast_to=NoneType, + ) + + def acquire( + self, + id_or_name: str, + *, + acquire_timeout_seconds: int | Omit = omit, + name: str | Omit = omit, + start_url: str | Omit = omit, + tags: TagsParam | Omit = omit, + telemetry: Optional[browser_pool_acquire_params.Telemetry] | 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, + ) -> BrowserPoolAcquireResponse: + """Long-polling endpoint to acquire a browser from the pool. + + Returns immediately + when a browser is available, or returns 204 No Content when the poll times out. + The client should retry the request to continue waiting for a browser. The + acquired browser will use the pool's timeout_seconds for its idle timeout. + + Args: + acquire_timeout_seconds: Maximum number of seconds to wait for a browser to be available. Defaults to the + calculated time it would take to fill the pool at the currently configured fill + rate. + + name: Optional human-readable name for the acquired browser session, used to find it + later in the dashboard. Must be unique among active sessions within the pool's + project. Applies to this lease only and is cleared when the browser is released + back to the pool. + + start_url: Optional URL to navigate the acquired browser to. Overrides the pool's start_url + for this acquire only. Best-effort: failures to navigate do not fail the + acquire. + + tags: Optional user-defined key-value tags for the acquired browser session, used to + find and group sessions later. Applies to this lease only and are cleared when + the browser is released back to the pool. Up to 50 pairs. + + telemetry: Telemetry override for the acquired browser, applied to this lease only. Merges + onto the browser's current (pool-inherited) telemetry using the same + per-category semantics as PATCH /browsers: provided categories override the + current configuration, omitted categories are inherited. Set enabled to true to + resolve the config fresh from the default set, or enabled to false to stop + capture. When the browser is released back to the pool with reuse, its telemetry + is reset to the pool's baseline, so the override does not carry over to the next + lease. + + 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_or_name: + raise ValueError(f"Expected a non-empty value for `id_or_name` but received {id_or_name!r}") + return self._post( + path_template("/browser_pools/{id_or_name}/acquire", id_or_name=id_or_name), + body=maybe_transform( + { + "acquire_timeout_seconds": acquire_timeout_seconds, + "name": name, + "start_url": start_url, + "tags": tags, + "telemetry": telemetry, + }, + browser_pool_acquire_params.BrowserPoolAcquireParams, + ), + options=make_request_options( + extra_headers=extra_headers, extra_query=extra_query, extra_body=extra_body, timeout=timeout + ), + cast_to=BrowserPoolAcquireResponse, + ) + + def flush( + self, + id_or_name: 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, + ) -> None: + """ + Destroys all idle browsers in the pool; leased browsers are not affected. + + 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_or_name: + raise ValueError(f"Expected a non-empty value for `id_or_name` but received {id_or_name!r}") + extra_headers = {"Accept": "*/*", **(extra_headers or {})} + return self._post( + path_template("/browser_pools/{id_or_name}/flush", id_or_name=id_or_name), + options=make_request_options( + extra_headers=extra_headers, extra_query=extra_query, extra_body=extra_body, timeout=timeout + ), + cast_to=NoneType, + ) + + def release( + self, + id_or_name: str, + *, + session_id: str, + reuse: 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, + ) -> None: + """ + Release a browser back to the pool, optionally recreating the browser instance. + + Args: + session_id: Browser session ID to release back to the pool + + reuse: Whether to reuse the browser instance or destroy it and create a new one. + Defaults to true. A reused browser keeps the configuration it was created with, + so it does not pick up pool configuration changes made while it was in use. + Release with `reuse: false`, or flush the pool afterward, to rebuild it with the + current configuration. + + 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_or_name: + raise ValueError(f"Expected a non-empty value for `id_or_name` but received {id_or_name!r}") + extra_headers = {"Accept": "*/*", **(extra_headers or {})} + return self._post( + path_template("/browser_pools/{id_or_name}/release", id_or_name=id_or_name), + body=maybe_transform( + { + "session_id": session_id, + "reuse": reuse, + }, + browser_pool_release_params.BrowserPoolReleaseParams, + ), + options=make_request_options( + extra_headers=extra_headers, extra_query=extra_query, extra_body=extra_body, timeout=timeout + ), + cast_to=NoneType, + ) + + +class AsyncBrowserPoolsResource(AsyncAPIResource): + """Create and manage browser pools for acquiring and releasing browsers.""" + + @cached_property + def with_raw_response(self) -> AsyncBrowserPoolsResourceWithRawResponse: + """ + 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/kernel/kernel-python-sdk#accessing-raw-response-data-eg-headers + """ + return AsyncBrowserPoolsResourceWithRawResponse(self) + + @cached_property + def with_streaming_response(self) -> AsyncBrowserPoolsResourceWithStreamingResponse: + """ + An alternative to `.with_raw_response` that doesn't eagerly read the response body. + + For more information, see https://www.github.com/kernel/kernel-python-sdk#with_streaming_response + """ + return AsyncBrowserPoolsResourceWithStreamingResponse(self) + + async def create( + self, + *, + size: int, + chrome_policy: Dict[str, object] | Omit = omit, + extensions: Iterable[BrowserExtension] | Omit = omit, + fill_rate_per_minute: int | Omit = omit, + headless: bool | Omit = omit, + kiosk_mode: bool | Omit = omit, + name: str | Omit = omit, + profile: browser_pool_create_params.Profile | Omit = omit, + proxy_id: str | Omit = omit, + refresh_on_profile_update: bool | Omit = omit, + start_url: str | Omit = omit, + stealth: bool | Omit = omit, + telemetry: Optional[browser_pool_create_params.Telemetry] | Omit = omit, + timeout_seconds: int | Omit = omit, + viewport: BrowserViewport | 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, + ) -> BrowserPool: + """Create a new browser pool with the specified configuration and size. + + Pooled + browsers load their profile read-only: any save_changes on the profile is + ignored (not rejected), so pooled browsers never persist changes back to the + profile. + + Args: + size: Number of browsers to maintain in the pool. The maximum size is determined by + your organization's pooled sessions limit (the sum of all pool sizes cannot + exceed your limit). + + chrome_policy: Custom Chrome enterprise policy overrides applied to all browsers in this pool. + Keys are Chrome enterprise policy names; values must match their expected types. + Blocked: kernel-managed policies (extensions, proxy, CDP/automation). See + https://chromeenterprise.google/policies/ The serialized JSON payload is capped + at 5 MiB. + + extensions: List of browser extensions to load into the session. Provide each by id or name. + + fill_rate_per_minute: Percentage of the pool to fill per minute. Defaults to 25. The cap is 25 for + most organizations but can be raised per-organization, so only the lower bound + is enforced here. + + headless: If true, launches the browser using a headless image. Defaults to false. + + kiosk_mode: If true, launches the browser in kiosk mode to hide address bar and tabs in live + view. Defaults to false. + + name: Optional name for the browser pool. Must be unique within the project. + + profile: Profile configuration for browsers in a pool. Provide either id or name. + Profiles must be created beforehand. Unlike single browser sessions, pools load + the profile read-only and never persist changes back to it, so save_changes is + omitted here. Any save_changes value sent on a pool profile is silently ignored + rather than rejected. + + proxy_id: Optional proxy to associate to the browser session. Must reference a proxy in + the same project as the browser session. + + refresh_on_profile_update: When true, flush idle browsers when the profile the pool uses is updated, so + pool browsers pick up the latest profile data. When a profile is provided during + creation, this defaults to true. Requires a profile to be set on the pool. + + start_url: Optional URL to navigate to when a new browser is warmed into the pool. + Best-effort: failures to navigate do not fail pool fill. Only applied to + newly-warmed browsers; browsers reused via release/acquire keep whatever URL the + previous lease left them on. Accepts any URL Chromium can resolve, including + chrome:// pages. + + stealth: If true, launches the browser in stealth mode to reduce detection by anti-bot + mechanisms. Defaults to false. + + telemetry: Telemetry configuration applied to browsers warmed into this pool. Set enabled + to true to start capture using the default set, or provide browser category + settings. If omitted, null, set to an empty object ({}), set to enabled: false + without browser category settings, or all four CDP categories are explicitly + disabled, no telemetry is configured on the pool. Only applied to newly-warmed + browsers. + + timeout_seconds: Default idle timeout in seconds for browsers acquired from this pool before they + are destroyed. Defaults to 600 seconds. Minimum 10, maximum 259200 (72 hours). + + viewport: Initial browser window size in pixels with optional refresh rate. If omitted, + image defaults apply (1920x1080@25). For GPU images, the default is + 1920x1080@60. Arbitrary viewport dimensions and refresh rates are accepted. + Known-good presets include: 2560x1440@10, 1920x1080@25, 1920x1200@25, + 1440x900@25, 1280x800@60, 1024x768@60, 1200x800@60, 768x1024@60, 390x844@60. For + GPU images, recommended presets use one of these resolutions with refresh rates + 60, 30, 25, or 10: 800x600, 960x720, 1024x576, 1024x768, 1152x648, 1200x800, + 1280x720, 1368x768, 1440x900, 1600x900, 1920x1080, 1920x1200, 390x844, 360x250, + 768x1024, 800x1600. Viewports outside this list may exhibit unstable live view + or recording behavior. If refresh_rate is not provided, it will be automatically + determined based on the resolution (higher resolutions use lower refresh rates + to keep bandwidth reasonable). + + 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( + "/browser_pools", + body=await async_maybe_transform( + { + "size": size, + "chrome_policy": chrome_policy, + "extensions": extensions, + "fill_rate_per_minute": fill_rate_per_minute, + "headless": headless, + "kiosk_mode": kiosk_mode, + "name": name, + "profile": profile, + "proxy_id": proxy_id, + "refresh_on_profile_update": refresh_on_profile_update, + "start_url": start_url, + "stealth": stealth, + "telemetry": telemetry, + "timeout_seconds": timeout_seconds, + "viewport": viewport, + }, + browser_pool_create_params.BrowserPoolCreateParams, + ), + options=make_request_options( + extra_headers=extra_headers, extra_query=extra_query, extra_body=extra_body, timeout=timeout + ), + cast_to=BrowserPool, + ) + + async def retrieve( + self, + id_or_name: 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, + ) -> BrowserPool: + """ + Retrieve details for a single browser pool by its ID or name. + + 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_or_name: + raise ValueError(f"Expected a non-empty value for `id_or_name` but received {id_or_name!r}") + return await self._get( + path_template("/browser_pools/{id_or_name}", id_or_name=id_or_name), + options=make_request_options( + extra_headers=extra_headers, extra_query=extra_query, extra_body=extra_body, timeout=timeout + ), + cast_to=BrowserPool, + ) + + async def update( + self, + id_or_name: str, + *, + chrome_policy: Dict[str, object] | Omit = omit, + discard_all_idle: bool | Omit = omit, + extensions: Iterable[BrowserExtension] | Omit = omit, + fill_rate_per_minute: int | Omit = omit, + headless: bool | Omit = omit, + kiosk_mode: bool | Omit = omit, + name: str | Omit = omit, + profile: browser_pool_update_params.Profile | Omit = omit, + proxy_id: str | Omit = omit, + refresh_on_profile_update: bool | Omit = omit, + size: int | Omit = omit, + start_url: str | Omit = omit, + stealth: bool | Omit = omit, + telemetry: Optional[browser_pool_update_params.Telemetry] | Omit = omit, + timeout_seconds: int | Omit = omit, + viewport: BrowserViewport | 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, + ) -> BrowserPool: + """Updates the configuration used to create browsers in the pool. + + As with creation, + save_changes on the pool profile is ignored (not rejected); pooled browsers + never persist changes back to the profile. To clear the profile reference, send + `profile: { "id": "" }`. Clearing the profile also disables + `refresh_on_profile_update`. + + Args: + chrome_policy: If provided, replaces the custom Chrome enterprise policy overrides applied to + all browsers in this pool. Empty object clears any previously-set policy. Keys + are Chrome enterprise policy names; values must match their expected types. + Blocked: kernel-managed policies (extensions, proxy, CDP/automation). See + https://chromeenterprise.google/policies/ The serialized JSON payload is capped + at 5 MiB. + + discard_all_idle: Whether to discard all idle browsers and rebuild them immediately with the new + configuration. Defaults to false. Only browsers that are idle when the update + runs are rebuilt. A browser that is in use during the update keeps its original + configuration, and if it is later released with `reuse: true` it returns to the + pool with that stale configuration until it is discarded (by this flag on a + later update, or by flushing the pool). + + extensions: If provided, replaces the extension list. Empty array clears all + previously-selected extensions. Omit this field to leave extensions unchanged. + + fill_rate_per_minute: If provided, replaces the percentage of the pool to fill per minute. The cap is + 25 for most organizations but can be raised per-organization, so only the lower + bound is enforced here. + + headless: If provided, replaces whether browsers launch using a headless image. + + kiosk_mode: If provided, replaces whether browsers launch in kiosk mode. + + name: If provided, replaces the pool name. Empty string is a no-op; the pool name + cannot be cleared or reset to empty once assigned. + + profile: Profile configuration for browsers in a pool. Provide either id or name. + Profiles must be created beforehand. Unlike single browser sessions, pools load + the profile read-only and never persist changes back to it, so save_changes is + omitted here. Any save_changes value sent on a pool profile is silently ignored + rather than rejected. + + proxy_id: Empty string clears the previously-selected proxy. Omit this field to leave the + proxy unchanged. + + refresh_on_profile_update: If provided, replaces whether idle browsers are flushed when the profile the + pool uses is updated. When the pool's profile reference is changed (including + newly attached) and this field is omitted, it defaults to true. Re-sending the + same profile reference leaves this setting unchanged. Clearing the profile also + disables this setting. Requires a profile to be set on the pool. + + size: If provided, replaces the number of browsers to maintain in the pool. The + maximum size is determined by your organization's pooled sessions limit (the sum + of all pool sizes cannot exceed your limit). + + start_url: If provided, replaces the URL to navigate to when a new browser is warmed into + the pool. Empty string clears the previously-set URL. Omit this field to leave + it unchanged. + + stealth: If provided, replaces whether browsers launch in stealth mode. + + telemetry: If provided, updates the pool's telemetry configuration. Omit, set to null, or + set to an empty object ({}) to leave the existing configuration unchanged. Set + enabled to true to enable capture using the default set. Set enabled to false to + clear the pool's telemetry. Provide browser category settings for per-category + updates, merged onto the pool's current configuration. Only applied to browsers + warmed after the update; browsers already in the pool keep their configuration + until discarded. + + timeout_seconds: If provided, replaces the default idle timeout in seconds for browsers acquired + from this pool before they are destroyed. Minimum 10, maximum 259200 (72 hours). + + viewport: Initial browser window size in pixels with optional refresh rate. If omitted, + image defaults apply (1920x1080@25). For GPU images, the default is + 1920x1080@60. Arbitrary viewport dimensions and refresh rates are accepted. + Known-good presets include: 2560x1440@10, 1920x1080@25, 1920x1200@25, + 1440x900@25, 1280x800@60, 1024x768@60, 1200x800@60, 768x1024@60, 390x844@60. For + GPU images, recommended presets use one of these resolutions with refresh rates + 60, 30, 25, or 10: 800x600, 960x720, 1024x576, 1024x768, 1152x648, 1200x800, + 1280x720, 1368x768, 1440x900, 1600x900, 1920x1080, 1920x1200, 390x844, 360x250, + 768x1024, 800x1600. Viewports outside this list may exhibit unstable live view + or recording behavior. If refresh_rate is not provided, it will be automatically + determined based on the resolution (higher resolutions use lower refresh rates + to keep bandwidth reasonable). + + 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_or_name: + raise ValueError(f"Expected a non-empty value for `id_or_name` but received {id_or_name!r}") + return await self._patch( + path_template("/browser_pools/{id_or_name}", id_or_name=id_or_name), + body=await async_maybe_transform( + { + "chrome_policy": chrome_policy, + "discard_all_idle": discard_all_idle, + "extensions": extensions, + "fill_rate_per_minute": fill_rate_per_minute, + "headless": headless, + "kiosk_mode": kiosk_mode, + "name": name, + "profile": profile, + "proxy_id": proxy_id, + "refresh_on_profile_update": refresh_on_profile_update, + "size": size, + "start_url": start_url, + "stealth": stealth, + "telemetry": telemetry, + "timeout_seconds": timeout_seconds, + "viewport": viewport, + }, + browser_pool_update_params.BrowserPoolUpdateParams, + ), + options=make_request_options( + extra_headers=extra_headers, extra_query=extra_query, extra_body=extra_body, timeout=timeout + ), + cast_to=BrowserPool, + ) + + def list( + self, + *, + limit: int | Omit = omit, + name: str | Omit = omit, + offset: int | Omit = omit, + query: 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, + ) -> AsyncPaginator[BrowserPool, AsyncOffsetPagination[BrowserPool]]: + """ + List browser pools in the resolved project. + + Args: + limit: Limit the number of browser pools to return. + + name: Exact-match filter on browser pool name using the database collation. In + production, matching is case- and accent-insensitive. During the default-project + migration, unscoped requests prefer a concrete default-project browser pool over + a legacy unscoped browser pool with the same name. + + offset: Offset the number of browser pools to return. + + query: Case-insensitive substring match against browser pool name. IDs match by exact + value. + + 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_api_list( + "/browser_pools", + page=AsyncOffsetPagination[BrowserPool], + options=make_request_options( + extra_headers=extra_headers, + extra_query=extra_query, + extra_body=extra_body, + timeout=timeout, + query=maybe_transform( + { + "limit": limit, + "name": name, + "offset": offset, + "query": query, + }, + browser_pool_list_params.BrowserPoolListParams, + ), + ), + model=BrowserPool, + ) + + async def delete( + self, + id_or_name: str, + *, + force: 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, + ) -> None: + """Delete a browser pool and all browsers in it. + + By default, deletion is blocked if + browsers are currently leased. Use force=true to terminate leased browsers. + + Args: + force: If true, force delete even if browsers are currently leased. Leased browsers + will be terminated. + + 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_or_name: + raise ValueError(f"Expected a non-empty value for `id_or_name` but received {id_or_name!r}") + extra_headers = {"Accept": "*/*", **(extra_headers or {})} + return await self._delete( + path_template("/browser_pools/{id_or_name}", id_or_name=id_or_name), + body=await async_maybe_transform({"force": force}, browser_pool_delete_params.BrowserPoolDeleteParams), + options=make_request_options( + extra_headers=extra_headers, extra_query=extra_query, extra_body=extra_body, timeout=timeout + ), + cast_to=NoneType, + ) + + async def acquire( + self, + id_or_name: str, + *, + acquire_timeout_seconds: int | Omit = omit, + name: str | Omit = omit, + start_url: str | Omit = omit, + tags: TagsParam | Omit = omit, + telemetry: Optional[browser_pool_acquire_params.Telemetry] | 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, + ) -> BrowserPoolAcquireResponse: + """Long-polling endpoint to acquire a browser from the pool. + + Returns immediately + when a browser is available, or returns 204 No Content when the poll times out. + The client should retry the request to continue waiting for a browser. The + acquired browser will use the pool's timeout_seconds for its idle timeout. + + Args: + acquire_timeout_seconds: Maximum number of seconds to wait for a browser to be available. Defaults to the + calculated time it would take to fill the pool at the currently configured fill + rate. + + name: Optional human-readable name for the acquired browser session, used to find it + later in the dashboard. Must be unique among active sessions within the pool's + project. Applies to this lease only and is cleared when the browser is released + back to the pool. + + start_url: Optional URL to navigate the acquired browser to. Overrides the pool's start_url + for this acquire only. Best-effort: failures to navigate do not fail the + acquire. + + tags: Optional user-defined key-value tags for the acquired browser session, used to + find and group sessions later. Applies to this lease only and are cleared when + the browser is released back to the pool. Up to 50 pairs. + + telemetry: Telemetry override for the acquired browser, applied to this lease only. Merges + onto the browser's current (pool-inherited) telemetry using the same + per-category semantics as PATCH /browsers: provided categories override the + current configuration, omitted categories are inherited. Set enabled to true to + resolve the config fresh from the default set, or enabled to false to stop + capture. When the browser is released back to the pool with reuse, its telemetry + is reset to the pool's baseline, so the override does not carry over to the next + lease. + + 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_or_name: + raise ValueError(f"Expected a non-empty value for `id_or_name` but received {id_or_name!r}") + return await self._post( + path_template("/browser_pools/{id_or_name}/acquire", id_or_name=id_or_name), + body=await async_maybe_transform( + { + "acquire_timeout_seconds": acquire_timeout_seconds, + "name": name, + "start_url": start_url, + "tags": tags, + "telemetry": telemetry, + }, + browser_pool_acquire_params.BrowserPoolAcquireParams, + ), + options=make_request_options( + extra_headers=extra_headers, extra_query=extra_query, extra_body=extra_body, timeout=timeout + ), + cast_to=BrowserPoolAcquireResponse, + ) + + async def flush( + self, + id_or_name: 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, + ) -> None: + """ + Destroys all idle browsers in the pool; leased browsers are not affected. + + 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_or_name: + raise ValueError(f"Expected a non-empty value for `id_or_name` but received {id_or_name!r}") + extra_headers = {"Accept": "*/*", **(extra_headers or {})} + return await self._post( + path_template("/browser_pools/{id_or_name}/flush", id_or_name=id_or_name), + options=make_request_options( + extra_headers=extra_headers, extra_query=extra_query, extra_body=extra_body, timeout=timeout + ), + cast_to=NoneType, + ) + + async def release( + self, + id_or_name: str, + *, + session_id: str, + reuse: 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, + ) -> None: + """ + Release a browser back to the pool, optionally recreating the browser instance. + + Args: + session_id: Browser session ID to release back to the pool + + reuse: Whether to reuse the browser instance or destroy it and create a new one. + Defaults to true. A reused browser keeps the configuration it was created with, + so it does not pick up pool configuration changes made while it was in use. + Release with `reuse: false`, or flush the pool afterward, to rebuild it with the + current configuration. + + 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_or_name: + raise ValueError(f"Expected a non-empty value for `id_or_name` but received {id_or_name!r}") + extra_headers = {"Accept": "*/*", **(extra_headers or {})} + return await self._post( + path_template("/browser_pools/{id_or_name}/release", id_or_name=id_or_name), + body=await async_maybe_transform( + { + "session_id": session_id, + "reuse": reuse, + }, + browser_pool_release_params.BrowserPoolReleaseParams, + ), + options=make_request_options( + extra_headers=extra_headers, extra_query=extra_query, extra_body=extra_body, timeout=timeout + ), + cast_to=NoneType, + ) + + +class BrowserPoolsResourceWithRawResponse: + def __init__(self, browser_pools: BrowserPoolsResource) -> None: + self._browser_pools = browser_pools + + self.create = to_raw_response_wrapper( + browser_pools.create, + ) + self.retrieve = to_raw_response_wrapper( + browser_pools.retrieve, + ) + self.update = to_raw_response_wrapper( + browser_pools.update, + ) + self.list = to_raw_response_wrapper( + browser_pools.list, + ) + self.delete = to_raw_response_wrapper( + browser_pools.delete, + ) + self.acquire = to_raw_response_wrapper( + browser_pools.acquire, + ) + self.flush = to_raw_response_wrapper( + browser_pools.flush, + ) + self.release = to_raw_response_wrapper( + browser_pools.release, + ) + + +class AsyncBrowserPoolsResourceWithRawResponse: + def __init__(self, browser_pools: AsyncBrowserPoolsResource) -> None: + self._browser_pools = browser_pools + + self.create = async_to_raw_response_wrapper( + browser_pools.create, + ) + self.retrieve = async_to_raw_response_wrapper( + browser_pools.retrieve, + ) + self.update = async_to_raw_response_wrapper( + browser_pools.update, + ) + self.list = async_to_raw_response_wrapper( + browser_pools.list, + ) + self.delete = async_to_raw_response_wrapper( + browser_pools.delete, + ) + self.acquire = async_to_raw_response_wrapper( + browser_pools.acquire, + ) + self.flush = async_to_raw_response_wrapper( + browser_pools.flush, + ) + self.release = async_to_raw_response_wrapper( + browser_pools.release, + ) + + +class BrowserPoolsResourceWithStreamingResponse: + def __init__(self, browser_pools: BrowserPoolsResource) -> None: + self._browser_pools = browser_pools + + self.create = to_streamed_response_wrapper( + browser_pools.create, + ) + self.retrieve = to_streamed_response_wrapper( + browser_pools.retrieve, + ) + self.update = to_streamed_response_wrapper( + browser_pools.update, + ) + self.list = to_streamed_response_wrapper( + browser_pools.list, + ) + self.delete = to_streamed_response_wrapper( + browser_pools.delete, + ) + self.acquire = to_streamed_response_wrapper( + browser_pools.acquire, + ) + self.flush = to_streamed_response_wrapper( + browser_pools.flush, + ) + self.release = to_streamed_response_wrapper( + browser_pools.release, + ) + + +class AsyncBrowserPoolsResourceWithStreamingResponse: + def __init__(self, browser_pools: AsyncBrowserPoolsResource) -> None: + self._browser_pools = browser_pools + + self.create = async_to_streamed_response_wrapper( + browser_pools.create, + ) + self.retrieve = async_to_streamed_response_wrapper( + browser_pools.retrieve, + ) + self.update = async_to_streamed_response_wrapper( + browser_pools.update, + ) + self.list = async_to_streamed_response_wrapper( + browser_pools.list, + ) + self.delete = async_to_streamed_response_wrapper( + browser_pools.delete, + ) + self.acquire = async_to_streamed_response_wrapper( + browser_pools.acquire, + ) + self.flush = async_to_streamed_response_wrapper( + browser_pools.flush, + ) + self.release = async_to_streamed_response_wrapper( + browser_pools.release, + ) diff --git a/src/kernel/resources/browsers/__init__.py b/src/kernel/resources/browsers/__init__.py new file mode 100644 index 00000000..928d4553 --- /dev/null +++ b/src/kernel/resources/browsers/__init__.py @@ -0,0 +1,117 @@ +# File generated from our OpenAPI spec by Stainless. See CONTRIBUTING.md for details. + +from .fs import ( + FsResource, + AsyncFsResource, + FsResourceWithRawResponse, + AsyncFsResourceWithRawResponse, + FsResourceWithStreamingResponse, + AsyncFsResourceWithStreamingResponse, +) +from .logs import ( + LogsResource, + AsyncLogsResource, + LogsResourceWithRawResponse, + AsyncLogsResourceWithRawResponse, + LogsResourceWithStreamingResponse, + AsyncLogsResourceWithStreamingResponse, +) +from .process import ( + ProcessResource, + AsyncProcessResource, + ProcessResourceWithRawResponse, + AsyncProcessResourceWithRawResponse, + ProcessResourceWithStreamingResponse, + AsyncProcessResourceWithStreamingResponse, +) +from .replays import ( + ReplaysResource, + AsyncReplaysResource, + ReplaysResourceWithRawResponse, + AsyncReplaysResourceWithRawResponse, + ReplaysResourceWithStreamingResponse, + AsyncReplaysResourceWithStreamingResponse, +) +from .browsers import ( + BrowsersResource, + AsyncBrowsersResource, + BrowsersResourceWithRawResponse, + AsyncBrowsersResourceWithRawResponse, + BrowsersResourceWithStreamingResponse, + AsyncBrowsersResourceWithStreamingResponse, +) +from .computer import ( + ComputerResource, + AsyncComputerResource, + ComputerResourceWithRawResponse, + AsyncComputerResourceWithRawResponse, + ComputerResourceWithStreamingResponse, + AsyncComputerResourceWithStreamingResponse, +) +from .telemetry import ( + TelemetryResource, + AsyncTelemetryResource, + TelemetryResourceWithRawResponse, + AsyncTelemetryResourceWithRawResponse, + TelemetryResourceWithStreamingResponse, + AsyncTelemetryResourceWithStreamingResponse, +) +from .playwright import ( + PlaywrightResource, + AsyncPlaywrightResource, + PlaywrightResourceWithRawResponse, + AsyncPlaywrightResourceWithRawResponse, + PlaywrightResourceWithStreamingResponse, + AsyncPlaywrightResourceWithStreamingResponse, +) + +__all__ = [ + "TelemetryResource", + "AsyncTelemetryResource", + "TelemetryResourceWithRawResponse", + "AsyncTelemetryResourceWithRawResponse", + "TelemetryResourceWithStreamingResponse", + "AsyncTelemetryResourceWithStreamingResponse", + "ReplaysResource", + "AsyncReplaysResource", + "ReplaysResourceWithRawResponse", + "AsyncReplaysResourceWithRawResponse", + "ReplaysResourceWithStreamingResponse", + "AsyncReplaysResourceWithStreamingResponse", + "FsResource", + "AsyncFsResource", + "FsResourceWithRawResponse", + "AsyncFsResourceWithRawResponse", + "FsResourceWithStreamingResponse", + "AsyncFsResourceWithStreamingResponse", + "ProcessResource", + "AsyncProcessResource", + "ProcessResourceWithRawResponse", + "AsyncProcessResourceWithRawResponse", + "ProcessResourceWithStreamingResponse", + "AsyncProcessResourceWithStreamingResponse", + "LogsResource", + "AsyncLogsResource", + "LogsResourceWithRawResponse", + "AsyncLogsResourceWithRawResponse", + "LogsResourceWithStreamingResponse", + "AsyncLogsResourceWithStreamingResponse", + "ComputerResource", + "AsyncComputerResource", + "ComputerResourceWithRawResponse", + "AsyncComputerResourceWithRawResponse", + "ComputerResourceWithStreamingResponse", + "AsyncComputerResourceWithStreamingResponse", + "PlaywrightResource", + "AsyncPlaywrightResource", + "PlaywrightResourceWithRawResponse", + "AsyncPlaywrightResourceWithRawResponse", + "PlaywrightResourceWithStreamingResponse", + "AsyncPlaywrightResourceWithStreamingResponse", + "BrowsersResource", + "AsyncBrowsersResource", + "BrowsersResourceWithRawResponse", + "AsyncBrowsersResourceWithRawResponse", + "BrowsersResourceWithStreamingResponse", + "AsyncBrowsersResourceWithStreamingResponse", +] diff --git a/src/kernel/resources/browsers/browsers.py b/src/kernel/resources/browsers/browsers.py new file mode 100644 index 00000000..5f6b71bc --- /dev/null +++ b/src/kernel/resources/browsers/browsers.py @@ -0,0 +1,1385 @@ +# File generated from our OpenAPI spec by Stainless. See CONTRIBUTING.md for details. + +from __future__ import annotations + +from typing import Dict, Mapping, Iterable, Optional, cast +from typing_extensions import Literal + +import httpx + +from .logs import ( + LogsResource, + AsyncLogsResource, + LogsResourceWithRawResponse, + AsyncLogsResourceWithRawResponse, + LogsResourceWithStreamingResponse, + AsyncLogsResourceWithStreamingResponse, +) +from .fs.fs import ( + FsResource, + AsyncFsResource, + FsResourceWithRawResponse, + AsyncFsResourceWithRawResponse, + FsResourceWithStreamingResponse, + AsyncFsResourceWithStreamingResponse, +) +from ...types import ( + browser_curl_params, + browser_list_params, + browser_create_params, + browser_update_params, + browser_retrieve_params, + browser_load_extensions_params, +) +from .process import ( + ProcessResource, + AsyncProcessResource, + ProcessResourceWithRawResponse, + AsyncProcessResourceWithRawResponse, + ProcessResourceWithStreamingResponse, + AsyncProcessResourceWithStreamingResponse, +) +from .replays import ( + ReplaysResource, + AsyncReplaysResource, + ReplaysResourceWithRawResponse, + AsyncReplaysResourceWithRawResponse, + ReplaysResourceWithStreamingResponse, + AsyncReplaysResourceWithStreamingResponse, +) +from ..._files import deepcopy_with_paths +from ..._types import Body, Omit, Query, Headers, NoneType, NotGiven, omit, not_given +from ..._utils import extract_files, path_template, maybe_transform, async_maybe_transform +from .computer import ( + ComputerResource, + AsyncComputerResource, + ComputerResourceWithRawResponse, + AsyncComputerResourceWithRawResponse, + ComputerResourceWithStreamingResponse, + AsyncComputerResourceWithStreamingResponse, +) +from ..._compat import cached_property +from .telemetry import ( + TelemetryResource, + AsyncTelemetryResource, + TelemetryResourceWithRawResponse, + AsyncTelemetryResourceWithRawResponse, + TelemetryResourceWithStreamingResponse, + AsyncTelemetryResourceWithStreamingResponse, +) +from .playwright import ( + PlaywrightResource, + AsyncPlaywrightResource, + PlaywrightResourceWithRawResponse, + AsyncPlaywrightResourceWithRawResponse, + PlaywrightResourceWithStreamingResponse, + AsyncPlaywrightResourceWithStreamingResponse, +) +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 ...pagination import SyncOffsetPagination, AsyncOffsetPagination +from ..._base_client import AsyncPaginator, make_request_options +from ...types.tags_param import TagsParam +from ...types.browser_curl_response import BrowserCurlResponse +from ...types.browser_list_response import BrowserListResponse +from ...types.browser_create_response import BrowserCreateResponse +from ...types.browser_update_response import BrowserUpdateResponse +from ...types.browser_retrieve_response import BrowserRetrieveResponse +from ...types.shared_params.browser_profile import BrowserProfile +from ...types.shared_params.browser_viewport import BrowserViewport +from ...types.shared_params.browser_extension import BrowserExtension + +__all__ = ["BrowsersResource", "AsyncBrowsersResource"] + + +class BrowsersResource(SyncAPIResource): + """Create and manage browser sessions.""" + + @cached_property + def telemetry(self) -> TelemetryResource: + """Stream live telemetry events from a browser session.""" + return TelemetryResource(self._client) + + @cached_property + def replays(self) -> ReplaysResource: + """Record and manage browser session video replays.""" + return ReplaysResource(self._client) + + @cached_property + def fs(self) -> FsResource: + """Read, write, and manage files on the browser instance.""" + return FsResource(self._client) + + @cached_property + def process(self) -> ProcessResource: + """Execute and manage processes on the browser instance.""" + return ProcessResource(self._client) + + @cached_property + def logs(self) -> LogsResource: + """Stream logs from the browser instance.""" + return LogsResource(self._client) + + @cached_property + def computer(self) -> ComputerResource: + """Control mouse, keyboard, and screen on the browser instance.""" + return ComputerResource(self._client) + + @cached_property + def playwright(self) -> PlaywrightResource: + """Execute Playwright code against the browser instance.""" + return PlaywrightResource(self._client) + + @cached_property + def with_raw_response(self) -> BrowsersResourceWithRawResponse: + """ + 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/kernel/kernel-python-sdk#accessing-raw-response-data-eg-headers + """ + return BrowsersResourceWithRawResponse(self) + + @cached_property + def with_streaming_response(self) -> BrowsersResourceWithStreamingResponse: + """ + An alternative to `.with_raw_response` that doesn't eagerly read the response body. + + For more information, see https://www.github.com/kernel/kernel-python-sdk#with_streaming_response + """ + return BrowsersResourceWithStreamingResponse(self) + + def create( + self, + *, + chrome_policy: Dict[str, object] | Omit = omit, + extensions: Iterable[BrowserExtension] | Omit = omit, + gpu: bool | Omit = omit, + headless: bool | Omit = omit, + invocation_id: str | Omit = omit, + kiosk_mode: bool | Omit = omit, + name: str | Omit = omit, + profile: BrowserProfile | Omit = omit, + proxy_id: str | Omit = omit, + start_url: str | Omit = omit, + stealth: bool | Omit = omit, + tags: TagsParam | Omit = omit, + telemetry: Optional[browser_create_params.Telemetry] | Omit = omit, + timeout_seconds: int | Omit = omit, + viewport: BrowserViewport | 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, + ) -> BrowserCreateResponse: + """ + Create a new browser session from within an action. + + Args: + chrome_policy: Custom Chrome enterprise policy overrides applied to this browser session. Keys + are Chrome enterprise policy names; values must match their expected types. + Blocked: kernel-managed policies (extensions, proxy, CDP/automation). See + https://chromeenterprise.google/policies/ + + extensions: List of browser extensions to load into the session. Provide each by id or name. + + gpu: If true, enables GPU acceleration for the browser session. Requires Start-Up or + Enterprise plan and headless=false. + + headless: If true, launches the browser using a headless image (no VNC/GUI). Defaults to + false. + + invocation_id: action invocation ID + + kiosk_mode: If true, launches the browser in kiosk mode to hide address bar and tabs in live + view. + + name: Optional human-readable name for the browser session, used to find it later in + the dashboard. Must be unique among active sessions within the project. Can be + changed later via PATCH /browsers/{id_or_name}. + + profile: Profile selection for the browser session. Provide either id or name. If + specified, the matching profile will be loaded into the browser session. + Profiles must be created beforehand. + + proxy_id: Optional proxy to associate to the browser session. Must reference a proxy in + the same project as the browser session. + + start_url: Optional URL to open when the browser session is created. Navigation is + best-effort, so navigation failures do not prevent the session from being + created. + + stealth: If true, launches the browser in stealth mode to reduce detection by anti-bot + mechanisms. + + tags: Optional user-defined key-value tags for the browser session, used to find and + group sessions later. Can be changed later via PATCH /browsers/{id_or_name}. Up + to 50 pairs. + + telemetry: Telemetry configuration for the browser session. Set enabled to true to start + capture using VM defaults, or provide browser category settings. If omitted, + null, set to an empty object ({}), set to enabled: false without browser + category settings, or all four categories are explicitly disabled, capture is + not started. + + timeout_seconds: The number of seconds of inactivity before the browser session is terminated. + Activity includes CDP connections and live view connections. Defaults to 60 + seconds. Minimum allowed is 10 seconds. Maximum allowed is 259200 (72 hours). We + check for inactivity every 5 seconds, so the actual timeout behavior you will + see is +/- 5 seconds around the specified value. + + viewport: Initial browser window size in pixels with optional refresh rate. If omitted, + image defaults apply (1920x1080@25). For GPU images, the default is + 1920x1080@60. Arbitrary viewport dimensions and refresh rates are accepted. + Known-good presets include: 2560x1440@10, 1920x1080@25, 1920x1200@25, + 1440x900@25, 1280x800@60, 1024x768@60, 1200x800@60, 768x1024@60, 390x844@60. For + GPU images, recommended presets use one of these resolutions with refresh rates + 60, 30, 25, or 10: 800x600, 960x720, 1024x576, 1024x768, 1152x648, 1200x800, + 1280x720, 1368x768, 1440x900, 1600x900, 1920x1080, 1920x1200, 390x844, 360x250, + 768x1024, 800x1600. Viewports outside this list may exhibit unstable live view + or recording behavior. If refresh_rate is not provided, it will be automatically + determined based on the resolution (higher resolutions use lower refresh rates + to keep bandwidth reasonable). + + 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( + "/browsers", + body=maybe_transform( + { + "chrome_policy": chrome_policy, + "extensions": extensions, + "gpu": gpu, + "headless": headless, + "invocation_id": invocation_id, + "kiosk_mode": kiosk_mode, + "name": name, + "profile": profile, + "proxy_id": proxy_id, + "start_url": start_url, + "stealth": stealth, + "tags": tags, + "telemetry": telemetry, + "timeout_seconds": timeout_seconds, + "viewport": viewport, + }, + browser_create_params.BrowserCreateParams, + ), + options=make_request_options( + extra_headers=extra_headers, extra_query=extra_query, extra_body=extra_body, timeout=timeout + ), + cast_to=BrowserCreateResponse, + ) + + def retrieve( + self, + id_or_name: str, + *, + include_deleted: 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, + ) -> BrowserRetrieveResponse: + """ + Get information about a browser session. + + Args: + include_deleted: When true, includes soft-deleted browser sessions in the lookup. + + 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_or_name: + raise ValueError(f"Expected a non-empty value for `id_or_name` but received {id_or_name!r}") + return self._get( + path_template("/browsers/{id_or_name}", id_or_name=id_or_name), + options=make_request_options( + extra_headers=extra_headers, + extra_query=extra_query, + extra_body=extra_body, + timeout=timeout, + query=maybe_transform( + {"include_deleted": include_deleted}, browser_retrieve_params.BrowserRetrieveParams + ), + ), + cast_to=BrowserRetrieveResponse, + ) + + def update( + self, + id_or_name: str, + *, + disable_default_proxy: bool | Omit = omit, + name: Optional[str] | Omit = omit, + profile: BrowserProfile | Omit = omit, + proxy_id: Optional[str] | Omit = omit, + tags: Optional[TagsParam] | Omit = omit, + telemetry: Optional[browser_update_params.Telemetry] | Omit = omit, + viewport: browser_update_params.Viewport | 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, + ) -> BrowserUpdateResponse: + """ + Update a browser session. + + Args: + disable_default_proxy: If true, stealth browsers connect directly instead of using the default stealth + proxy. + + name: Human-readable name for the browser session. Omit to leave unchanged, set to an + empty string to clear the name. When set, must be unique among active sessions + within the project. + + profile: Profile to load into the browser session. Only allowed if the session does not + already have a profile loaded. + + proxy_id: ID of the proxy to use. Omit to leave unchanged, set to empty string to remove + proxy. + + tags: User-defined key-value tags for the browser session. Omit to leave unchanged. + Provide a map to replace the entire tag set (full replace, not a merge). Set to + an empty object ({}) to clear all tags. Up to 50 pairs. + + telemetry: Telemetry configuration. Omit, set to null, or set to an empty object ({}) to + leave the existing configuration unchanged. Set enabled to true to enable + capture using VM defaults. Set enabled to false to stop capture. Provide browser + category settings for per-category updates. Explicitly disabling all four + categories also stops capture. + + viewport: Viewport configuration to apply to the browser session. + + 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_or_name: + raise ValueError(f"Expected a non-empty value for `id_or_name` but received {id_or_name!r}") + return self._patch( + path_template("/browsers/{id_or_name}", id_or_name=id_or_name), + body=maybe_transform( + { + "disable_default_proxy": disable_default_proxy, + "name": name, + "profile": profile, + "proxy_id": proxy_id, + "tags": tags, + "telemetry": telemetry, + "viewport": viewport, + }, + browser_update_params.BrowserUpdateParams, + ), + options=make_request_options( + extra_headers=extra_headers, extra_query=extra_query, extra_body=extra_body, timeout=timeout + ), + cast_to=BrowserUpdateResponse, + ) + + def list( + self, + *, + include_deleted: bool | Omit = omit, + limit: int | Omit = omit, + offset: int | Omit = omit, + query: str | Omit = omit, + status: Literal["active", "deleted", "all"] | Omit = omit, + tags: Dict[str, 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, + ) -> SyncOffsetPagination[BrowserListResponse]: + """List all browser sessions with pagination support. + + Use status parameter to + filter by session state. + + Args: + include_deleted: Deprecated: Use status=all instead. When true, includes soft-deleted browser + sessions in the results alongside active sessions. + + limit: Maximum number of results to return. Defaults to 20, maximum 100. + + offset: Number of results to skip. Defaults to 0. + + query: Search browsers by name, session ID, profile ID, proxy ID, or pool name. + + status: Filter sessions by status. "active" returns only active sessions (default), + "deleted" returns only soft-deleted sessions, "all" returns both. + + tags: Filter sessions by tag key-value pairs using deepObject style, e.g. + ?tags[team]=backend&tags[env]=staging. Multiple pairs are ANDed: a session must + match every supplied pair exactly. + + 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_api_list( + "/browsers", + page=SyncOffsetPagination[BrowserListResponse], + options=make_request_options( + extra_headers=extra_headers, + extra_query=extra_query, + extra_body=extra_body, + timeout=timeout, + query=maybe_transform( + { + "include_deleted": include_deleted, + "limit": limit, + "offset": offset, + "query": query, + "status": status, + "tags": tags, + }, + browser_list_params.BrowserListParams, + ), + ), + model=BrowserListResponse, + ) + + def curl( + self, + id: str, + *, + url: str, + body: str | Omit = omit, + headers: Dict[str, str] | Omit = omit, + method: Literal["GET", "HEAD", "POST", "PUT", "PATCH", "DELETE", "OPTIONS"] | Omit = omit, + response_encoding: Literal["utf8", "base64"] | Omit = omit, + timeout_ms: int | 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, + ) -> BrowserCurlResponse: + """ + Sends an HTTP request through Chrome's HTTP request stack, inheriting the + browser's TLS fingerprint, cookies, proxy configuration, and headers. Returns a + structured JSON response with status, headers, body, and timing. + + Args: + url: Target URL (must be http or https). + + body: Request body (for POST/PUT/PATCH). + + headers: Custom headers merged with browser defaults. + + method: HTTP method. + + response_encoding: Encoding for the response body. Use base64 for binary content. + + timeout_ms: Request timeout in milliseconds. + + 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( + path_template("/browsers/{id}/curl", id=id), + body=maybe_transform( + { + "url": url, + "body": body, + "headers": headers, + "method": method, + "response_encoding": response_encoding, + "timeout_ms": timeout_ms, + }, + browser_curl_params.BrowserCurlParams, + ), + options=make_request_options( + extra_headers=extra_headers, extra_query=extra_query, extra_body=extra_body, timeout=timeout + ), + cast_to=BrowserCurlResponse, + ) + + def delete_by_id( + self, + id_or_name: 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, + ) -> None: + """ + Delete a browser session by ID or name + + 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_or_name: + raise ValueError(f"Expected a non-empty value for `id_or_name` but received {id_or_name!r}") + extra_headers = {"Accept": "*/*", **(extra_headers or {})} + return self._delete( + path_template("/browsers/{id_or_name}", id_or_name=id_or_name), + options=make_request_options( + extra_headers=extra_headers, extra_query=extra_query, extra_body=extra_body, timeout=timeout + ), + cast_to=NoneType, + ) + + def load_extensions( + self, + id: str, + *, + extensions: Iterable[browser_load_extensions_params.Extension], + # 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, + ) -> None: + """ + Loads one or more unpacked extensions and restarts Chromium on the browser + instance. + + Args: + extensions: List of extensions to upload and activate + + 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}") + extra_headers = {"Accept": "*/*", **(extra_headers or {})} + body = deepcopy_with_paths({"extensions": extensions}, [["extensions", "", "zip_file"]]) + files = extract_files(cast(Mapping[str, object], body), paths=[["extensions", "", "zip_file"]]) + # It should be noted that the actual Content-Type header that will be + # sent to the server will contain a `boundary` parameter, e.g. + # multipart/form-data; boundary=---abc-- + extra_headers["Content-Type"] = "multipart/form-data" + return self._post( + path_template("/browsers/{id}/extensions", id=id), + body=maybe_transform(body, browser_load_extensions_params.BrowserLoadExtensionsParams), + files=files, + options=make_request_options( + extra_headers=extra_headers, extra_query=extra_query, extra_body=extra_body, timeout=timeout + ), + cast_to=NoneType, + ) + + +class AsyncBrowsersResource(AsyncAPIResource): + """Create and manage browser sessions.""" + + @cached_property + def telemetry(self) -> AsyncTelemetryResource: + """Stream live telemetry events from a browser session.""" + return AsyncTelemetryResource(self._client) + + @cached_property + def replays(self) -> AsyncReplaysResource: + """Record and manage browser session video replays.""" + return AsyncReplaysResource(self._client) + + @cached_property + def fs(self) -> AsyncFsResource: + """Read, write, and manage files on the browser instance.""" + return AsyncFsResource(self._client) + + @cached_property + def process(self) -> AsyncProcessResource: + """Execute and manage processes on the browser instance.""" + return AsyncProcessResource(self._client) + + @cached_property + def logs(self) -> AsyncLogsResource: + """Stream logs from the browser instance.""" + return AsyncLogsResource(self._client) + + @cached_property + def computer(self) -> AsyncComputerResource: + """Control mouse, keyboard, and screen on the browser instance.""" + return AsyncComputerResource(self._client) + + @cached_property + def playwright(self) -> AsyncPlaywrightResource: + """Execute Playwright code against the browser instance.""" + return AsyncPlaywrightResource(self._client) + + @cached_property + def with_raw_response(self) -> AsyncBrowsersResourceWithRawResponse: + """ + 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/kernel/kernel-python-sdk#accessing-raw-response-data-eg-headers + """ + return AsyncBrowsersResourceWithRawResponse(self) + + @cached_property + def with_streaming_response(self) -> AsyncBrowsersResourceWithStreamingResponse: + """ + An alternative to `.with_raw_response` that doesn't eagerly read the response body. + + For more information, see https://www.github.com/kernel/kernel-python-sdk#with_streaming_response + """ + return AsyncBrowsersResourceWithStreamingResponse(self) + + async def create( + self, + *, + chrome_policy: Dict[str, object] | Omit = omit, + extensions: Iterable[BrowserExtension] | Omit = omit, + gpu: bool | Omit = omit, + headless: bool | Omit = omit, + invocation_id: str | Omit = omit, + kiosk_mode: bool | Omit = omit, + name: str | Omit = omit, + profile: BrowserProfile | Omit = omit, + proxy_id: str | Omit = omit, + start_url: str | Omit = omit, + stealth: bool | Omit = omit, + tags: TagsParam | Omit = omit, + telemetry: Optional[browser_create_params.Telemetry] | Omit = omit, + timeout_seconds: int | Omit = omit, + viewport: BrowserViewport | 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, + ) -> BrowserCreateResponse: + """ + Create a new browser session from within an action. + + Args: + chrome_policy: Custom Chrome enterprise policy overrides applied to this browser session. Keys + are Chrome enterprise policy names; values must match their expected types. + Blocked: kernel-managed policies (extensions, proxy, CDP/automation). See + https://chromeenterprise.google/policies/ + + extensions: List of browser extensions to load into the session. Provide each by id or name. + + gpu: If true, enables GPU acceleration for the browser session. Requires Start-Up or + Enterprise plan and headless=false. + + headless: If true, launches the browser using a headless image (no VNC/GUI). Defaults to + false. + + invocation_id: action invocation ID + + kiosk_mode: If true, launches the browser in kiosk mode to hide address bar and tabs in live + view. + + name: Optional human-readable name for the browser session, used to find it later in + the dashboard. Must be unique among active sessions within the project. Can be + changed later via PATCH /browsers/{id_or_name}. + + profile: Profile selection for the browser session. Provide either id or name. If + specified, the matching profile will be loaded into the browser session. + Profiles must be created beforehand. + + proxy_id: Optional proxy to associate to the browser session. Must reference a proxy in + the same project as the browser session. + + start_url: Optional URL to open when the browser session is created. Navigation is + best-effort, so navigation failures do not prevent the session from being + created. + + stealth: If true, launches the browser in stealth mode to reduce detection by anti-bot + mechanisms. + + tags: Optional user-defined key-value tags for the browser session, used to find and + group sessions later. Can be changed later via PATCH /browsers/{id_or_name}. Up + to 50 pairs. + + telemetry: Telemetry configuration for the browser session. Set enabled to true to start + capture using VM defaults, or provide browser category settings. If omitted, + null, set to an empty object ({}), set to enabled: false without browser + category settings, or all four categories are explicitly disabled, capture is + not started. + + timeout_seconds: The number of seconds of inactivity before the browser session is terminated. + Activity includes CDP connections and live view connections. Defaults to 60 + seconds. Minimum allowed is 10 seconds. Maximum allowed is 259200 (72 hours). We + check for inactivity every 5 seconds, so the actual timeout behavior you will + see is +/- 5 seconds around the specified value. + + viewport: Initial browser window size in pixels with optional refresh rate. If omitted, + image defaults apply (1920x1080@25). For GPU images, the default is + 1920x1080@60. Arbitrary viewport dimensions and refresh rates are accepted. + Known-good presets include: 2560x1440@10, 1920x1080@25, 1920x1200@25, + 1440x900@25, 1280x800@60, 1024x768@60, 1200x800@60, 768x1024@60, 390x844@60. For + GPU images, recommended presets use one of these resolutions with refresh rates + 60, 30, 25, or 10: 800x600, 960x720, 1024x576, 1024x768, 1152x648, 1200x800, + 1280x720, 1368x768, 1440x900, 1600x900, 1920x1080, 1920x1200, 390x844, 360x250, + 768x1024, 800x1600. Viewports outside this list may exhibit unstable live view + or recording behavior. If refresh_rate is not provided, it will be automatically + determined based on the resolution (higher resolutions use lower refresh rates + to keep bandwidth reasonable). + + 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( + "/browsers", + body=await async_maybe_transform( + { + "chrome_policy": chrome_policy, + "extensions": extensions, + "gpu": gpu, + "headless": headless, + "invocation_id": invocation_id, + "kiosk_mode": kiosk_mode, + "name": name, + "profile": profile, + "proxy_id": proxy_id, + "start_url": start_url, + "stealth": stealth, + "tags": tags, + "telemetry": telemetry, + "timeout_seconds": timeout_seconds, + "viewport": viewport, + }, + browser_create_params.BrowserCreateParams, + ), + options=make_request_options( + extra_headers=extra_headers, extra_query=extra_query, extra_body=extra_body, timeout=timeout + ), + cast_to=BrowserCreateResponse, + ) + + async def retrieve( + self, + id_or_name: str, + *, + include_deleted: 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, + ) -> BrowserRetrieveResponse: + """ + Get information about a browser session. + + Args: + include_deleted: When true, includes soft-deleted browser sessions in the lookup. + + 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_or_name: + raise ValueError(f"Expected a non-empty value for `id_or_name` but received {id_or_name!r}") + return await self._get( + path_template("/browsers/{id_or_name}", id_or_name=id_or_name), + options=make_request_options( + extra_headers=extra_headers, + extra_query=extra_query, + extra_body=extra_body, + timeout=timeout, + query=await async_maybe_transform( + {"include_deleted": include_deleted}, browser_retrieve_params.BrowserRetrieveParams + ), + ), + cast_to=BrowserRetrieveResponse, + ) + + async def update( + self, + id_or_name: str, + *, + disable_default_proxy: bool | Omit = omit, + name: Optional[str] | Omit = omit, + profile: BrowserProfile | Omit = omit, + proxy_id: Optional[str] | Omit = omit, + tags: Optional[TagsParam] | Omit = omit, + telemetry: Optional[browser_update_params.Telemetry] | Omit = omit, + viewport: browser_update_params.Viewport | 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, + ) -> BrowserUpdateResponse: + """ + Update a browser session. + + Args: + disable_default_proxy: If true, stealth browsers connect directly instead of using the default stealth + proxy. + + name: Human-readable name for the browser session. Omit to leave unchanged, set to an + empty string to clear the name. When set, must be unique among active sessions + within the project. + + profile: Profile to load into the browser session. Only allowed if the session does not + already have a profile loaded. + + proxy_id: ID of the proxy to use. Omit to leave unchanged, set to empty string to remove + proxy. + + tags: User-defined key-value tags for the browser session. Omit to leave unchanged. + Provide a map to replace the entire tag set (full replace, not a merge). Set to + an empty object ({}) to clear all tags. Up to 50 pairs. + + telemetry: Telemetry configuration. Omit, set to null, or set to an empty object ({}) to + leave the existing configuration unchanged. Set enabled to true to enable + capture using VM defaults. Set enabled to false to stop capture. Provide browser + category settings for per-category updates. Explicitly disabling all four + categories also stops capture. + + viewport: Viewport configuration to apply to the browser session. + + 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_or_name: + raise ValueError(f"Expected a non-empty value for `id_or_name` but received {id_or_name!r}") + return await self._patch( + path_template("/browsers/{id_or_name}", id_or_name=id_or_name), + body=await async_maybe_transform( + { + "disable_default_proxy": disable_default_proxy, + "name": name, + "profile": profile, + "proxy_id": proxy_id, + "tags": tags, + "telemetry": telemetry, + "viewport": viewport, + }, + browser_update_params.BrowserUpdateParams, + ), + options=make_request_options( + extra_headers=extra_headers, extra_query=extra_query, extra_body=extra_body, timeout=timeout + ), + cast_to=BrowserUpdateResponse, + ) + + def list( + self, + *, + include_deleted: bool | Omit = omit, + limit: int | Omit = omit, + offset: int | Omit = omit, + query: str | Omit = omit, + status: Literal["active", "deleted", "all"] | Omit = omit, + tags: Dict[str, 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, + ) -> AsyncPaginator[BrowserListResponse, AsyncOffsetPagination[BrowserListResponse]]: + """List all browser sessions with pagination support. + + Use status parameter to + filter by session state. + + Args: + include_deleted: Deprecated: Use status=all instead. When true, includes soft-deleted browser + sessions in the results alongside active sessions. + + limit: Maximum number of results to return. Defaults to 20, maximum 100. + + offset: Number of results to skip. Defaults to 0. + + query: Search browsers by name, session ID, profile ID, proxy ID, or pool name. + + status: Filter sessions by status. "active" returns only active sessions (default), + "deleted" returns only soft-deleted sessions, "all" returns both. + + tags: Filter sessions by tag key-value pairs using deepObject style, e.g. + ?tags[team]=backend&tags[env]=staging. Multiple pairs are ANDed: a session must + match every supplied pair exactly. + + 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_api_list( + "/browsers", + page=AsyncOffsetPagination[BrowserListResponse], + options=make_request_options( + extra_headers=extra_headers, + extra_query=extra_query, + extra_body=extra_body, + timeout=timeout, + query=maybe_transform( + { + "include_deleted": include_deleted, + "limit": limit, + "offset": offset, + "query": query, + "status": status, + "tags": tags, + }, + browser_list_params.BrowserListParams, + ), + ), + model=BrowserListResponse, + ) + + async def curl( + self, + id: str, + *, + url: str, + body: str | Omit = omit, + headers: Dict[str, str] | Omit = omit, + method: Literal["GET", "HEAD", "POST", "PUT", "PATCH", "DELETE", "OPTIONS"] | Omit = omit, + response_encoding: Literal["utf8", "base64"] | Omit = omit, + timeout_ms: int | 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, + ) -> BrowserCurlResponse: + """ + Sends an HTTP request through Chrome's HTTP request stack, inheriting the + browser's TLS fingerprint, cookies, proxy configuration, and headers. Returns a + structured JSON response with status, headers, body, and timing. + + Args: + url: Target URL (must be http or https). + + body: Request body (for POST/PUT/PATCH). + + headers: Custom headers merged with browser defaults. + + method: HTTP method. + + response_encoding: Encoding for the response body. Use base64 for binary content. + + timeout_ms: Request timeout in milliseconds. + + 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( + path_template("/browsers/{id}/curl", id=id), + body=await async_maybe_transform( + { + "url": url, + "body": body, + "headers": headers, + "method": method, + "response_encoding": response_encoding, + "timeout_ms": timeout_ms, + }, + browser_curl_params.BrowserCurlParams, + ), + options=make_request_options( + extra_headers=extra_headers, extra_query=extra_query, extra_body=extra_body, timeout=timeout + ), + cast_to=BrowserCurlResponse, + ) + + async def delete_by_id( + self, + id_or_name: 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, + ) -> None: + """ + Delete a browser session by ID or name + + 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_or_name: + raise ValueError(f"Expected a non-empty value for `id_or_name` but received {id_or_name!r}") + extra_headers = {"Accept": "*/*", **(extra_headers or {})} + return await self._delete( + path_template("/browsers/{id_or_name}", id_or_name=id_or_name), + options=make_request_options( + extra_headers=extra_headers, extra_query=extra_query, extra_body=extra_body, timeout=timeout + ), + cast_to=NoneType, + ) + + async def load_extensions( + self, + id: str, + *, + extensions: Iterable[browser_load_extensions_params.Extension], + # 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, + ) -> None: + """ + Loads one or more unpacked extensions and restarts Chromium on the browser + instance. + + Args: + extensions: List of extensions to upload and activate + + 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}") + extra_headers = {"Accept": "*/*", **(extra_headers or {})} + body = deepcopy_with_paths({"extensions": extensions}, [["extensions", "", "zip_file"]]) + files = extract_files(cast(Mapping[str, object], body), paths=[["extensions", "", "zip_file"]]) + # It should be noted that the actual Content-Type header that will be + # sent to the server will contain a `boundary` parameter, e.g. + # multipart/form-data; boundary=---abc-- + extra_headers["Content-Type"] = "multipart/form-data" + return await self._post( + path_template("/browsers/{id}/extensions", id=id), + body=await async_maybe_transform(body, browser_load_extensions_params.BrowserLoadExtensionsParams), + files=files, + options=make_request_options( + extra_headers=extra_headers, extra_query=extra_query, extra_body=extra_body, timeout=timeout + ), + cast_to=NoneType, + ) + + +class BrowsersResourceWithRawResponse: + def __init__(self, browsers: BrowsersResource) -> None: + self._browsers = browsers + + self.create = to_raw_response_wrapper( + browsers.create, + ) + self.retrieve = to_raw_response_wrapper( + browsers.retrieve, + ) + self.update = to_raw_response_wrapper( + browsers.update, + ) + self.list = to_raw_response_wrapper( + browsers.list, + ) + self.curl = to_raw_response_wrapper( + browsers.curl, + ) + self.delete_by_id = to_raw_response_wrapper( + browsers.delete_by_id, + ) + self.load_extensions = to_raw_response_wrapper( + browsers.load_extensions, + ) + + @cached_property + def telemetry(self) -> TelemetryResourceWithRawResponse: + """Stream live telemetry events from a browser session.""" + return TelemetryResourceWithRawResponse(self._browsers.telemetry) + + @cached_property + def replays(self) -> ReplaysResourceWithRawResponse: + """Record and manage browser session video replays.""" + return ReplaysResourceWithRawResponse(self._browsers.replays) + + @cached_property + def fs(self) -> FsResourceWithRawResponse: + """Read, write, and manage files on the browser instance.""" + return FsResourceWithRawResponse(self._browsers.fs) + + @cached_property + def process(self) -> ProcessResourceWithRawResponse: + """Execute and manage processes on the browser instance.""" + return ProcessResourceWithRawResponse(self._browsers.process) + + @cached_property + def logs(self) -> LogsResourceWithRawResponse: + """Stream logs from the browser instance.""" + return LogsResourceWithRawResponse(self._browsers.logs) + + @cached_property + def computer(self) -> ComputerResourceWithRawResponse: + """Control mouse, keyboard, and screen on the browser instance.""" + return ComputerResourceWithRawResponse(self._browsers.computer) + + @cached_property + def playwright(self) -> PlaywrightResourceWithRawResponse: + """Execute Playwright code against the browser instance.""" + return PlaywrightResourceWithRawResponse(self._browsers.playwright) + + +class AsyncBrowsersResourceWithRawResponse: + def __init__(self, browsers: AsyncBrowsersResource) -> None: + self._browsers = browsers + + self.create = async_to_raw_response_wrapper( + browsers.create, + ) + self.retrieve = async_to_raw_response_wrapper( + browsers.retrieve, + ) + self.update = async_to_raw_response_wrapper( + browsers.update, + ) + self.list = async_to_raw_response_wrapper( + browsers.list, + ) + self.curl = async_to_raw_response_wrapper( + browsers.curl, + ) + self.delete_by_id = async_to_raw_response_wrapper( + browsers.delete_by_id, + ) + self.load_extensions = async_to_raw_response_wrapper( + browsers.load_extensions, + ) + + @cached_property + def telemetry(self) -> AsyncTelemetryResourceWithRawResponse: + """Stream live telemetry events from a browser session.""" + return AsyncTelemetryResourceWithRawResponse(self._browsers.telemetry) + + @cached_property + def replays(self) -> AsyncReplaysResourceWithRawResponse: + """Record and manage browser session video replays.""" + return AsyncReplaysResourceWithRawResponse(self._browsers.replays) + + @cached_property + def fs(self) -> AsyncFsResourceWithRawResponse: + """Read, write, and manage files on the browser instance.""" + return AsyncFsResourceWithRawResponse(self._browsers.fs) + + @cached_property + def process(self) -> AsyncProcessResourceWithRawResponse: + """Execute and manage processes on the browser instance.""" + return AsyncProcessResourceWithRawResponse(self._browsers.process) + + @cached_property + def logs(self) -> AsyncLogsResourceWithRawResponse: + """Stream logs from the browser instance.""" + return AsyncLogsResourceWithRawResponse(self._browsers.logs) + + @cached_property + def computer(self) -> AsyncComputerResourceWithRawResponse: + """Control mouse, keyboard, and screen on the browser instance.""" + return AsyncComputerResourceWithRawResponse(self._browsers.computer) + + @cached_property + def playwright(self) -> AsyncPlaywrightResourceWithRawResponse: + """Execute Playwright code against the browser instance.""" + return AsyncPlaywrightResourceWithRawResponse(self._browsers.playwright) + + +class BrowsersResourceWithStreamingResponse: + def __init__(self, browsers: BrowsersResource) -> None: + self._browsers = browsers + + self.create = to_streamed_response_wrapper( + browsers.create, + ) + self.retrieve = to_streamed_response_wrapper( + browsers.retrieve, + ) + self.update = to_streamed_response_wrapper( + browsers.update, + ) + self.list = to_streamed_response_wrapper( + browsers.list, + ) + self.curl = to_streamed_response_wrapper( + browsers.curl, + ) + self.delete_by_id = to_streamed_response_wrapper( + browsers.delete_by_id, + ) + self.load_extensions = to_streamed_response_wrapper( + browsers.load_extensions, + ) + + @cached_property + def telemetry(self) -> TelemetryResourceWithStreamingResponse: + """Stream live telemetry events from a browser session.""" + return TelemetryResourceWithStreamingResponse(self._browsers.telemetry) + + @cached_property + def replays(self) -> ReplaysResourceWithStreamingResponse: + """Record and manage browser session video replays.""" + return ReplaysResourceWithStreamingResponse(self._browsers.replays) + + @cached_property + def fs(self) -> FsResourceWithStreamingResponse: + """Read, write, and manage files on the browser instance.""" + return FsResourceWithStreamingResponse(self._browsers.fs) + + @cached_property + def process(self) -> ProcessResourceWithStreamingResponse: + """Execute and manage processes on the browser instance.""" + return ProcessResourceWithStreamingResponse(self._browsers.process) + + @cached_property + def logs(self) -> LogsResourceWithStreamingResponse: + """Stream logs from the browser instance.""" + return LogsResourceWithStreamingResponse(self._browsers.logs) + + @cached_property + def computer(self) -> ComputerResourceWithStreamingResponse: + """Control mouse, keyboard, and screen on the browser instance.""" + return ComputerResourceWithStreamingResponse(self._browsers.computer) + + @cached_property + def playwright(self) -> PlaywrightResourceWithStreamingResponse: + """Execute Playwright code against the browser instance.""" + return PlaywrightResourceWithStreamingResponse(self._browsers.playwright) + + +class AsyncBrowsersResourceWithStreamingResponse: + def __init__(self, browsers: AsyncBrowsersResource) -> None: + self._browsers = browsers + + self.create = async_to_streamed_response_wrapper( + browsers.create, + ) + self.retrieve = async_to_streamed_response_wrapper( + browsers.retrieve, + ) + self.update = async_to_streamed_response_wrapper( + browsers.update, + ) + self.list = async_to_streamed_response_wrapper( + browsers.list, + ) + self.curl = async_to_streamed_response_wrapper( + browsers.curl, + ) + self.delete_by_id = async_to_streamed_response_wrapper( + browsers.delete_by_id, + ) + self.load_extensions = async_to_streamed_response_wrapper( + browsers.load_extensions, + ) + + @cached_property + def telemetry(self) -> AsyncTelemetryResourceWithStreamingResponse: + """Stream live telemetry events from a browser session.""" + return AsyncTelemetryResourceWithStreamingResponse(self._browsers.telemetry) + + @cached_property + def replays(self) -> AsyncReplaysResourceWithStreamingResponse: + """Record and manage browser session video replays.""" + return AsyncReplaysResourceWithStreamingResponse(self._browsers.replays) + + @cached_property + def fs(self) -> AsyncFsResourceWithStreamingResponse: + """Read, write, and manage files on the browser instance.""" + return AsyncFsResourceWithStreamingResponse(self._browsers.fs) + + @cached_property + def process(self) -> AsyncProcessResourceWithStreamingResponse: + """Execute and manage processes on the browser instance.""" + return AsyncProcessResourceWithStreamingResponse(self._browsers.process) + + @cached_property + def logs(self) -> AsyncLogsResourceWithStreamingResponse: + """Stream logs from the browser instance.""" + return AsyncLogsResourceWithStreamingResponse(self._browsers.logs) + + @cached_property + def computer(self) -> AsyncComputerResourceWithStreamingResponse: + """Control mouse, keyboard, and screen on the browser instance.""" + return AsyncComputerResourceWithStreamingResponse(self._browsers.computer) + + @cached_property + def playwright(self) -> AsyncPlaywrightResourceWithStreamingResponse: + """Execute Playwright code against the browser instance.""" + return AsyncPlaywrightResourceWithStreamingResponse(self._browsers.playwright) diff --git a/src/kernel/resources/browsers/computer.py b/src/kernel/resources/browsers/computer.py new file mode 100644 index 00000000..d2b57304 --- /dev/null +++ b/src/kernel/resources/browsers/computer.py @@ -0,0 +1,1429 @@ +# File generated from our OpenAPI spec by Stainless. See CONTRIBUTING.md for details. + +from __future__ import annotations + +from typing import Iterable +from typing_extensions import Literal + +import httpx + +from ..._types import Body, Omit, Query, Headers, NoneType, NotGiven, SequenceNotStr, omit, not_given +from ..._utils import path_template, maybe_transform, async_maybe_transform +from ..._compat import cached_property +from ..._resource import SyncAPIResource, AsyncAPIResource +from ..._response import ( + BinaryAPIResponse, + AsyncBinaryAPIResponse, + StreamedBinaryAPIResponse, + AsyncStreamedBinaryAPIResponse, + to_raw_response_wrapper, + to_streamed_response_wrapper, + async_to_raw_response_wrapper, + to_custom_raw_response_wrapper, + async_to_streamed_response_wrapper, + to_custom_streamed_response_wrapper, + async_to_custom_raw_response_wrapper, + async_to_custom_streamed_response_wrapper, +) +from ..._base_client import make_request_options +from ...types.browsers import ( + computer_batch_params, + computer_scroll_params, + computer_press_key_params, + computer_type_text_params, + computer_drag_mouse_params, + computer_move_mouse_params, + computer_click_mouse_params, + computer_write_clipboard_params, + computer_capture_screenshot_params, + computer_set_cursor_visibility_params, +) +from ...types.browsers.computer_read_clipboard_response import ComputerReadClipboardResponse +from ...types.browsers.computer_get_mouse_position_response import ComputerGetMousePositionResponse +from ...types.browsers.computer_set_cursor_visibility_response import ComputerSetCursorVisibilityResponse + +__all__ = ["ComputerResource", "AsyncComputerResource"] + + +class ComputerResource(SyncAPIResource): + """Control mouse, keyboard, and screen on the browser instance.""" + + @cached_property + def with_raw_response(self) -> ComputerResourceWithRawResponse: + """ + 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/kernel/kernel-python-sdk#accessing-raw-response-data-eg-headers + """ + return ComputerResourceWithRawResponse(self) + + @cached_property + def with_streaming_response(self) -> ComputerResourceWithStreamingResponse: + """ + An alternative to `.with_raw_response` that doesn't eagerly read the response body. + + For more information, see https://www.github.com/kernel/kernel-python-sdk#with_streaming_response + """ + return ComputerResourceWithStreamingResponse(self) + + def batch( + self, + id: str, + *, + actions: Iterable[computer_batch_params.Action], + # 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, + ) -> None: + """ + Send an array of computer actions to execute in order on the browser instance. + Execution stops on the first error. This reduces network latency compared to + sending individual action requests. + + Args: + actions: Ordered list of actions to execute. Execution stops on the first error. + + 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}") + extra_headers = {"Accept": "*/*", **(extra_headers or {})} + return self._post( + path_template("/browsers/{id}/computer/batch", id=id), + body=maybe_transform({"actions": actions}, computer_batch_params.ComputerBatchParams), + options=make_request_options( + extra_headers=extra_headers, extra_query=extra_query, extra_body=extra_body, timeout=timeout + ), + cast_to=NoneType, + ) + + def capture_screenshot( + self, + id: str, + *, + region: computer_capture_screenshot_params.Region | 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, + ) -> BinaryAPIResponse: + """ + Capture a screenshot of the browser instance + + 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}") + extra_headers = {"Accept": "image/png", **(extra_headers or {})} + return self._post( + path_template("/browsers/{id}/computer/screenshot", id=id), + body=maybe_transform( + {"region": region}, computer_capture_screenshot_params.ComputerCaptureScreenshotParams + ), + options=make_request_options( + extra_headers=extra_headers, extra_query=extra_query, extra_body=extra_body, timeout=timeout + ), + cast_to=BinaryAPIResponse, + ) + + def click_mouse( + self, + id: str, + *, + x: int, + y: int, + button: Literal["left", "right", "middle", "back", "forward"] | Omit = omit, + click_type: Literal["down", "up", "click"] | Omit = omit, + hold_keys: SequenceNotStr[str] | Omit = omit, + num_clicks: int | 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, + ) -> None: + """ + Simulate a mouse click action on the browser instance + + Args: + x: X coordinate of the click position + + y: Y coordinate of the click position + + button: Mouse button to interact with + + click_type: Type of click action + + hold_keys: Modifier keys to hold during the click + + num_clicks: Number of times to repeat the click + + 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}") + extra_headers = {"Accept": "*/*", **(extra_headers or {})} + return self._post( + path_template("/browsers/{id}/computer/click_mouse", id=id), + body=maybe_transform( + { + "x": x, + "y": y, + "button": button, + "click_type": click_type, + "hold_keys": hold_keys, + "num_clicks": num_clicks, + }, + computer_click_mouse_params.ComputerClickMouseParams, + ), + options=make_request_options( + extra_headers=extra_headers, extra_query=extra_query, extra_body=extra_body, timeout=timeout + ), + cast_to=NoneType, + ) + + def drag_mouse( + self, + id: str, + *, + path: Iterable[Iterable[int]], + button: Literal["left", "middle", "right"] | Omit = omit, + delay: int | Omit = omit, + duration_ms: int | Omit = omit, + hold_keys: SequenceNotStr[str] | Omit = omit, + smooth: bool | Omit = omit, + step_delay_ms: int | Omit = omit, + steps_per_segment: int | 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, + ) -> None: + """ + Drag the mouse along a path + + Args: + path: Ordered list of [x, y] coordinate pairs to move through while dragging. Must + contain at least 2 points. + + button: Mouse button to drag with + + delay: Delay in milliseconds between button down and starting to move along the path. + + duration_ms: Target total duration in milliseconds for the entire drag movement when + smooth=true. Omit for automatic timing based on total path length. + + hold_keys: Modifier keys to hold during the drag + + smooth: Use human-like Bezier curves between path waypoints instead of linear + interpolation. When true, steps_per_segment and step_delay_ms are ignored. + + step_delay_ms: Delay in milliseconds between relative steps while dragging (not the initial + delay). + + steps_per_segment: Number of relative move steps per segment in the path. Minimum 1. + + 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}") + extra_headers = {"Accept": "*/*", **(extra_headers or {})} + return self._post( + path_template("/browsers/{id}/computer/drag_mouse", id=id), + body=maybe_transform( + { + "path": path, + "button": button, + "delay": delay, + "duration_ms": duration_ms, + "hold_keys": hold_keys, + "smooth": smooth, + "step_delay_ms": step_delay_ms, + "steps_per_segment": steps_per_segment, + }, + computer_drag_mouse_params.ComputerDragMouseParams, + ), + options=make_request_options( + extra_headers=extra_headers, extra_query=extra_query, extra_body=extra_body, timeout=timeout + ), + cast_to=NoneType, + ) + + def get_mouse_position( + 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, + ) -> ComputerGetMousePositionResponse: + """ + Get the current mouse cursor position on the browser instance + + 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( + path_template("/browsers/{id}/computer/get_mouse_position", id=id), + options=make_request_options( + extra_headers=extra_headers, extra_query=extra_query, extra_body=extra_body, timeout=timeout + ), + cast_to=ComputerGetMousePositionResponse, + ) + + def move_mouse( + self, + id: str, + *, + x: int, + y: int, + duration_ms: int | Omit = omit, + hold_keys: SequenceNotStr[str] | Omit = omit, + smooth: 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, + ) -> None: + """ + Move the mouse cursor to the specified coordinates on the browser instance + + Args: + x: X coordinate to move the cursor to + + y: Y coordinate to move the cursor to + + duration_ms: Target total duration in milliseconds for the mouse movement when smooth=true. + Omit for automatic timing based on distance. + + hold_keys: Modifier keys to hold during the move + + smooth: Use human-like Bezier curve path instead of instant mouse movement. + + 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}") + extra_headers = {"Accept": "*/*", **(extra_headers or {})} + return self._post( + path_template("/browsers/{id}/computer/move_mouse", id=id), + body=maybe_transform( + { + "x": x, + "y": y, + "duration_ms": duration_ms, + "hold_keys": hold_keys, + "smooth": smooth, + }, + computer_move_mouse_params.ComputerMoveMouseParams, + ), + options=make_request_options( + extra_headers=extra_headers, extra_query=extra_query, extra_body=extra_body, timeout=timeout + ), + cast_to=NoneType, + ) + + def press_key( + self, + id: str, + *, + keys: SequenceNotStr[str], + duration: int | Omit = omit, + hold_keys: SequenceNotStr[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, + ) -> None: + """ + Press one or more keys on the host computer + + Args: + keys: List of key symbols to press. Each item should be a key symbol supported by + xdotool (see X11 keysym definitions). Examples include "Return", "Shift", + "Ctrl", "Alt", "F5". Items in this list could also be combinations, e.g. + "Ctrl+t" or "Ctrl+Shift+Tab". + + duration: Duration to hold the keys down in milliseconds. If omitted or 0, keys are + tapped. + + hold_keys: Optional modifier keys to hold during the key press sequence. + + 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}") + extra_headers = {"Accept": "*/*", **(extra_headers or {})} + return self._post( + path_template("/browsers/{id}/computer/press_key", id=id), + body=maybe_transform( + { + "keys": keys, + "duration": duration, + "hold_keys": hold_keys, + }, + computer_press_key_params.ComputerPressKeyParams, + ), + options=make_request_options( + extra_headers=extra_headers, extra_query=extra_query, extra_body=extra_body, timeout=timeout + ), + cast_to=NoneType, + ) + + def read_clipboard( + 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, + ) -> ComputerReadClipboardResponse: + """ + Read text from the clipboard on the browser instance + + 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( + path_template("/browsers/{id}/computer/clipboard/read", id=id), + options=make_request_options( + extra_headers=extra_headers, extra_query=extra_query, extra_body=extra_body, timeout=timeout + ), + cast_to=ComputerReadClipboardResponse, + ) + + def scroll( + self, + id: str, + *, + x: int, + y: int, + delta_x: int | Omit = omit, + delta_y: int | Omit = omit, + hold_keys: SequenceNotStr[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, + ) -> None: + """ + Scroll the mouse wheel at a position on the host computer + + Args: + x: X coordinate at which to perform the scroll + + y: Y coordinate at which to perform the scroll + + delta_x: Horizontal scroll amount in xdotool "wheel units." Positive scrolls right, + negative scrolls left. + + delta_y: Vertical scroll amount in xdotool "wheel units." Positive scrolls down, negative + scrolls up. + + hold_keys: Modifier keys to hold during the scroll + + 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}") + extra_headers = {"Accept": "*/*", **(extra_headers or {})} + return self._post( + path_template("/browsers/{id}/computer/scroll", id=id), + body=maybe_transform( + { + "x": x, + "y": y, + "delta_x": delta_x, + "delta_y": delta_y, + "hold_keys": hold_keys, + }, + computer_scroll_params.ComputerScrollParams, + ), + options=make_request_options( + extra_headers=extra_headers, extra_query=extra_query, extra_body=extra_body, timeout=timeout + ), + cast_to=NoneType, + ) + + def set_cursor_visibility( + self, + id: str, + *, + hidden: bool, + # 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, + ) -> ComputerSetCursorVisibilityResponse: + """ + Set cursor visibility + + Args: + hidden: Whether the cursor should be hidden or visible + + 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( + path_template("/browsers/{id}/computer/cursor", id=id), + body=maybe_transform( + {"hidden": hidden}, computer_set_cursor_visibility_params.ComputerSetCursorVisibilityParams + ), + options=make_request_options( + extra_headers=extra_headers, extra_query=extra_query, extra_body=extra_body, timeout=timeout + ), + cast_to=ComputerSetCursorVisibilityResponse, + ) + + def type_text( + self, + id: str, + *, + text: str, + delay: int | 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, + ) -> None: + """ + Type text on the browser instance + + Args: + text: Text to type on the browser instance + + delay: Delay in milliseconds between keystrokes + + 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}") + extra_headers = {"Accept": "*/*", **(extra_headers or {})} + return self._post( + path_template("/browsers/{id}/computer/type", id=id), + body=maybe_transform( + { + "text": text, + "delay": delay, + }, + computer_type_text_params.ComputerTypeTextParams, + ), + options=make_request_options( + extra_headers=extra_headers, extra_query=extra_query, extra_body=extra_body, timeout=timeout + ), + cast_to=NoneType, + ) + + def write_clipboard( + self, + id: str, + *, + 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, + ) -> None: + """ + Write text to the clipboard on the browser instance + + Args: + text: Text to write to the system clipboard + + 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}") + extra_headers = {"Accept": "*/*", **(extra_headers or {})} + return self._post( + path_template("/browsers/{id}/computer/clipboard/write", id=id), + body=maybe_transform({"text": text}, computer_write_clipboard_params.ComputerWriteClipboardParams), + options=make_request_options( + extra_headers=extra_headers, extra_query=extra_query, extra_body=extra_body, timeout=timeout + ), + cast_to=NoneType, + ) + + +class AsyncComputerResource(AsyncAPIResource): + """Control mouse, keyboard, and screen on the browser instance.""" + + @cached_property + def with_raw_response(self) -> AsyncComputerResourceWithRawResponse: + """ + 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/kernel/kernel-python-sdk#accessing-raw-response-data-eg-headers + """ + return AsyncComputerResourceWithRawResponse(self) + + @cached_property + def with_streaming_response(self) -> AsyncComputerResourceWithStreamingResponse: + """ + An alternative to `.with_raw_response` that doesn't eagerly read the response body. + + For more information, see https://www.github.com/kernel/kernel-python-sdk#with_streaming_response + """ + return AsyncComputerResourceWithStreamingResponse(self) + + async def batch( + self, + id: str, + *, + actions: Iterable[computer_batch_params.Action], + # 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, + ) -> None: + """ + Send an array of computer actions to execute in order on the browser instance. + Execution stops on the first error. This reduces network latency compared to + sending individual action requests. + + Args: + actions: Ordered list of actions to execute. Execution stops on the first error. + + 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}") + extra_headers = {"Accept": "*/*", **(extra_headers or {})} + return await self._post( + path_template("/browsers/{id}/computer/batch", id=id), + body=await async_maybe_transform({"actions": actions}, computer_batch_params.ComputerBatchParams), + options=make_request_options( + extra_headers=extra_headers, extra_query=extra_query, extra_body=extra_body, timeout=timeout + ), + cast_to=NoneType, + ) + + async def capture_screenshot( + self, + id: str, + *, + region: computer_capture_screenshot_params.Region | 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, + ) -> AsyncBinaryAPIResponse: + """ + Capture a screenshot of the browser instance + + 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}") + extra_headers = {"Accept": "image/png", **(extra_headers or {})} + return await self._post( + path_template("/browsers/{id}/computer/screenshot", id=id), + body=await async_maybe_transform( + {"region": region}, computer_capture_screenshot_params.ComputerCaptureScreenshotParams + ), + options=make_request_options( + extra_headers=extra_headers, extra_query=extra_query, extra_body=extra_body, timeout=timeout + ), + cast_to=AsyncBinaryAPIResponse, + ) + + async def click_mouse( + self, + id: str, + *, + x: int, + y: int, + button: Literal["left", "right", "middle", "back", "forward"] | Omit = omit, + click_type: Literal["down", "up", "click"] | Omit = omit, + hold_keys: SequenceNotStr[str] | Omit = omit, + num_clicks: int | 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, + ) -> None: + """ + Simulate a mouse click action on the browser instance + + Args: + x: X coordinate of the click position + + y: Y coordinate of the click position + + button: Mouse button to interact with + + click_type: Type of click action + + hold_keys: Modifier keys to hold during the click + + num_clicks: Number of times to repeat the click + + 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}") + extra_headers = {"Accept": "*/*", **(extra_headers or {})} + return await self._post( + path_template("/browsers/{id}/computer/click_mouse", id=id), + body=await async_maybe_transform( + { + "x": x, + "y": y, + "button": button, + "click_type": click_type, + "hold_keys": hold_keys, + "num_clicks": num_clicks, + }, + computer_click_mouse_params.ComputerClickMouseParams, + ), + options=make_request_options( + extra_headers=extra_headers, extra_query=extra_query, extra_body=extra_body, timeout=timeout + ), + cast_to=NoneType, + ) + + async def drag_mouse( + self, + id: str, + *, + path: Iterable[Iterable[int]], + button: Literal["left", "middle", "right"] | Omit = omit, + delay: int | Omit = omit, + duration_ms: int | Omit = omit, + hold_keys: SequenceNotStr[str] | Omit = omit, + smooth: bool | Omit = omit, + step_delay_ms: int | Omit = omit, + steps_per_segment: int | 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, + ) -> None: + """ + Drag the mouse along a path + + Args: + path: Ordered list of [x, y] coordinate pairs to move through while dragging. Must + contain at least 2 points. + + button: Mouse button to drag with + + delay: Delay in milliseconds between button down and starting to move along the path. + + duration_ms: Target total duration in milliseconds for the entire drag movement when + smooth=true. Omit for automatic timing based on total path length. + + hold_keys: Modifier keys to hold during the drag + + smooth: Use human-like Bezier curves between path waypoints instead of linear + interpolation. When true, steps_per_segment and step_delay_ms are ignored. + + step_delay_ms: Delay in milliseconds between relative steps while dragging (not the initial + delay). + + steps_per_segment: Number of relative move steps per segment in the path. Minimum 1. + + 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}") + extra_headers = {"Accept": "*/*", **(extra_headers or {})} + return await self._post( + path_template("/browsers/{id}/computer/drag_mouse", id=id), + body=await async_maybe_transform( + { + "path": path, + "button": button, + "delay": delay, + "duration_ms": duration_ms, + "hold_keys": hold_keys, + "smooth": smooth, + "step_delay_ms": step_delay_ms, + "steps_per_segment": steps_per_segment, + }, + computer_drag_mouse_params.ComputerDragMouseParams, + ), + options=make_request_options( + extra_headers=extra_headers, extra_query=extra_query, extra_body=extra_body, timeout=timeout + ), + cast_to=NoneType, + ) + + async def get_mouse_position( + 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, + ) -> ComputerGetMousePositionResponse: + """ + Get the current mouse cursor position on the browser instance + + 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( + path_template("/browsers/{id}/computer/get_mouse_position", id=id), + options=make_request_options( + extra_headers=extra_headers, extra_query=extra_query, extra_body=extra_body, timeout=timeout + ), + cast_to=ComputerGetMousePositionResponse, + ) + + async def move_mouse( + self, + id: str, + *, + x: int, + y: int, + duration_ms: int | Omit = omit, + hold_keys: SequenceNotStr[str] | Omit = omit, + smooth: 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, + ) -> None: + """ + Move the mouse cursor to the specified coordinates on the browser instance + + Args: + x: X coordinate to move the cursor to + + y: Y coordinate to move the cursor to + + duration_ms: Target total duration in milliseconds for the mouse movement when smooth=true. + Omit for automatic timing based on distance. + + hold_keys: Modifier keys to hold during the move + + smooth: Use human-like Bezier curve path instead of instant mouse movement. + + 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}") + extra_headers = {"Accept": "*/*", **(extra_headers or {})} + return await self._post( + path_template("/browsers/{id}/computer/move_mouse", id=id), + body=await async_maybe_transform( + { + "x": x, + "y": y, + "duration_ms": duration_ms, + "hold_keys": hold_keys, + "smooth": smooth, + }, + computer_move_mouse_params.ComputerMoveMouseParams, + ), + options=make_request_options( + extra_headers=extra_headers, extra_query=extra_query, extra_body=extra_body, timeout=timeout + ), + cast_to=NoneType, + ) + + async def press_key( + self, + id: str, + *, + keys: SequenceNotStr[str], + duration: int | Omit = omit, + hold_keys: SequenceNotStr[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, + ) -> None: + """ + Press one or more keys on the host computer + + Args: + keys: List of key symbols to press. Each item should be a key symbol supported by + xdotool (see X11 keysym definitions). Examples include "Return", "Shift", + "Ctrl", "Alt", "F5". Items in this list could also be combinations, e.g. + "Ctrl+t" or "Ctrl+Shift+Tab". + + duration: Duration to hold the keys down in milliseconds. If omitted or 0, keys are + tapped. + + hold_keys: Optional modifier keys to hold during the key press sequence. + + 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}") + extra_headers = {"Accept": "*/*", **(extra_headers or {})} + return await self._post( + path_template("/browsers/{id}/computer/press_key", id=id), + body=await async_maybe_transform( + { + "keys": keys, + "duration": duration, + "hold_keys": hold_keys, + }, + computer_press_key_params.ComputerPressKeyParams, + ), + options=make_request_options( + extra_headers=extra_headers, extra_query=extra_query, extra_body=extra_body, timeout=timeout + ), + cast_to=NoneType, + ) + + async def read_clipboard( + 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, + ) -> ComputerReadClipboardResponse: + """ + Read text from the clipboard on the browser instance + + 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( + path_template("/browsers/{id}/computer/clipboard/read", id=id), + options=make_request_options( + extra_headers=extra_headers, extra_query=extra_query, extra_body=extra_body, timeout=timeout + ), + cast_to=ComputerReadClipboardResponse, + ) + + async def scroll( + self, + id: str, + *, + x: int, + y: int, + delta_x: int | Omit = omit, + delta_y: int | Omit = omit, + hold_keys: SequenceNotStr[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, + ) -> None: + """ + Scroll the mouse wheel at a position on the host computer + + Args: + x: X coordinate at which to perform the scroll + + y: Y coordinate at which to perform the scroll + + delta_x: Horizontal scroll amount in xdotool "wheel units." Positive scrolls right, + negative scrolls left. + + delta_y: Vertical scroll amount in xdotool "wheel units." Positive scrolls down, negative + scrolls up. + + hold_keys: Modifier keys to hold during the scroll + + 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}") + extra_headers = {"Accept": "*/*", **(extra_headers or {})} + return await self._post( + path_template("/browsers/{id}/computer/scroll", id=id), + body=await async_maybe_transform( + { + "x": x, + "y": y, + "delta_x": delta_x, + "delta_y": delta_y, + "hold_keys": hold_keys, + }, + computer_scroll_params.ComputerScrollParams, + ), + options=make_request_options( + extra_headers=extra_headers, extra_query=extra_query, extra_body=extra_body, timeout=timeout + ), + cast_to=NoneType, + ) + + async def set_cursor_visibility( + self, + id: str, + *, + hidden: bool, + # 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, + ) -> ComputerSetCursorVisibilityResponse: + """ + Set cursor visibility + + Args: + hidden: Whether the cursor should be hidden or visible + + 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( + path_template("/browsers/{id}/computer/cursor", id=id), + body=await async_maybe_transform( + {"hidden": hidden}, computer_set_cursor_visibility_params.ComputerSetCursorVisibilityParams + ), + options=make_request_options( + extra_headers=extra_headers, extra_query=extra_query, extra_body=extra_body, timeout=timeout + ), + cast_to=ComputerSetCursorVisibilityResponse, + ) + + async def type_text( + self, + id: str, + *, + text: str, + delay: int | 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, + ) -> None: + """ + Type text on the browser instance + + Args: + text: Text to type on the browser instance + + delay: Delay in milliseconds between keystrokes + + 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}") + extra_headers = {"Accept": "*/*", **(extra_headers or {})} + return await self._post( + path_template("/browsers/{id}/computer/type", id=id), + body=await async_maybe_transform( + { + "text": text, + "delay": delay, + }, + computer_type_text_params.ComputerTypeTextParams, + ), + options=make_request_options( + extra_headers=extra_headers, extra_query=extra_query, extra_body=extra_body, timeout=timeout + ), + cast_to=NoneType, + ) + + async def write_clipboard( + self, + id: str, + *, + 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, + ) -> None: + """ + Write text to the clipboard on the browser instance + + Args: + text: Text to write to the system clipboard + + 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}") + extra_headers = {"Accept": "*/*", **(extra_headers or {})} + return await self._post( + path_template("/browsers/{id}/computer/clipboard/write", id=id), + body=await async_maybe_transform( + {"text": text}, computer_write_clipboard_params.ComputerWriteClipboardParams + ), + options=make_request_options( + extra_headers=extra_headers, extra_query=extra_query, extra_body=extra_body, timeout=timeout + ), + cast_to=NoneType, + ) + + +class ComputerResourceWithRawResponse: + def __init__(self, computer: ComputerResource) -> None: + self._computer = computer + + self.batch = to_raw_response_wrapper( + computer.batch, + ) + self.capture_screenshot = to_custom_raw_response_wrapper( + computer.capture_screenshot, + BinaryAPIResponse, + ) + self.click_mouse = to_raw_response_wrapper( + computer.click_mouse, + ) + self.drag_mouse = to_raw_response_wrapper( + computer.drag_mouse, + ) + self.get_mouse_position = to_raw_response_wrapper( + computer.get_mouse_position, + ) + self.move_mouse = to_raw_response_wrapper( + computer.move_mouse, + ) + self.press_key = to_raw_response_wrapper( + computer.press_key, + ) + self.read_clipboard = to_raw_response_wrapper( + computer.read_clipboard, + ) + self.scroll = to_raw_response_wrapper( + computer.scroll, + ) + self.set_cursor_visibility = to_raw_response_wrapper( + computer.set_cursor_visibility, + ) + self.type_text = to_raw_response_wrapper( + computer.type_text, + ) + self.write_clipboard = to_raw_response_wrapper( + computer.write_clipboard, + ) + + +class AsyncComputerResourceWithRawResponse: + def __init__(self, computer: AsyncComputerResource) -> None: + self._computer = computer + + self.batch = async_to_raw_response_wrapper( + computer.batch, + ) + self.capture_screenshot = async_to_custom_raw_response_wrapper( + computer.capture_screenshot, + AsyncBinaryAPIResponse, + ) + self.click_mouse = async_to_raw_response_wrapper( + computer.click_mouse, + ) + self.drag_mouse = async_to_raw_response_wrapper( + computer.drag_mouse, + ) + self.get_mouse_position = async_to_raw_response_wrapper( + computer.get_mouse_position, + ) + self.move_mouse = async_to_raw_response_wrapper( + computer.move_mouse, + ) + self.press_key = async_to_raw_response_wrapper( + computer.press_key, + ) + self.read_clipboard = async_to_raw_response_wrapper( + computer.read_clipboard, + ) + self.scroll = async_to_raw_response_wrapper( + computer.scroll, + ) + self.set_cursor_visibility = async_to_raw_response_wrapper( + computer.set_cursor_visibility, + ) + self.type_text = async_to_raw_response_wrapper( + computer.type_text, + ) + self.write_clipboard = async_to_raw_response_wrapper( + computer.write_clipboard, + ) + + +class ComputerResourceWithStreamingResponse: + def __init__(self, computer: ComputerResource) -> None: + self._computer = computer + + self.batch = to_streamed_response_wrapper( + computer.batch, + ) + self.capture_screenshot = to_custom_streamed_response_wrapper( + computer.capture_screenshot, + StreamedBinaryAPIResponse, + ) + self.click_mouse = to_streamed_response_wrapper( + computer.click_mouse, + ) + self.drag_mouse = to_streamed_response_wrapper( + computer.drag_mouse, + ) + self.get_mouse_position = to_streamed_response_wrapper( + computer.get_mouse_position, + ) + self.move_mouse = to_streamed_response_wrapper( + computer.move_mouse, + ) + self.press_key = to_streamed_response_wrapper( + computer.press_key, + ) + self.read_clipboard = to_streamed_response_wrapper( + computer.read_clipboard, + ) + self.scroll = to_streamed_response_wrapper( + computer.scroll, + ) + self.set_cursor_visibility = to_streamed_response_wrapper( + computer.set_cursor_visibility, + ) + self.type_text = to_streamed_response_wrapper( + computer.type_text, + ) + self.write_clipboard = to_streamed_response_wrapper( + computer.write_clipboard, + ) + + +class AsyncComputerResourceWithStreamingResponse: + def __init__(self, computer: AsyncComputerResource) -> None: + self._computer = computer + + self.batch = async_to_streamed_response_wrapper( + computer.batch, + ) + self.capture_screenshot = async_to_custom_streamed_response_wrapper( + computer.capture_screenshot, + AsyncStreamedBinaryAPIResponse, + ) + self.click_mouse = async_to_streamed_response_wrapper( + computer.click_mouse, + ) + self.drag_mouse = async_to_streamed_response_wrapper( + computer.drag_mouse, + ) + self.get_mouse_position = async_to_streamed_response_wrapper( + computer.get_mouse_position, + ) + self.move_mouse = async_to_streamed_response_wrapper( + computer.move_mouse, + ) + self.press_key = async_to_streamed_response_wrapper( + computer.press_key, + ) + self.read_clipboard = async_to_streamed_response_wrapper( + computer.read_clipboard, + ) + self.scroll = async_to_streamed_response_wrapper( + computer.scroll, + ) + self.set_cursor_visibility = async_to_streamed_response_wrapper( + computer.set_cursor_visibility, + ) + self.type_text = async_to_streamed_response_wrapper( + computer.type_text, + ) + self.write_clipboard = async_to_streamed_response_wrapper( + computer.write_clipboard, + ) diff --git a/src/kernel/resources/browsers/fs/__init__.py b/src/kernel/resources/browsers/fs/__init__.py new file mode 100644 index 00000000..8195b3f9 --- /dev/null +++ b/src/kernel/resources/browsers/fs/__init__.py @@ -0,0 +1,33 @@ +# File generated from our OpenAPI spec by Stainless. See CONTRIBUTING.md for details. + +from .fs import ( + FsResource, + AsyncFsResource, + FsResourceWithRawResponse, + AsyncFsResourceWithRawResponse, + FsResourceWithStreamingResponse, + AsyncFsResourceWithStreamingResponse, +) +from .watch import ( + WatchResource, + AsyncWatchResource, + WatchResourceWithRawResponse, + AsyncWatchResourceWithRawResponse, + WatchResourceWithStreamingResponse, + AsyncWatchResourceWithStreamingResponse, +) + +__all__ = [ + "WatchResource", + "AsyncWatchResource", + "WatchResourceWithRawResponse", + "AsyncWatchResourceWithRawResponse", + "WatchResourceWithStreamingResponse", + "AsyncWatchResourceWithStreamingResponse", + "FsResource", + "AsyncFsResource", + "FsResourceWithRawResponse", + "AsyncFsResourceWithRawResponse", + "FsResourceWithStreamingResponse", + "AsyncFsResourceWithStreamingResponse", +] diff --git a/src/kernel/resources/browsers/fs/fs.py b/src/kernel/resources/browsers/fs/fs.py new file mode 100644 index 00000000..17149d51 --- /dev/null +++ b/src/kernel/resources/browsers/fs/fs.py @@ -0,0 +1,1390 @@ +# File generated from our OpenAPI spec by Stainless. See CONTRIBUTING.md for details. + +from __future__ import annotations + +import os +from typing import Mapping, Iterable, cast + +import httpx + +from .watch import ( + WatchResource, + AsyncWatchResource, + WatchResourceWithRawResponse, + AsyncWatchResourceWithRawResponse, + WatchResourceWithStreamingResponse, + AsyncWatchResourceWithStreamingResponse, +) +from ...._files import read_file_content, deepcopy_with_paths, async_read_file_content +from ...._types import ( + Body, + Omit, + Query, + Headers, + NoneType, + NotGiven, + FileTypes, + BinaryTypes, + FileContent, + AsyncBinaryTypes, + omit, + not_given, +) +from ...._utils import extract_files, path_template, maybe_transform, async_maybe_transform +from ...._compat import cached_property +from ...._resource import SyncAPIResource, AsyncAPIResource +from ...._response import ( + BinaryAPIResponse, + AsyncBinaryAPIResponse, + StreamedBinaryAPIResponse, + AsyncStreamedBinaryAPIResponse, + to_raw_response_wrapper, + to_streamed_response_wrapper, + async_to_raw_response_wrapper, + to_custom_raw_response_wrapper, + async_to_streamed_response_wrapper, + to_custom_streamed_response_wrapper, + async_to_custom_raw_response_wrapper, + async_to_custom_streamed_response_wrapper, +) +from ...._base_client import make_request_options +from ....types.browsers import ( + f_move_params, + f_upload_params, + f_file_info_params, + f_read_file_params, + f_list_files_params, + f_upload_zip_params, + f_write_file_params, + f_delete_file_params, + f_create_directory_params, + f_delete_directory_params, + f_download_dir_zip_params, + f_set_file_permissions_params, +) +from ....types.browsers.f_file_info_response import FFileInfoResponse +from ....types.browsers.f_list_files_response import FListFilesResponse + +__all__ = ["FsResource", "AsyncFsResource"] + + +class FsResource(SyncAPIResource): + """Read, write, and manage files on the browser instance.""" + + @cached_property + def watch(self) -> WatchResource: + """Read, write, and manage files on the browser instance.""" + return WatchResource(self._client) + + @cached_property + def with_raw_response(self) -> FsResourceWithRawResponse: + """ + 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/kernel/kernel-python-sdk#accessing-raw-response-data-eg-headers + """ + return FsResourceWithRawResponse(self) + + @cached_property + def with_streaming_response(self) -> FsResourceWithStreamingResponse: + """ + An alternative to `.with_raw_response` that doesn't eagerly read the response body. + + For more information, see https://www.github.com/kernel/kernel-python-sdk#with_streaming_response + """ + return FsResourceWithStreamingResponse(self) + + def create_directory( + self, + id: str, + *, + path: str, + mode: 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, + ) -> None: + """ + Create a new directory + + Args: + path: Absolute directory path to create. + + mode: Optional directory mode (octal string, e.g. 755). Defaults to 755. + + 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}") + extra_headers = {"Accept": "*/*", **(extra_headers or {})} + return self._put( + path_template("/browsers/{id}/fs/create_directory", id=id), + body=maybe_transform( + { + "path": path, + "mode": mode, + }, + f_create_directory_params.FCreateDirectoryParams, + ), + options=make_request_options( + extra_headers=extra_headers, extra_query=extra_query, extra_body=extra_body, timeout=timeout + ), + cast_to=NoneType, + ) + + def delete_directory( + self, + id: str, + *, + 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, + ) -> None: + """ + Delete a directory + + Args: + path: Absolute path to delete. + + 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}") + extra_headers = {"Accept": "*/*", **(extra_headers or {})} + return self._put( + path_template("/browsers/{id}/fs/delete_directory", id=id), + body=maybe_transform({"path": path}, f_delete_directory_params.FDeleteDirectoryParams), + options=make_request_options( + extra_headers=extra_headers, extra_query=extra_query, extra_body=extra_body, timeout=timeout + ), + cast_to=NoneType, + ) + + def delete_file( + self, + id: str, + *, + 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, + ) -> None: + """ + Delete a file + + Args: + path: Absolute path to delete. + + 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}") + extra_headers = {"Accept": "*/*", **(extra_headers or {})} + return self._put( + path_template("/browsers/{id}/fs/delete_file", id=id), + body=maybe_transform({"path": path}, f_delete_file_params.FDeleteFileParams), + options=make_request_options( + extra_headers=extra_headers, extra_query=extra_query, extra_body=extra_body, timeout=timeout + ), + cast_to=NoneType, + ) + + def download_dir_zip( + self, + id: str, + *, + 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, + ) -> BinaryAPIResponse: + """ + Returns a ZIP file containing the contents of the specified directory. + + Args: + path: Absolute directory path to archive and download. + + 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}") + extra_headers = {"Accept": "application/zip", **(extra_headers or {})} + return self._get( + path_template("/browsers/{id}/fs/download_dir_zip", id=id), + options=make_request_options( + extra_headers=extra_headers, + extra_query=extra_query, + extra_body=extra_body, + timeout=timeout, + query=maybe_transform({"path": path}, f_download_dir_zip_params.FDownloadDirZipParams), + ), + cast_to=BinaryAPIResponse, + ) + + def file_info( + self, + id: str, + *, + 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, + ) -> FFileInfoResponse: + """ + Get information about a file or directory + + Args: + path: Absolute path of the file or directory. + + 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( + path_template("/browsers/{id}/fs/file_info", id=id), + options=make_request_options( + extra_headers=extra_headers, + extra_query=extra_query, + extra_body=extra_body, + timeout=timeout, + query=maybe_transform({"path": path}, f_file_info_params.FFileInfoParams), + ), + cast_to=FFileInfoResponse, + ) + + def list_files( + self, + id: str, + *, + 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, + ) -> FListFilesResponse: + """ + List files in a directory + + Args: + path: Absolute directory path. + + 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( + path_template("/browsers/{id}/fs/list_files", id=id), + options=make_request_options( + extra_headers=extra_headers, + extra_query=extra_query, + extra_body=extra_body, + timeout=timeout, + query=maybe_transform({"path": path}, f_list_files_params.FListFilesParams), + ), + cast_to=FListFilesResponse, + ) + + def move( + self, + id: str, + *, + dest_path: str, + src_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, + ) -> None: + """ + Move or rename a file or directory + + Args: + dest_path: Absolute destination path. + + src_path: Absolute source path. + + 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}") + extra_headers = {"Accept": "*/*", **(extra_headers or {})} + return self._put( + path_template("/browsers/{id}/fs/move", id=id), + body=maybe_transform( + { + "dest_path": dest_path, + "src_path": src_path, + }, + f_move_params.FMoveParams, + ), + options=make_request_options( + extra_headers=extra_headers, extra_query=extra_query, extra_body=extra_body, timeout=timeout + ), + cast_to=NoneType, + ) + + def read_file( + self, + id: str, + *, + 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, + ) -> BinaryAPIResponse: + """ + Read file contents + + Args: + path: Absolute file path to read. + + 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}") + extra_headers = {"Accept": "application/octet-stream", **(extra_headers or {})} + return self._get( + path_template("/browsers/{id}/fs/read_file", id=id), + options=make_request_options( + extra_headers=extra_headers, + extra_query=extra_query, + extra_body=extra_body, + timeout=timeout, + query=maybe_transform({"path": path}, f_read_file_params.FReadFileParams), + ), + cast_to=BinaryAPIResponse, + ) + + def set_file_permissions( + self, + id: str, + *, + mode: str, + path: str, + group: str | Omit = omit, + owner: 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, + ) -> None: + """ + Set file or directory permissions/ownership + + Args: + mode: File mode bits (octal string, e.g. 644). + + path: Absolute path whose permissions are to be changed. + + group: New group name or GID. + + owner: New owner username or UID. + + 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}") + extra_headers = {"Accept": "*/*", **(extra_headers or {})} + return self._put( + path_template("/browsers/{id}/fs/set_file_permissions", id=id), + body=maybe_transform( + { + "mode": mode, + "path": path, + "group": group, + "owner": owner, + }, + f_set_file_permissions_params.FSetFilePermissionsParams, + ), + options=make_request_options( + extra_headers=extra_headers, extra_query=extra_query, extra_body=extra_body, timeout=timeout + ), + cast_to=NoneType, + ) + + def upload( + self, + id: str, + *, + files: Iterable[f_upload_params.File], + # 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, + ) -> None: + """ + Allows uploading single or multiple files to the remote filesystem. + + 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}") + extra_headers = {"Accept": "*/*", **(extra_headers or {})} + body = deepcopy_with_paths({"files": files}, [["files", "", "file"]]) + extracted_files = extract_files(cast(Mapping[str, object], body), paths=[["files", "", "file"]]) + # It should be noted that the actual Content-Type header that will be + # sent to the server will contain a `boundary` parameter, e.g. + # multipart/form-data; boundary=---abc-- + extra_headers["Content-Type"] = "multipart/form-data" + return self._post( + path_template("/browsers/{id}/fs/upload", id=id), + body=maybe_transform(body, f_upload_params.FUploadParams), + files=extracted_files, + options=make_request_options( + extra_headers=extra_headers, extra_query=extra_query, extra_body=extra_body, timeout=timeout + ), + cast_to=NoneType, + ) + + def upload_zip( + self, + id: str, + *, + dest_path: str, + zip_file: FileTypes, + # 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, + ) -> None: + """ + Upload a zip file and extract its contents to the specified destination path. + + Args: + dest_path: Absolute destination directory to extract the archive to. + + 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}") + extra_headers = {"Accept": "*/*", **(extra_headers or {})} + body = deepcopy_with_paths( + { + "dest_path": dest_path, + "zip_file": zip_file, + }, + [["zip_file"]], + ) + files = extract_files(cast(Mapping[str, object], body), paths=[["zip_file"]]) + # It should be noted that the actual Content-Type header that will be + # sent to the server will contain a `boundary` parameter, e.g. + # multipart/form-data; boundary=---abc-- + extra_headers["Content-Type"] = "multipart/form-data" + return self._post( + path_template("/browsers/{id}/fs/upload_zip", id=id), + body=maybe_transform(body, f_upload_zip_params.FUploadZipParams), + files=files, + options=make_request_options( + extra_headers=extra_headers, extra_query=extra_query, extra_body=extra_body, timeout=timeout + ), + cast_to=NoneType, + ) + + def write_file( + self, + id: str, + contents: FileContent | BinaryTypes, + *, + path: str, + mode: 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, + ) -> None: + """ + Write or create a file + + Args: + path: Destination absolute file path. + + mode: Optional file mode (octal string, e.g. 644). Defaults to 644. + + 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}") + extra_headers = {"Accept": "*/*", **(extra_headers or {})} + extra_headers["Content-Type"] = "application/octet-stream" + return self._put( + path_template("/browsers/{id}/fs/write_file", id=id), + content=read_file_content(contents) if isinstance(contents, os.PathLike) else contents, + options=make_request_options( + extra_headers=extra_headers, + extra_query=extra_query, + extra_body=extra_body, + timeout=timeout, + query=maybe_transform( + { + "path": path, + "mode": mode, + }, + f_write_file_params.FWriteFileParams, + ), + ), + cast_to=NoneType, + ) + + +class AsyncFsResource(AsyncAPIResource): + """Read, write, and manage files on the browser instance.""" + + @cached_property + def watch(self) -> AsyncWatchResource: + """Read, write, and manage files on the browser instance.""" + return AsyncWatchResource(self._client) + + @cached_property + def with_raw_response(self) -> AsyncFsResourceWithRawResponse: + """ + 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/kernel/kernel-python-sdk#accessing-raw-response-data-eg-headers + """ + return AsyncFsResourceWithRawResponse(self) + + @cached_property + def with_streaming_response(self) -> AsyncFsResourceWithStreamingResponse: + """ + An alternative to `.with_raw_response` that doesn't eagerly read the response body. + + For more information, see https://www.github.com/kernel/kernel-python-sdk#with_streaming_response + """ + return AsyncFsResourceWithStreamingResponse(self) + + async def create_directory( + self, + id: str, + *, + path: str, + mode: 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, + ) -> None: + """ + Create a new directory + + Args: + path: Absolute directory path to create. + + mode: Optional directory mode (octal string, e.g. 755). Defaults to 755. + + 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}") + extra_headers = {"Accept": "*/*", **(extra_headers or {})} + return await self._put( + path_template("/browsers/{id}/fs/create_directory", id=id), + body=await async_maybe_transform( + { + "path": path, + "mode": mode, + }, + f_create_directory_params.FCreateDirectoryParams, + ), + options=make_request_options( + extra_headers=extra_headers, extra_query=extra_query, extra_body=extra_body, timeout=timeout + ), + cast_to=NoneType, + ) + + async def delete_directory( + self, + id: str, + *, + 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, + ) -> None: + """ + Delete a directory + + Args: + path: Absolute path to delete. + + 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}") + extra_headers = {"Accept": "*/*", **(extra_headers or {})} + return await self._put( + path_template("/browsers/{id}/fs/delete_directory", id=id), + body=await async_maybe_transform({"path": path}, f_delete_directory_params.FDeleteDirectoryParams), + options=make_request_options( + extra_headers=extra_headers, extra_query=extra_query, extra_body=extra_body, timeout=timeout + ), + cast_to=NoneType, + ) + + async def delete_file( + self, + id: str, + *, + 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, + ) -> None: + """ + Delete a file + + Args: + path: Absolute path to delete. + + 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}") + extra_headers = {"Accept": "*/*", **(extra_headers or {})} + return await self._put( + path_template("/browsers/{id}/fs/delete_file", id=id), + body=await async_maybe_transform({"path": path}, f_delete_file_params.FDeleteFileParams), + options=make_request_options( + extra_headers=extra_headers, extra_query=extra_query, extra_body=extra_body, timeout=timeout + ), + cast_to=NoneType, + ) + + async def download_dir_zip( + self, + id: str, + *, + 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, + ) -> AsyncBinaryAPIResponse: + """ + Returns a ZIP file containing the contents of the specified directory. + + Args: + path: Absolute directory path to archive and download. + + 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}") + extra_headers = {"Accept": "application/zip", **(extra_headers or {})} + return await self._get( + path_template("/browsers/{id}/fs/download_dir_zip", id=id), + 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}, f_download_dir_zip_params.FDownloadDirZipParams), + ), + cast_to=AsyncBinaryAPIResponse, + ) + + async def file_info( + self, + id: str, + *, + 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, + ) -> FFileInfoResponse: + """ + Get information about a file or directory + + Args: + path: Absolute path of the file or directory. + + 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( + path_template("/browsers/{id}/fs/file_info", id=id), + 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}, f_file_info_params.FFileInfoParams), + ), + cast_to=FFileInfoResponse, + ) + + async def list_files( + self, + id: str, + *, + 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, + ) -> FListFilesResponse: + """ + List files in a directory + + Args: + path: Absolute directory path. + + 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( + path_template("/browsers/{id}/fs/list_files", id=id), + 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}, f_list_files_params.FListFilesParams), + ), + cast_to=FListFilesResponse, + ) + + async def move( + self, + id: str, + *, + dest_path: str, + src_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, + ) -> None: + """ + Move or rename a file or directory + + Args: + dest_path: Absolute destination path. + + src_path: Absolute source path. + + 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}") + extra_headers = {"Accept": "*/*", **(extra_headers or {})} + return await self._put( + path_template("/browsers/{id}/fs/move", id=id), + body=await async_maybe_transform( + { + "dest_path": dest_path, + "src_path": src_path, + }, + f_move_params.FMoveParams, + ), + options=make_request_options( + extra_headers=extra_headers, extra_query=extra_query, extra_body=extra_body, timeout=timeout + ), + cast_to=NoneType, + ) + + async def read_file( + self, + id: str, + *, + 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, + ) -> AsyncBinaryAPIResponse: + """ + Read file contents + + Args: + path: Absolute file path to read. + + 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}") + extra_headers = {"Accept": "application/octet-stream", **(extra_headers or {})} + return await self._get( + path_template("/browsers/{id}/fs/read_file", id=id), + 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}, f_read_file_params.FReadFileParams), + ), + cast_to=AsyncBinaryAPIResponse, + ) + + async def set_file_permissions( + self, + id: str, + *, + mode: str, + path: str, + group: str | Omit = omit, + owner: 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, + ) -> None: + """ + Set file or directory permissions/ownership + + Args: + mode: File mode bits (octal string, e.g. 644). + + path: Absolute path whose permissions are to be changed. + + group: New group name or GID. + + owner: New owner username or UID. + + 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}") + extra_headers = {"Accept": "*/*", **(extra_headers or {})} + return await self._put( + path_template("/browsers/{id}/fs/set_file_permissions", id=id), + body=await async_maybe_transform( + { + "mode": mode, + "path": path, + "group": group, + "owner": owner, + }, + f_set_file_permissions_params.FSetFilePermissionsParams, + ), + options=make_request_options( + extra_headers=extra_headers, extra_query=extra_query, extra_body=extra_body, timeout=timeout + ), + cast_to=NoneType, + ) + + async def upload( + self, + id: str, + *, + files: Iterable[f_upload_params.File], + # 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, + ) -> None: + """ + Allows uploading single or multiple files to the remote filesystem. + + 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}") + extra_headers = {"Accept": "*/*", **(extra_headers or {})} + body = deepcopy_with_paths({"files": files}, [["files", "", "file"]]) + extracted_files = extract_files(cast(Mapping[str, object], body), paths=[["files", "", "file"]]) + # It should be noted that the actual Content-Type header that will be + # sent to the server will contain a `boundary` parameter, e.g. + # multipart/form-data; boundary=---abc-- + extra_headers["Content-Type"] = "multipart/form-data" + return await self._post( + path_template("/browsers/{id}/fs/upload", id=id), + body=await async_maybe_transform(body, f_upload_params.FUploadParams), + files=extracted_files, + options=make_request_options( + extra_headers=extra_headers, extra_query=extra_query, extra_body=extra_body, timeout=timeout + ), + cast_to=NoneType, + ) + + async def upload_zip( + self, + id: str, + *, + dest_path: str, + zip_file: FileTypes, + # 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, + ) -> None: + """ + Upload a zip file and extract its contents to the specified destination path. + + Args: + dest_path: Absolute destination directory to extract the archive to. + + 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}") + extra_headers = {"Accept": "*/*", **(extra_headers or {})} + body = deepcopy_with_paths( + { + "dest_path": dest_path, + "zip_file": zip_file, + }, + [["zip_file"]], + ) + files = extract_files(cast(Mapping[str, object], body), paths=[["zip_file"]]) + # It should be noted that the actual Content-Type header that will be + # sent to the server will contain a `boundary` parameter, e.g. + # multipart/form-data; boundary=---abc-- + extra_headers["Content-Type"] = "multipart/form-data" + return await self._post( + path_template("/browsers/{id}/fs/upload_zip", id=id), + body=await async_maybe_transform(body, f_upload_zip_params.FUploadZipParams), + files=files, + options=make_request_options( + extra_headers=extra_headers, extra_query=extra_query, extra_body=extra_body, timeout=timeout + ), + cast_to=NoneType, + ) + + async def write_file( + self, + id: str, + contents: FileContent | AsyncBinaryTypes, + *, + path: str, + mode: 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, + ) -> None: + """ + Write or create a file + + Args: + path: Destination absolute file path. + + mode: Optional file mode (octal string, e.g. 644). Defaults to 644. + + 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}") + extra_headers = {"Accept": "*/*", **(extra_headers or {})} + extra_headers["Content-Type"] = "application/octet-stream" + return await self._put( + path_template("/browsers/{id}/fs/write_file", id=id), + content=await async_read_file_content(contents) if isinstance(contents, os.PathLike) else contents, + 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, + "mode": mode, + }, + f_write_file_params.FWriteFileParams, + ), + ), + cast_to=NoneType, + ) + + +class FsResourceWithRawResponse: + def __init__(self, fs: FsResource) -> None: + self._fs = fs + + self.create_directory = to_raw_response_wrapper( + fs.create_directory, + ) + self.delete_directory = to_raw_response_wrapper( + fs.delete_directory, + ) + self.delete_file = to_raw_response_wrapper( + fs.delete_file, + ) + self.download_dir_zip = to_custom_raw_response_wrapper( + fs.download_dir_zip, + BinaryAPIResponse, + ) + self.file_info = to_raw_response_wrapper( + fs.file_info, + ) + self.list_files = to_raw_response_wrapper( + fs.list_files, + ) + self.move = to_raw_response_wrapper( + fs.move, + ) + self.read_file = to_custom_raw_response_wrapper( + fs.read_file, + BinaryAPIResponse, + ) + self.set_file_permissions = to_raw_response_wrapper( + fs.set_file_permissions, + ) + self.upload = to_raw_response_wrapper( + fs.upload, + ) + self.upload_zip = to_raw_response_wrapper( + fs.upload_zip, + ) + self.write_file = to_raw_response_wrapper( + fs.write_file, + ) + + @cached_property + def watch(self) -> WatchResourceWithRawResponse: + """Read, write, and manage files on the browser instance.""" + return WatchResourceWithRawResponse(self._fs.watch) + + +class AsyncFsResourceWithRawResponse: + def __init__(self, fs: AsyncFsResource) -> None: + self._fs = fs + + self.create_directory = async_to_raw_response_wrapper( + fs.create_directory, + ) + self.delete_directory = async_to_raw_response_wrapper( + fs.delete_directory, + ) + self.delete_file = async_to_raw_response_wrapper( + fs.delete_file, + ) + self.download_dir_zip = async_to_custom_raw_response_wrapper( + fs.download_dir_zip, + AsyncBinaryAPIResponse, + ) + self.file_info = async_to_raw_response_wrapper( + fs.file_info, + ) + self.list_files = async_to_raw_response_wrapper( + fs.list_files, + ) + self.move = async_to_raw_response_wrapper( + fs.move, + ) + self.read_file = async_to_custom_raw_response_wrapper( + fs.read_file, + AsyncBinaryAPIResponse, + ) + self.set_file_permissions = async_to_raw_response_wrapper( + fs.set_file_permissions, + ) + self.upload = async_to_raw_response_wrapper( + fs.upload, + ) + self.upload_zip = async_to_raw_response_wrapper( + fs.upload_zip, + ) + self.write_file = async_to_raw_response_wrapper( + fs.write_file, + ) + + @cached_property + def watch(self) -> AsyncWatchResourceWithRawResponse: + """Read, write, and manage files on the browser instance.""" + return AsyncWatchResourceWithRawResponse(self._fs.watch) + + +class FsResourceWithStreamingResponse: + def __init__(self, fs: FsResource) -> None: + self._fs = fs + + self.create_directory = to_streamed_response_wrapper( + fs.create_directory, + ) + self.delete_directory = to_streamed_response_wrapper( + fs.delete_directory, + ) + self.delete_file = to_streamed_response_wrapper( + fs.delete_file, + ) + self.download_dir_zip = to_custom_streamed_response_wrapper( + fs.download_dir_zip, + StreamedBinaryAPIResponse, + ) + self.file_info = to_streamed_response_wrapper( + fs.file_info, + ) + self.list_files = to_streamed_response_wrapper( + fs.list_files, + ) + self.move = to_streamed_response_wrapper( + fs.move, + ) + self.read_file = to_custom_streamed_response_wrapper( + fs.read_file, + StreamedBinaryAPIResponse, + ) + self.set_file_permissions = to_streamed_response_wrapper( + fs.set_file_permissions, + ) + self.upload = to_streamed_response_wrapper( + fs.upload, + ) + self.upload_zip = to_streamed_response_wrapper( + fs.upload_zip, + ) + self.write_file = to_streamed_response_wrapper( + fs.write_file, + ) + + @cached_property + def watch(self) -> WatchResourceWithStreamingResponse: + """Read, write, and manage files on the browser instance.""" + return WatchResourceWithStreamingResponse(self._fs.watch) + + +class AsyncFsResourceWithStreamingResponse: + def __init__(self, fs: AsyncFsResource) -> None: + self._fs = fs + + self.create_directory = async_to_streamed_response_wrapper( + fs.create_directory, + ) + self.delete_directory = async_to_streamed_response_wrapper( + fs.delete_directory, + ) + self.delete_file = async_to_streamed_response_wrapper( + fs.delete_file, + ) + self.download_dir_zip = async_to_custom_streamed_response_wrapper( + fs.download_dir_zip, + AsyncStreamedBinaryAPIResponse, + ) + self.file_info = async_to_streamed_response_wrapper( + fs.file_info, + ) + self.list_files = async_to_streamed_response_wrapper( + fs.list_files, + ) + self.move = async_to_streamed_response_wrapper( + fs.move, + ) + self.read_file = async_to_custom_streamed_response_wrapper( + fs.read_file, + AsyncStreamedBinaryAPIResponse, + ) + self.set_file_permissions = async_to_streamed_response_wrapper( + fs.set_file_permissions, + ) + self.upload = async_to_streamed_response_wrapper( + fs.upload, + ) + self.upload_zip = async_to_streamed_response_wrapper( + fs.upload_zip, + ) + self.write_file = async_to_streamed_response_wrapper( + fs.write_file, + ) + + @cached_property + def watch(self) -> AsyncWatchResourceWithStreamingResponse: + """Read, write, and manage files on the browser instance.""" + return AsyncWatchResourceWithStreamingResponse(self._fs.watch) diff --git a/src/kernel/resources/browsers/fs/watch.py b/src/kernel/resources/browsers/fs/watch.py new file mode 100644 index 00000000..bc046053 --- /dev/null +++ b/src/kernel/resources/browsers/fs/watch.py @@ -0,0 +1,373 @@ +# File generated from our OpenAPI spec by Stainless. See CONTRIBUTING.md for details. + +from __future__ import annotations + +import httpx + +from ...._types import Body, Omit, Query, Headers, NoneType, NotGiven, omit, not_given +from ...._utils import path_template, 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.browsers.fs import watch_start_params +from ....types.browsers.fs.watch_start_response import WatchStartResponse +from ....types.browsers.fs.watch_events_response import WatchEventsResponse + +__all__ = ["WatchResource", "AsyncWatchResource"] + + +class WatchResource(SyncAPIResource): + """Read, write, and manage files on the browser instance.""" + + @cached_property + def with_raw_response(self) -> WatchResourceWithRawResponse: + """ + 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/kernel/kernel-python-sdk#accessing-raw-response-data-eg-headers + """ + return WatchResourceWithRawResponse(self) + + @cached_property + def with_streaming_response(self) -> WatchResourceWithStreamingResponse: + """ + An alternative to `.with_raw_response` that doesn't eagerly read the response body. + + For more information, see https://www.github.com/kernel/kernel-python-sdk#with_streaming_response + """ + return WatchResourceWithStreamingResponse(self) + + def events( + self, + watch_id: str, + *, + 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, + ) -> Stream[WatchEventsResponse]: + """ + Stream filesystem events for a watch + + 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 watch_id: + raise ValueError(f"Expected a non-empty value for `watch_id` but received {watch_id!r}") + extra_headers = {"Accept": "text/event-stream", **(extra_headers or {})} + return self._get( + path_template("/browsers/{id}/fs/watch/{watch_id}/events", id=id, watch_id=watch_id), + options=make_request_options( + extra_headers=extra_headers, extra_query=extra_query, extra_body=extra_body, timeout=timeout + ), + cast_to=WatchEventsResponse, + stream=True, + stream_cls=Stream[WatchEventsResponse], + ) + + def start( + self, + id: str, + *, + path: str, + recursive: 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, + ) -> WatchStartResponse: + """ + Watch a directory for changes + + Args: + path: Directory to watch. + + recursive: Whether to watch recursively. + + 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( + path_template("/browsers/{id}/fs/watch", id=id), + body=maybe_transform( + { + "path": path, + "recursive": recursive, + }, + watch_start_params.WatchStartParams, + ), + options=make_request_options( + extra_headers=extra_headers, extra_query=extra_query, extra_body=extra_body, timeout=timeout + ), + cast_to=WatchStartResponse, + ) + + def stop( + self, + watch_id: str, + *, + 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, + ) -> None: + """ + Stop watching a directory + + 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 watch_id: + raise ValueError(f"Expected a non-empty value for `watch_id` but received {watch_id!r}") + extra_headers = {"Accept": "*/*", **(extra_headers or {})} + return self._delete( + path_template("/browsers/{id}/fs/watch/{watch_id}", id=id, watch_id=watch_id), + options=make_request_options( + extra_headers=extra_headers, extra_query=extra_query, extra_body=extra_body, timeout=timeout + ), + cast_to=NoneType, + ) + + +class AsyncWatchResource(AsyncAPIResource): + """Read, write, and manage files on the browser instance.""" + + @cached_property + def with_raw_response(self) -> AsyncWatchResourceWithRawResponse: + """ + 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/kernel/kernel-python-sdk#accessing-raw-response-data-eg-headers + """ + return AsyncWatchResourceWithRawResponse(self) + + @cached_property + def with_streaming_response(self) -> AsyncWatchResourceWithStreamingResponse: + """ + An alternative to `.with_raw_response` that doesn't eagerly read the response body. + + For more information, see https://www.github.com/kernel/kernel-python-sdk#with_streaming_response + """ + return AsyncWatchResourceWithStreamingResponse(self) + + async def events( + self, + watch_id: str, + *, + 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, + ) -> AsyncStream[WatchEventsResponse]: + """ + Stream filesystem events for a watch + + 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 watch_id: + raise ValueError(f"Expected a non-empty value for `watch_id` but received {watch_id!r}") + extra_headers = {"Accept": "text/event-stream", **(extra_headers or {})} + return await self._get( + path_template("/browsers/{id}/fs/watch/{watch_id}/events", id=id, watch_id=watch_id), + options=make_request_options( + extra_headers=extra_headers, extra_query=extra_query, extra_body=extra_body, timeout=timeout + ), + cast_to=WatchEventsResponse, + stream=True, + stream_cls=AsyncStream[WatchEventsResponse], + ) + + async def start( + self, + id: str, + *, + path: str, + recursive: 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, + ) -> WatchStartResponse: + """ + Watch a directory for changes + + Args: + path: Directory to watch. + + recursive: Whether to watch recursively. + + 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( + path_template("/browsers/{id}/fs/watch", id=id), + body=await async_maybe_transform( + { + "path": path, + "recursive": recursive, + }, + watch_start_params.WatchStartParams, + ), + options=make_request_options( + extra_headers=extra_headers, extra_query=extra_query, extra_body=extra_body, timeout=timeout + ), + cast_to=WatchStartResponse, + ) + + async def stop( + self, + watch_id: str, + *, + 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, + ) -> None: + """ + Stop watching a directory + + 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 watch_id: + raise ValueError(f"Expected a non-empty value for `watch_id` but received {watch_id!r}") + extra_headers = {"Accept": "*/*", **(extra_headers or {})} + return await self._delete( + path_template("/browsers/{id}/fs/watch/{watch_id}", id=id, watch_id=watch_id), + options=make_request_options( + extra_headers=extra_headers, extra_query=extra_query, extra_body=extra_body, timeout=timeout + ), + cast_to=NoneType, + ) + + +class WatchResourceWithRawResponse: + def __init__(self, watch: WatchResource) -> None: + self._watch = watch + + self.events = to_raw_response_wrapper( + watch.events, + ) + self.start = to_raw_response_wrapper( + watch.start, + ) + self.stop = to_raw_response_wrapper( + watch.stop, + ) + + +class AsyncWatchResourceWithRawResponse: + def __init__(self, watch: AsyncWatchResource) -> None: + self._watch = watch + + self.events = async_to_raw_response_wrapper( + watch.events, + ) + self.start = async_to_raw_response_wrapper( + watch.start, + ) + self.stop = async_to_raw_response_wrapper( + watch.stop, + ) + + +class WatchResourceWithStreamingResponse: + def __init__(self, watch: WatchResource) -> None: + self._watch = watch + + self.events = to_streamed_response_wrapper( + watch.events, + ) + self.start = to_streamed_response_wrapper( + watch.start, + ) + self.stop = to_streamed_response_wrapper( + watch.stop, + ) + + +class AsyncWatchResourceWithStreamingResponse: + def __init__(self, watch: AsyncWatchResource) -> None: + self._watch = watch + + self.events = async_to_streamed_response_wrapper( + watch.events, + ) + self.start = async_to_streamed_response_wrapper( + watch.start, + ) + self.stop = async_to_streamed_response_wrapper( + watch.stop, + ) diff --git a/src/kernel/resources/browsers/logs.py b/src/kernel/resources/browsers/logs.py new file mode 100644 index 00000000..35ee66dc --- /dev/null +++ b/src/kernel/resources/browsers/logs.py @@ -0,0 +1,218 @@ +# 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 Body, Omit, Query, Headers, NotGiven, omit, not_given +from ..._utils import path_template, 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.browsers import log_stream_params +from ...types.shared.log_event import LogEvent + +__all__ = ["LogsResource", "AsyncLogsResource"] + + +class LogsResource(SyncAPIResource): + """Stream logs from the browser instance.""" + + @cached_property + def with_raw_response(self) -> LogsResourceWithRawResponse: + """ + 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/kernel/kernel-python-sdk#accessing-raw-response-data-eg-headers + """ + return LogsResourceWithRawResponse(self) + + @cached_property + def with_streaming_response(self) -> LogsResourceWithStreamingResponse: + """ + An alternative to `.with_raw_response` that doesn't eagerly read the response body. + + For more information, see https://www.github.com/kernel/kernel-python-sdk#with_streaming_response + """ + return LogsResourceWithStreamingResponse(self) + + def stream( + self, + id: str, + *, + source: Literal["path", "supervisor"], + follow: bool | Omit = omit, + path: str | Omit = omit, + supervisor_process: 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, + ) -> Stream[LogEvent]: + """ + Stream log files on the browser instance via SSE + + Args: + path: only required if source is path + + supervisor_process: only required if source is supervisor + + 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}") + extra_headers = {"Accept": "text/event-stream", **(extra_headers or {})} + return self._get( + path_template("/browsers/{id}/logs/stream", id=id), + options=make_request_options( + extra_headers=extra_headers, + extra_query=extra_query, + extra_body=extra_body, + timeout=timeout, + query=maybe_transform( + { + "source": source, + "follow": follow, + "path": path, + "supervisor_process": supervisor_process, + }, + log_stream_params.LogStreamParams, + ), + ), + cast_to=LogEvent, + stream=True, + stream_cls=Stream[LogEvent], + ) + + +class AsyncLogsResource(AsyncAPIResource): + """Stream logs from the browser instance.""" + + @cached_property + def with_raw_response(self) -> AsyncLogsResourceWithRawResponse: + """ + 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/kernel/kernel-python-sdk#accessing-raw-response-data-eg-headers + """ + return AsyncLogsResourceWithRawResponse(self) + + @cached_property + def with_streaming_response(self) -> AsyncLogsResourceWithStreamingResponse: + """ + An alternative to `.with_raw_response` that doesn't eagerly read the response body. + + For more information, see https://www.github.com/kernel/kernel-python-sdk#with_streaming_response + """ + return AsyncLogsResourceWithStreamingResponse(self) + + async def stream( + self, + id: str, + *, + source: Literal["path", "supervisor"], + follow: bool | Omit = omit, + path: str | Omit = omit, + supervisor_process: 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, + ) -> AsyncStream[LogEvent]: + """ + Stream log files on the browser instance via SSE + + Args: + path: only required if source is path + + supervisor_process: only required if source is supervisor + + 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}") + extra_headers = {"Accept": "text/event-stream", **(extra_headers or {})} + return await self._get( + path_template("/browsers/{id}/logs/stream", id=id), + options=make_request_options( + extra_headers=extra_headers, + extra_query=extra_query, + extra_body=extra_body, + timeout=timeout, + query=await async_maybe_transform( + { + "source": source, + "follow": follow, + "path": path, + "supervisor_process": supervisor_process, + }, + log_stream_params.LogStreamParams, + ), + ), + cast_to=LogEvent, + stream=True, + stream_cls=AsyncStream[LogEvent], + ) + + +class LogsResourceWithRawResponse: + def __init__(self, logs: LogsResource) -> None: + self._logs = logs + + self.stream = to_raw_response_wrapper( + logs.stream, + ) + + +class AsyncLogsResourceWithRawResponse: + def __init__(self, logs: AsyncLogsResource) -> None: + self._logs = logs + + self.stream = async_to_raw_response_wrapper( + logs.stream, + ) + + +class LogsResourceWithStreamingResponse: + def __init__(self, logs: LogsResource) -> None: + self._logs = logs + + self.stream = to_streamed_response_wrapper( + logs.stream, + ) + + +class AsyncLogsResourceWithStreamingResponse: + def __init__(self, logs: AsyncLogsResource) -> None: + self._logs = logs + + self.stream = async_to_streamed_response_wrapper( + logs.stream, + ) diff --git a/src/kernel/resources/browsers/playwright.py b/src/kernel/resources/browsers/playwright.py new file mode 100644 index 00000000..8d261ed5 --- /dev/null +++ b/src/kernel/resources/browsers/playwright.py @@ -0,0 +1,209 @@ +# File generated from our OpenAPI spec by Stainless. See CONTRIBUTING.md for details. + +from __future__ import annotations + +import httpx + +from ..._types import Body, Omit, Query, Headers, NotGiven, omit, not_given +from ..._utils import path_template, 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.browsers import playwright_execute_params +from ...types.browsers.playwright_execute_response import PlaywrightExecuteResponse + +__all__ = ["PlaywrightResource", "AsyncPlaywrightResource"] + + +class PlaywrightResource(SyncAPIResource): + """Execute Playwright code against the browser instance.""" + + @cached_property + def with_raw_response(self) -> PlaywrightResourceWithRawResponse: + """ + 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/kernel/kernel-python-sdk#accessing-raw-response-data-eg-headers + """ + return PlaywrightResourceWithRawResponse(self) + + @cached_property + def with_streaming_response(self) -> PlaywrightResourceWithStreamingResponse: + """ + An alternative to `.with_raw_response` that doesn't eagerly read the response body. + + For more information, see https://www.github.com/kernel/kernel-python-sdk#with_streaming_response + """ + return PlaywrightResourceWithStreamingResponse(self) + + def execute( + self, + id: str, + *, + code: str, + timeout_sec: int | 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, + ) -> PlaywrightExecuteResponse: + """ + Execute arbitrary Playwright code in a fresh execution context against the + browser. The code runs in the same VM as the browser, minimizing latency and + maximizing throughput. It has access to 'page', 'context', and 'browser' + variables. It can `return` a value, and this value is returned in the response. + + Args: + code: TypeScript/JavaScript code to execute. The code has access to 'page', 'context', + and 'browser' variables. It runs within a function, so you can use a return + statement at the end to return a value. This value is returned as the `result` + property in the response. Example: "await page.goto('https://example.com'); + return await page.title();" + + timeout_sec: Maximum execution time in seconds. Default is 60. + + 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( + path_template("/browsers/{id}/playwright/execute", id=id), + body=maybe_transform( + { + "code": code, + "timeout_sec": timeout_sec, + }, + playwright_execute_params.PlaywrightExecuteParams, + ), + options=make_request_options( + extra_headers=extra_headers, extra_query=extra_query, extra_body=extra_body, timeout=timeout + ), + cast_to=PlaywrightExecuteResponse, + ) + + +class AsyncPlaywrightResource(AsyncAPIResource): + """Execute Playwright code against the browser instance.""" + + @cached_property + def with_raw_response(self) -> AsyncPlaywrightResourceWithRawResponse: + """ + 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/kernel/kernel-python-sdk#accessing-raw-response-data-eg-headers + """ + return AsyncPlaywrightResourceWithRawResponse(self) + + @cached_property + def with_streaming_response(self) -> AsyncPlaywrightResourceWithStreamingResponse: + """ + An alternative to `.with_raw_response` that doesn't eagerly read the response body. + + For more information, see https://www.github.com/kernel/kernel-python-sdk#with_streaming_response + """ + return AsyncPlaywrightResourceWithStreamingResponse(self) + + async def execute( + self, + id: str, + *, + code: str, + timeout_sec: int | 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, + ) -> PlaywrightExecuteResponse: + """ + Execute arbitrary Playwright code in a fresh execution context against the + browser. The code runs in the same VM as the browser, minimizing latency and + maximizing throughput. It has access to 'page', 'context', and 'browser' + variables. It can `return` a value, and this value is returned in the response. + + Args: + code: TypeScript/JavaScript code to execute. The code has access to 'page', 'context', + and 'browser' variables. It runs within a function, so you can use a return + statement at the end to return a value. This value is returned as the `result` + property in the response. Example: "await page.goto('https://example.com'); + return await page.title();" + + timeout_sec: Maximum execution time in seconds. Default is 60. + + 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( + path_template("/browsers/{id}/playwright/execute", id=id), + body=await async_maybe_transform( + { + "code": code, + "timeout_sec": timeout_sec, + }, + playwright_execute_params.PlaywrightExecuteParams, + ), + options=make_request_options( + extra_headers=extra_headers, extra_query=extra_query, extra_body=extra_body, timeout=timeout + ), + cast_to=PlaywrightExecuteResponse, + ) + + +class PlaywrightResourceWithRawResponse: + def __init__(self, playwright: PlaywrightResource) -> None: + self._playwright = playwright + + self.execute = to_raw_response_wrapper( + playwright.execute, + ) + + +class AsyncPlaywrightResourceWithRawResponse: + def __init__(self, playwright: AsyncPlaywrightResource) -> None: + self._playwright = playwright + + self.execute = async_to_raw_response_wrapper( + playwright.execute, + ) + + +class PlaywrightResourceWithStreamingResponse: + def __init__(self, playwright: PlaywrightResource) -> None: + self._playwright = playwright + + self.execute = to_streamed_response_wrapper( + playwright.execute, + ) + + +class AsyncPlaywrightResourceWithStreamingResponse: + def __init__(self, playwright: AsyncPlaywrightResource) -> None: + self._playwright = playwright + + self.execute = async_to_streamed_response_wrapper( + playwright.execute, + ) diff --git a/src/kernel/resources/browsers/process.py b/src/kernel/resources/browsers/process.py new file mode 100644 index 00000000..83827d38 --- /dev/null +++ b/src/kernel/resources/browsers/process.py @@ -0,0 +1,887 @@ +# File generated from our OpenAPI spec by Stainless. See CONTRIBUTING.md for details. + +from __future__ import annotations + +from typing import Dict, Optional +from typing_extensions import Literal + +import httpx + +from ..._types import Body, Omit, Query, Headers, NotGiven, SequenceNotStr, omit, not_given +from ..._utils import path_template, 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.browsers import ( + process_exec_params, + process_kill_params, + process_spawn_params, + process_stdin_params, + process_resize_params, +) +from ...types.browsers.process_exec_response import ProcessExecResponse +from ...types.browsers.process_kill_response import ProcessKillResponse +from ...types.browsers.process_spawn_response import ProcessSpawnResponse +from ...types.browsers.process_stdin_response import ProcessStdinResponse +from ...types.browsers.process_resize_response import ProcessResizeResponse +from ...types.browsers.process_status_response import ProcessStatusResponse +from ...types.browsers.process_stdout_stream_response import ProcessStdoutStreamResponse + +__all__ = ["ProcessResource", "AsyncProcessResource"] + + +class ProcessResource(SyncAPIResource): + """Execute and manage processes on the browser instance.""" + + @cached_property + def with_raw_response(self) -> ProcessResourceWithRawResponse: + """ + 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/kernel/kernel-python-sdk#accessing-raw-response-data-eg-headers + """ + return ProcessResourceWithRawResponse(self) + + @cached_property + def with_streaming_response(self) -> ProcessResourceWithStreamingResponse: + """ + An alternative to `.with_raw_response` that doesn't eagerly read the response body. + + For more information, see https://www.github.com/kernel/kernel-python-sdk#with_streaming_response + """ + return ProcessResourceWithStreamingResponse(self) + + def exec( + self, + id: str, + *, + command: str, + args: SequenceNotStr[str] | Omit = omit, + as_root: bool | Omit = omit, + as_user: Optional[str] | Omit = omit, + cwd: Optional[str] | Omit = omit, + env: Dict[str, str] | Omit = omit, + timeout_sec: Optional[int] | 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, + ) -> ProcessExecResponse: + """ + Execute a command synchronously + + Args: + command: Executable or shell command to run. + + args: Command arguments. + + as_root: Run the process with root privileges. + + as_user: Run the process as this user. + + cwd: Working directory (absolute path) to run the command in. + + env: Environment variables to set for the process. + + timeout_sec: Maximum execution time in seconds. + + 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( + path_template("/browsers/{id}/process/exec", id=id), + body=maybe_transform( + { + "command": command, + "args": args, + "as_root": as_root, + "as_user": as_user, + "cwd": cwd, + "env": env, + "timeout_sec": timeout_sec, + }, + process_exec_params.ProcessExecParams, + ), + options=make_request_options( + extra_headers=extra_headers, extra_query=extra_query, extra_body=extra_body, timeout=timeout + ), + cast_to=ProcessExecResponse, + ) + + def kill( + self, + process_id: str, + *, + id: str, + signal: Literal["TERM", "KILL", "INT", "HUP"], + # 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, + ) -> ProcessKillResponse: + """ + Send signal to process + + Args: + signal: Signal to send. + + 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 process_id: + raise ValueError(f"Expected a non-empty value for `process_id` but received {process_id!r}") + return self._post( + path_template("/browsers/{id}/process/{process_id}/kill", id=id, process_id=process_id), + body=maybe_transform({"signal": signal}, process_kill_params.ProcessKillParams), + options=make_request_options( + extra_headers=extra_headers, extra_query=extra_query, extra_body=extra_body, timeout=timeout + ), + cast_to=ProcessKillResponse, + ) + + def resize( + self, + process_id: str, + *, + id: str, + cols: int, + rows: int, + # 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, + ) -> ProcessResizeResponse: + """ + Resize a PTY-backed process terminal + + Args: + cols: New terminal columns. + + rows: New terminal rows. + + 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 process_id: + raise ValueError(f"Expected a non-empty value for `process_id` but received {process_id!r}") + return self._post( + path_template("/browsers/{id}/process/{process_id}/resize", id=id, process_id=process_id), + body=maybe_transform( + { + "cols": cols, + "rows": rows, + }, + process_resize_params.ProcessResizeParams, + ), + options=make_request_options( + extra_headers=extra_headers, extra_query=extra_query, extra_body=extra_body, timeout=timeout + ), + cast_to=ProcessResizeResponse, + ) + + def spawn( + self, + id: str, + *, + command: str, + allocate_tty: bool | Omit = omit, + args: SequenceNotStr[str] | Omit = omit, + as_root: bool | Omit = omit, + as_user: Optional[str] | Omit = omit, + cols: int | Omit = omit, + cwd: Optional[str] | Omit = omit, + env: Dict[str, str] | Omit = omit, + rows: int | Omit = omit, + timeout_sec: Optional[int] | 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, + ) -> ProcessSpawnResponse: + """ + Execute a command asynchronously + + Args: + command: Executable or shell command to run. + + allocate_tty: Allocate a pseudo-terminal (PTY) for interactive shells. + + args: Command arguments. + + as_root: Run the process with root privileges. + + as_user: Run the process as this user. + + cols: Initial terminal columns. Only used when allocate_tty is true. + + cwd: Working directory (absolute path) to run the command in. + + env: Environment variables to set for the process. + + rows: Initial terminal rows. Only used when allocate_tty is true. + + timeout_sec: Maximum execution time in seconds. + + 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( + path_template("/browsers/{id}/process/spawn", id=id), + body=maybe_transform( + { + "command": command, + "allocate_tty": allocate_tty, + "args": args, + "as_root": as_root, + "as_user": as_user, + "cols": cols, + "cwd": cwd, + "env": env, + "rows": rows, + "timeout_sec": timeout_sec, + }, + process_spawn_params.ProcessSpawnParams, + ), + options=make_request_options( + extra_headers=extra_headers, extra_query=extra_query, extra_body=extra_body, timeout=timeout + ), + cast_to=ProcessSpawnResponse, + ) + + def status( + self, + process_id: str, + *, + 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, + ) -> ProcessStatusResponse: + """ + Get process 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 + """ + if not id: + raise ValueError(f"Expected a non-empty value for `id` but received {id!r}") + if not process_id: + raise ValueError(f"Expected a non-empty value for `process_id` but received {process_id!r}") + return self._get( + path_template("/browsers/{id}/process/{process_id}/status", id=id, process_id=process_id), + options=make_request_options( + extra_headers=extra_headers, extra_query=extra_query, extra_body=extra_body, timeout=timeout + ), + cast_to=ProcessStatusResponse, + ) + + def stdin( + self, + process_id: str, + *, + id: str, + data_b64: 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, + ) -> ProcessStdinResponse: + """ + Write to process stdin + + Args: + data_b64: Base64-encoded data to write. + + 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 process_id: + raise ValueError(f"Expected a non-empty value for `process_id` but received {process_id!r}") + return self._post( + path_template("/browsers/{id}/process/{process_id}/stdin", id=id, process_id=process_id), + body=maybe_transform({"data_b64": data_b64}, process_stdin_params.ProcessStdinParams), + options=make_request_options( + extra_headers=extra_headers, extra_query=extra_query, extra_body=extra_body, timeout=timeout + ), + cast_to=ProcessStdinResponse, + ) + + def stdout_stream( + self, + process_id: str, + *, + 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, + ) -> Stream[ProcessStdoutStreamResponse]: + """ + Stream process stdout via SSE + + 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 process_id: + raise ValueError(f"Expected a non-empty value for `process_id` but received {process_id!r}") + extra_headers = {"Accept": "text/event-stream", **(extra_headers or {})} + return self._get( + path_template("/browsers/{id}/process/{process_id}/stdout/stream", id=id, process_id=process_id), + options=make_request_options( + extra_headers=extra_headers, extra_query=extra_query, extra_body=extra_body, timeout=timeout + ), + cast_to=ProcessStdoutStreamResponse, + stream=True, + stream_cls=Stream[ProcessStdoutStreamResponse], + ) + + +class AsyncProcessResource(AsyncAPIResource): + """Execute and manage processes on the browser instance.""" + + @cached_property + def with_raw_response(self) -> AsyncProcessResourceWithRawResponse: + """ + 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/kernel/kernel-python-sdk#accessing-raw-response-data-eg-headers + """ + return AsyncProcessResourceWithRawResponse(self) + + @cached_property + def with_streaming_response(self) -> AsyncProcessResourceWithStreamingResponse: + """ + An alternative to `.with_raw_response` that doesn't eagerly read the response body. + + For more information, see https://www.github.com/kernel/kernel-python-sdk#with_streaming_response + """ + return AsyncProcessResourceWithStreamingResponse(self) + + async def exec( + self, + id: str, + *, + command: str, + args: SequenceNotStr[str] | Omit = omit, + as_root: bool | Omit = omit, + as_user: Optional[str] | Omit = omit, + cwd: Optional[str] | Omit = omit, + env: Dict[str, str] | Omit = omit, + timeout_sec: Optional[int] | 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, + ) -> ProcessExecResponse: + """ + Execute a command synchronously + + Args: + command: Executable or shell command to run. + + args: Command arguments. + + as_root: Run the process with root privileges. + + as_user: Run the process as this user. + + cwd: Working directory (absolute path) to run the command in. + + env: Environment variables to set for the process. + + timeout_sec: Maximum execution time in seconds. + + 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( + path_template("/browsers/{id}/process/exec", id=id), + body=await async_maybe_transform( + { + "command": command, + "args": args, + "as_root": as_root, + "as_user": as_user, + "cwd": cwd, + "env": env, + "timeout_sec": timeout_sec, + }, + process_exec_params.ProcessExecParams, + ), + options=make_request_options( + extra_headers=extra_headers, extra_query=extra_query, extra_body=extra_body, timeout=timeout + ), + cast_to=ProcessExecResponse, + ) + + async def kill( + self, + process_id: str, + *, + id: str, + signal: Literal["TERM", "KILL", "INT", "HUP"], + # 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, + ) -> ProcessKillResponse: + """ + Send signal to process + + Args: + signal: Signal to send. + + 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 process_id: + raise ValueError(f"Expected a non-empty value for `process_id` but received {process_id!r}") + return await self._post( + path_template("/browsers/{id}/process/{process_id}/kill", id=id, process_id=process_id), + body=await async_maybe_transform({"signal": signal}, process_kill_params.ProcessKillParams), + options=make_request_options( + extra_headers=extra_headers, extra_query=extra_query, extra_body=extra_body, timeout=timeout + ), + cast_to=ProcessKillResponse, + ) + + async def resize( + self, + process_id: str, + *, + id: str, + cols: int, + rows: int, + # 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, + ) -> ProcessResizeResponse: + """ + Resize a PTY-backed process terminal + + Args: + cols: New terminal columns. + + rows: New terminal rows. + + 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 process_id: + raise ValueError(f"Expected a non-empty value for `process_id` but received {process_id!r}") + return await self._post( + path_template("/browsers/{id}/process/{process_id}/resize", id=id, process_id=process_id), + body=await async_maybe_transform( + { + "cols": cols, + "rows": rows, + }, + process_resize_params.ProcessResizeParams, + ), + options=make_request_options( + extra_headers=extra_headers, extra_query=extra_query, extra_body=extra_body, timeout=timeout + ), + cast_to=ProcessResizeResponse, + ) + + async def spawn( + self, + id: str, + *, + command: str, + allocate_tty: bool | Omit = omit, + args: SequenceNotStr[str] | Omit = omit, + as_root: bool | Omit = omit, + as_user: Optional[str] | Omit = omit, + cols: int | Omit = omit, + cwd: Optional[str] | Omit = omit, + env: Dict[str, str] | Omit = omit, + rows: int | Omit = omit, + timeout_sec: Optional[int] | 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, + ) -> ProcessSpawnResponse: + """ + Execute a command asynchronously + + Args: + command: Executable or shell command to run. + + allocate_tty: Allocate a pseudo-terminal (PTY) for interactive shells. + + args: Command arguments. + + as_root: Run the process with root privileges. + + as_user: Run the process as this user. + + cols: Initial terminal columns. Only used when allocate_tty is true. + + cwd: Working directory (absolute path) to run the command in. + + env: Environment variables to set for the process. + + rows: Initial terminal rows. Only used when allocate_tty is true. + + timeout_sec: Maximum execution time in seconds. + + 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( + path_template("/browsers/{id}/process/spawn", id=id), + body=await async_maybe_transform( + { + "command": command, + "allocate_tty": allocate_tty, + "args": args, + "as_root": as_root, + "as_user": as_user, + "cols": cols, + "cwd": cwd, + "env": env, + "rows": rows, + "timeout_sec": timeout_sec, + }, + process_spawn_params.ProcessSpawnParams, + ), + options=make_request_options( + extra_headers=extra_headers, extra_query=extra_query, extra_body=extra_body, timeout=timeout + ), + cast_to=ProcessSpawnResponse, + ) + + async def status( + self, + process_id: str, + *, + 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, + ) -> ProcessStatusResponse: + """ + Get process 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 + """ + if not id: + raise ValueError(f"Expected a non-empty value for `id` but received {id!r}") + if not process_id: + raise ValueError(f"Expected a non-empty value for `process_id` but received {process_id!r}") + return await self._get( + path_template("/browsers/{id}/process/{process_id}/status", id=id, process_id=process_id), + options=make_request_options( + extra_headers=extra_headers, extra_query=extra_query, extra_body=extra_body, timeout=timeout + ), + cast_to=ProcessStatusResponse, + ) + + async def stdin( + self, + process_id: str, + *, + id: str, + data_b64: 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, + ) -> ProcessStdinResponse: + """ + Write to process stdin + + Args: + data_b64: Base64-encoded data to write. + + 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 process_id: + raise ValueError(f"Expected a non-empty value for `process_id` but received {process_id!r}") + return await self._post( + path_template("/browsers/{id}/process/{process_id}/stdin", id=id, process_id=process_id), + body=await async_maybe_transform({"data_b64": data_b64}, process_stdin_params.ProcessStdinParams), + options=make_request_options( + extra_headers=extra_headers, extra_query=extra_query, extra_body=extra_body, timeout=timeout + ), + cast_to=ProcessStdinResponse, + ) + + async def stdout_stream( + self, + process_id: str, + *, + 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, + ) -> AsyncStream[ProcessStdoutStreamResponse]: + """ + Stream process stdout via SSE + + 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 process_id: + raise ValueError(f"Expected a non-empty value for `process_id` but received {process_id!r}") + extra_headers = {"Accept": "text/event-stream", **(extra_headers or {})} + return await self._get( + path_template("/browsers/{id}/process/{process_id}/stdout/stream", id=id, process_id=process_id), + options=make_request_options( + extra_headers=extra_headers, extra_query=extra_query, extra_body=extra_body, timeout=timeout + ), + cast_to=ProcessStdoutStreamResponse, + stream=True, + stream_cls=AsyncStream[ProcessStdoutStreamResponse], + ) + + +class ProcessResourceWithRawResponse: + def __init__(self, process: ProcessResource) -> None: + self._process = process + + self.exec = to_raw_response_wrapper( + process.exec, + ) + self.kill = to_raw_response_wrapper( + process.kill, + ) + self.resize = to_raw_response_wrapper( + process.resize, + ) + self.spawn = to_raw_response_wrapper( + process.spawn, + ) + self.status = to_raw_response_wrapper( + process.status, + ) + self.stdin = to_raw_response_wrapper( + process.stdin, + ) + self.stdout_stream = to_raw_response_wrapper( + process.stdout_stream, + ) + + +class AsyncProcessResourceWithRawResponse: + def __init__(self, process: AsyncProcessResource) -> None: + self._process = process + + self.exec = async_to_raw_response_wrapper( + process.exec, + ) + self.kill = async_to_raw_response_wrapper( + process.kill, + ) + self.resize = async_to_raw_response_wrapper( + process.resize, + ) + self.spawn = async_to_raw_response_wrapper( + process.spawn, + ) + self.status = async_to_raw_response_wrapper( + process.status, + ) + self.stdin = async_to_raw_response_wrapper( + process.stdin, + ) + self.stdout_stream = async_to_raw_response_wrapper( + process.stdout_stream, + ) + + +class ProcessResourceWithStreamingResponse: + def __init__(self, process: ProcessResource) -> None: + self._process = process + + self.exec = to_streamed_response_wrapper( + process.exec, + ) + self.kill = to_streamed_response_wrapper( + process.kill, + ) + self.resize = to_streamed_response_wrapper( + process.resize, + ) + self.spawn = to_streamed_response_wrapper( + process.spawn, + ) + self.status = to_streamed_response_wrapper( + process.status, + ) + self.stdin = to_streamed_response_wrapper( + process.stdin, + ) + self.stdout_stream = to_streamed_response_wrapper( + process.stdout_stream, + ) + + +class AsyncProcessResourceWithStreamingResponse: + def __init__(self, process: AsyncProcessResource) -> None: + self._process = process + + self.exec = async_to_streamed_response_wrapper( + process.exec, + ) + self.kill = async_to_streamed_response_wrapper( + process.kill, + ) + self.resize = async_to_streamed_response_wrapper( + process.resize, + ) + self.spawn = async_to_streamed_response_wrapper( + process.spawn, + ) + self.status = async_to_streamed_response_wrapper( + process.status, + ) + self.stdin = async_to_streamed_response_wrapper( + process.stdin, + ) + self.stdout_stream = async_to_streamed_response_wrapper( + process.stdout_stream, + ) diff --git a/src/kernel/resources/browsers/replays.py b/src/kernel/resources/browsers/replays.py new file mode 100644 index 00000000..e58f8333 --- /dev/null +++ b/src/kernel/resources/browsers/replays.py @@ -0,0 +1,470 @@ +# File generated from our OpenAPI spec by Stainless. See CONTRIBUTING.md for details. + +from __future__ import annotations + +import httpx + +from ..._types import Body, Omit, Query, Headers, NoneType, NotGiven, omit, not_given +from ..._utils import path_template, maybe_transform, async_maybe_transform +from ..._compat import cached_property +from ..._resource import SyncAPIResource, AsyncAPIResource +from ..._response import ( + BinaryAPIResponse, + AsyncBinaryAPIResponse, + StreamedBinaryAPIResponse, + AsyncStreamedBinaryAPIResponse, + to_raw_response_wrapper, + to_streamed_response_wrapper, + async_to_raw_response_wrapper, + to_custom_raw_response_wrapper, + async_to_streamed_response_wrapper, + to_custom_streamed_response_wrapper, + async_to_custom_raw_response_wrapper, + async_to_custom_streamed_response_wrapper, +) +from ..._base_client import make_request_options +from ...types.browsers import replay_start_params +from ...types.browsers.replay_list_response import ReplayListResponse +from ...types.browsers.replay_start_response import ReplayStartResponse + +__all__ = ["ReplaysResource", "AsyncReplaysResource"] + + +class ReplaysResource(SyncAPIResource): + """Record and manage browser session video replays.""" + + @cached_property + def with_raw_response(self) -> ReplaysResourceWithRawResponse: + """ + 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/kernel/kernel-python-sdk#accessing-raw-response-data-eg-headers + """ + return ReplaysResourceWithRawResponse(self) + + @cached_property + def with_streaming_response(self) -> ReplaysResourceWithStreamingResponse: + """ + An alternative to `.with_raw_response` that doesn't eagerly read the response body. + + For more information, see https://www.github.com/kernel/kernel-python-sdk#with_streaming_response + """ + return ReplaysResourceWithStreamingResponse(self) + + def list( + 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, + ) -> ReplayListResponse: + """ + List all replays for the specified browser 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( + path_template("/browsers/{id}/replays", id=id), + options=make_request_options( + extra_headers=extra_headers, extra_query=extra_query, extra_body=extra_body, timeout=timeout + ), + cast_to=ReplayListResponse, + ) + + def download( + self, + replay_id: str, + *, + 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, + ) -> BinaryAPIResponse: + """ + Download or stream the specified replay recording. + + 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 replay_id: + raise ValueError(f"Expected a non-empty value for `replay_id` but received {replay_id!r}") + extra_headers = {"Accept": "video/mp4", **(extra_headers or {})} + return self._get( + path_template("/browsers/{id}/replays/{replay_id}", id=id, replay_id=replay_id), + options=make_request_options( + extra_headers=extra_headers, extra_query=extra_query, extra_body=extra_body, timeout=timeout + ), + cast_to=BinaryAPIResponse, + ) + + def start( + self, + id: str, + *, + framerate: int | Omit = omit, + max_duration_in_seconds: int | Omit = omit, + record_audio: 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, + ) -> ReplayStartResponse: + """ + Start recording the browser session and return a replay ID. + + Args: + framerate: Recording framerate in fps. Values above 20 require GPU to be enabled on the + browser session. + + max_duration_in_seconds: Maximum recording duration in seconds. + + record_audio: Record audio in addition to video. When false (the default), the recording is + video-only. + + 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( + path_template("/browsers/{id}/replays", id=id), + body=maybe_transform( + { + "framerate": framerate, + "max_duration_in_seconds": max_duration_in_seconds, + "record_audio": record_audio, + }, + replay_start_params.ReplayStartParams, + ), + options=make_request_options( + extra_headers=extra_headers, extra_query=extra_query, extra_body=extra_body, timeout=timeout + ), + cast_to=ReplayStartResponse, + ) + + def stop( + self, + replay_id: str, + *, + 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, + ) -> None: + """ + Stop the specified replay recording and persist the video. + + 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 replay_id: + raise ValueError(f"Expected a non-empty value for `replay_id` but received {replay_id!r}") + extra_headers = {"Accept": "*/*", **(extra_headers or {})} + return self._post( + path_template("/browsers/{id}/replays/{replay_id}/stop", id=id, replay_id=replay_id), + options=make_request_options( + extra_headers=extra_headers, extra_query=extra_query, extra_body=extra_body, timeout=timeout + ), + cast_to=NoneType, + ) + + +class AsyncReplaysResource(AsyncAPIResource): + """Record and manage browser session video replays.""" + + @cached_property + def with_raw_response(self) -> AsyncReplaysResourceWithRawResponse: + """ + 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/kernel/kernel-python-sdk#accessing-raw-response-data-eg-headers + """ + return AsyncReplaysResourceWithRawResponse(self) + + @cached_property + def with_streaming_response(self) -> AsyncReplaysResourceWithStreamingResponse: + """ + An alternative to `.with_raw_response` that doesn't eagerly read the response body. + + For more information, see https://www.github.com/kernel/kernel-python-sdk#with_streaming_response + """ + return AsyncReplaysResourceWithStreamingResponse(self) + + async def list( + 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, + ) -> ReplayListResponse: + """ + List all replays for the specified browser 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( + path_template("/browsers/{id}/replays", id=id), + options=make_request_options( + extra_headers=extra_headers, extra_query=extra_query, extra_body=extra_body, timeout=timeout + ), + cast_to=ReplayListResponse, + ) + + async def download( + self, + replay_id: str, + *, + 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, + ) -> AsyncBinaryAPIResponse: + """ + Download or stream the specified replay recording. + + 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 replay_id: + raise ValueError(f"Expected a non-empty value for `replay_id` but received {replay_id!r}") + extra_headers = {"Accept": "video/mp4", **(extra_headers or {})} + return await self._get( + path_template("/browsers/{id}/replays/{replay_id}", id=id, replay_id=replay_id), + options=make_request_options( + extra_headers=extra_headers, extra_query=extra_query, extra_body=extra_body, timeout=timeout + ), + cast_to=AsyncBinaryAPIResponse, + ) + + async def start( + self, + id: str, + *, + framerate: int | Omit = omit, + max_duration_in_seconds: int | Omit = omit, + record_audio: 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, + ) -> ReplayStartResponse: + """ + Start recording the browser session and return a replay ID. + + Args: + framerate: Recording framerate in fps. Values above 20 require GPU to be enabled on the + browser session. + + max_duration_in_seconds: Maximum recording duration in seconds. + + record_audio: Record audio in addition to video. When false (the default), the recording is + video-only. + + 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( + path_template("/browsers/{id}/replays", id=id), + body=await async_maybe_transform( + { + "framerate": framerate, + "max_duration_in_seconds": max_duration_in_seconds, + "record_audio": record_audio, + }, + replay_start_params.ReplayStartParams, + ), + options=make_request_options( + extra_headers=extra_headers, extra_query=extra_query, extra_body=extra_body, timeout=timeout + ), + cast_to=ReplayStartResponse, + ) + + async def stop( + self, + replay_id: str, + *, + 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, + ) -> None: + """ + Stop the specified replay recording and persist the video. + + 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 replay_id: + raise ValueError(f"Expected a non-empty value for `replay_id` but received {replay_id!r}") + extra_headers = {"Accept": "*/*", **(extra_headers or {})} + return await self._post( + path_template("/browsers/{id}/replays/{replay_id}/stop", id=id, replay_id=replay_id), + options=make_request_options( + extra_headers=extra_headers, extra_query=extra_query, extra_body=extra_body, timeout=timeout + ), + cast_to=NoneType, + ) + + +class ReplaysResourceWithRawResponse: + def __init__(self, replays: ReplaysResource) -> None: + self._replays = replays + + self.list = to_raw_response_wrapper( + replays.list, + ) + self.download = to_custom_raw_response_wrapper( + replays.download, + BinaryAPIResponse, + ) + self.start = to_raw_response_wrapper( + replays.start, + ) + self.stop = to_raw_response_wrapper( + replays.stop, + ) + + +class AsyncReplaysResourceWithRawResponse: + def __init__(self, replays: AsyncReplaysResource) -> None: + self._replays = replays + + self.list = async_to_raw_response_wrapper( + replays.list, + ) + self.download = async_to_custom_raw_response_wrapper( + replays.download, + AsyncBinaryAPIResponse, + ) + self.start = async_to_raw_response_wrapper( + replays.start, + ) + self.stop = async_to_raw_response_wrapper( + replays.stop, + ) + + +class ReplaysResourceWithStreamingResponse: + def __init__(self, replays: ReplaysResource) -> None: + self._replays = replays + + self.list = to_streamed_response_wrapper( + replays.list, + ) + self.download = to_custom_streamed_response_wrapper( + replays.download, + StreamedBinaryAPIResponse, + ) + self.start = to_streamed_response_wrapper( + replays.start, + ) + self.stop = to_streamed_response_wrapper( + replays.stop, + ) + + +class AsyncReplaysResourceWithStreamingResponse: + def __init__(self, replays: AsyncReplaysResource) -> None: + self._replays = replays + + self.list = async_to_streamed_response_wrapper( + replays.list, + ) + self.download = async_to_custom_streamed_response_wrapper( + replays.download, + AsyncStreamedBinaryAPIResponse, + ) + self.start = async_to_streamed_response_wrapper( + replays.start, + ) + self.stop = async_to_streamed_response_wrapper( + replays.stop, + ) diff --git a/src/kernel/resources/browsers/telemetry.py b/src/kernel/resources/browsers/telemetry.py new file mode 100644 index 00000000..4c7769e7 --- /dev/null +++ b/src/kernel/resources/browsers/telemetry.py @@ -0,0 +1,423 @@ +# File generated from our OpenAPI spec by Stainless. See CONTRIBUTING.md for details. + +from __future__ import annotations + +from typing import List +from typing_extensions import Literal + +import httpx + +from ..._types import Body, Omit, Query, Headers, NotGiven, omit, not_given +from ..._utils import path_template, maybe_transform, strip_not_given, 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 ...pagination import SyncOffsetPagination, AsyncOffsetPagination +from ..._base_client import AsyncPaginator, make_request_options +from ...types.browsers import telemetry_events_params, telemetry_stream_params +from ...types.browsers.telemetry_events_response import TelemetryEventsResponse +from ...types.browsers.telemetry_stream_response import TelemetryStreamResponse + +__all__ = ["TelemetryResource", "AsyncTelemetryResource"] + + +class TelemetryResource(SyncAPIResource): + """Stream live telemetry events from a browser session.""" + + @cached_property + def with_raw_response(self) -> TelemetryResourceWithRawResponse: + """ + 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/kernel/kernel-python-sdk#accessing-raw-response-data-eg-headers + """ + return TelemetryResourceWithRawResponse(self) + + @cached_property + def with_streaming_response(self) -> TelemetryResourceWithStreamingResponse: + """ + An alternative to `.with_raw_response` that doesn't eagerly read the response body. + + For more information, see https://www.github.com/kernel/kernel-python-sdk#with_streaming_response + """ + return TelemetryResourceWithStreamingResponse(self) + + def events( + self, + id: str, + *, + category: List[ + Literal[ + "console", + "network", + "page", + "interaction", + "control", + "connection", + "system", + "screenshot", + "captcha", + "monitor", + ] + ] + | Omit = omit, + limit: int | Omit = omit, + offset: int | Omit = omit, + order: str | Omit = omit, + since: str | Omit = omit, + until: 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, + ) -> SyncOffsetPagination[TelemetryEventsResponse]: + """Reads a page of telemetry events for the browser session. + + To page through + results, pass the X-Next-Offset value from the previous response as offset and + repeat while X-Has-More is true. Returns an empty list when telemetry data is + unavailable. + + Args: + category: Restrict results to these event categories. Repeat the parameter for multiple + values. + + limit: Maximum number of events per page. Defaults to 20. + + offset: Opaque pagination cursor: pass the X-Next-Offset value from the previous + response to fetch the next page. When set, paging continues from this cursor and + since is ignored, while until still bounds the page. It is not an event's seq + field, so do not derive it from the response body. + + order: Read direction. asc (default) reads oldest first, starting from since or the + offset cursor. desc reads newest first: each request returns one page of up to + limit records ending at the offset cursor (or until, or the newest archived + event); combining desc with since is rejected with a 400. In either direction + the category filter applies within the page, so a filtered page may be empty + while X-Has-More is true. + + since: Start of the window: an RFC-3339 timestamp, or a duration like 5m meaning that + long ago. Defaults to 5m. Ignored when offset is set. + + until: End of the window (exclusive): an RFC-3339 timestamp, or a duration like 5m + meaning that long ago. + + 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_api_list( + path_template("/browsers/{id}/telemetry/events", id=id), + page=SyncOffsetPagination[TelemetryEventsResponse], + options=make_request_options( + extra_headers=extra_headers, + extra_query=extra_query, + extra_body=extra_body, + timeout=timeout, + query=maybe_transform( + { + "category": category, + "limit": limit, + "offset": offset, + "order": order, + "since": since, + "until": until, + }, + telemetry_events_params.TelemetryEventsParams, + ), + ), + model=TelemetryEventsResponse, + ) + + def stream( + self, + id: str, + *, + replay: str | Omit = omit, + last_event_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, + ) -> Stream[TelemetryStreamResponse]: + """Streams browser telemetry events as a server-sent events (SSE) stream. + + The + stream closes when the browser session terminates. Each event frame includes an + id: field containing a monotonically increasing sequence number; pass it as + Last-Event-ID on reconnect to resume without gaps. The event: field is never + set; all frames carry JSON in the data: field. A keepalive comment frame is sent + every 15 seconds when no events arrive. Returns 404 if the browser session does + not exist. If telemetry was not enabled on the session, the stream opens but no + events are delivered. Fresh connections only see new events; pass replay=all to + start from the oldest retained event instead. + + Args: + replay: Pass `all` to start from the oldest retained event instead of only new events; + any other value is treated as from-now. The buffer is bounded, so the first + event id may be greater than 1 if older events were evicted. + + 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}") + extra_headers = {"Accept": "text/event-stream", **(extra_headers or {})} + extra_headers = {**strip_not_given({"Last-Event-ID": last_event_id}), **(extra_headers or {})} + return self._get( + path_template("/browsers/{id}/telemetry/stream", id=id), + options=make_request_options( + extra_headers=extra_headers, + extra_query=extra_query, + extra_body=extra_body, + timeout=timeout, + query=maybe_transform({"replay": replay}, telemetry_stream_params.TelemetryStreamParams), + ), + cast_to=TelemetryStreamResponse, + stream=True, + stream_cls=Stream[TelemetryStreamResponse], + ) + + +class AsyncTelemetryResource(AsyncAPIResource): + """Stream live telemetry events from a browser session.""" + + @cached_property + def with_raw_response(self) -> AsyncTelemetryResourceWithRawResponse: + """ + 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/kernel/kernel-python-sdk#accessing-raw-response-data-eg-headers + """ + return AsyncTelemetryResourceWithRawResponse(self) + + @cached_property + def with_streaming_response(self) -> AsyncTelemetryResourceWithStreamingResponse: + """ + An alternative to `.with_raw_response` that doesn't eagerly read the response body. + + For more information, see https://www.github.com/kernel/kernel-python-sdk#with_streaming_response + """ + return AsyncTelemetryResourceWithStreamingResponse(self) + + def events( + self, + id: str, + *, + category: List[ + Literal[ + "console", + "network", + "page", + "interaction", + "control", + "connection", + "system", + "screenshot", + "captcha", + "monitor", + ] + ] + | Omit = omit, + limit: int | Omit = omit, + offset: int | Omit = omit, + order: str | Omit = omit, + since: str | Omit = omit, + until: 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, + ) -> AsyncPaginator[TelemetryEventsResponse, AsyncOffsetPagination[TelemetryEventsResponse]]: + """Reads a page of telemetry events for the browser session. + + To page through + results, pass the X-Next-Offset value from the previous response as offset and + repeat while X-Has-More is true. Returns an empty list when telemetry data is + unavailable. + + Args: + category: Restrict results to these event categories. Repeat the parameter for multiple + values. + + limit: Maximum number of events per page. Defaults to 20. + + offset: Opaque pagination cursor: pass the X-Next-Offset value from the previous + response to fetch the next page. When set, paging continues from this cursor and + since is ignored, while until still bounds the page. It is not an event's seq + field, so do not derive it from the response body. + + order: Read direction. asc (default) reads oldest first, starting from since or the + offset cursor. desc reads newest first: each request returns one page of up to + limit records ending at the offset cursor (or until, or the newest archived + event); combining desc with since is rejected with a 400. In either direction + the category filter applies within the page, so a filtered page may be empty + while X-Has-More is true. + + since: Start of the window: an RFC-3339 timestamp, or a duration like 5m meaning that + long ago. Defaults to 5m. Ignored when offset is set. + + until: End of the window (exclusive): an RFC-3339 timestamp, or a duration like 5m + meaning that long ago. + + 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_api_list( + path_template("/browsers/{id}/telemetry/events", id=id), + page=AsyncOffsetPagination[TelemetryEventsResponse], + options=make_request_options( + extra_headers=extra_headers, + extra_query=extra_query, + extra_body=extra_body, + timeout=timeout, + query=maybe_transform( + { + "category": category, + "limit": limit, + "offset": offset, + "order": order, + "since": since, + "until": until, + }, + telemetry_events_params.TelemetryEventsParams, + ), + ), + model=TelemetryEventsResponse, + ) + + async def stream( + self, + id: str, + *, + replay: str | Omit = omit, + last_event_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, + ) -> AsyncStream[TelemetryStreamResponse]: + """Streams browser telemetry events as a server-sent events (SSE) stream. + + The + stream closes when the browser session terminates. Each event frame includes an + id: field containing a monotonically increasing sequence number; pass it as + Last-Event-ID on reconnect to resume without gaps. The event: field is never + set; all frames carry JSON in the data: field. A keepalive comment frame is sent + every 15 seconds when no events arrive. Returns 404 if the browser session does + not exist. If telemetry was not enabled on the session, the stream opens but no + events are delivered. Fresh connections only see new events; pass replay=all to + start from the oldest retained event instead. + + Args: + replay: Pass `all` to start from the oldest retained event instead of only new events; + any other value is treated as from-now. The buffer is bounded, so the first + event id may be greater than 1 if older events were evicted. + + 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}") + extra_headers = {"Accept": "text/event-stream", **(extra_headers or {})} + extra_headers = {**strip_not_given({"Last-Event-ID": last_event_id}), **(extra_headers or {})} + return await self._get( + path_template("/browsers/{id}/telemetry/stream", id=id), + options=make_request_options( + extra_headers=extra_headers, + extra_query=extra_query, + extra_body=extra_body, + timeout=timeout, + query=await async_maybe_transform({"replay": replay}, telemetry_stream_params.TelemetryStreamParams), + ), + cast_to=TelemetryStreamResponse, + stream=True, + stream_cls=AsyncStream[TelemetryStreamResponse], + ) + + +class TelemetryResourceWithRawResponse: + def __init__(self, telemetry: TelemetryResource) -> None: + self._telemetry = telemetry + + self.events = to_raw_response_wrapper( + telemetry.events, + ) + self.stream = to_raw_response_wrapper( + telemetry.stream, + ) + + +class AsyncTelemetryResourceWithRawResponse: + def __init__(self, telemetry: AsyncTelemetryResource) -> None: + self._telemetry = telemetry + + self.events = async_to_raw_response_wrapper( + telemetry.events, + ) + self.stream = async_to_raw_response_wrapper( + telemetry.stream, + ) + + +class TelemetryResourceWithStreamingResponse: + def __init__(self, telemetry: TelemetryResource) -> None: + self._telemetry = telemetry + + self.events = to_streamed_response_wrapper( + telemetry.events, + ) + self.stream = to_streamed_response_wrapper( + telemetry.stream, + ) + + +class AsyncTelemetryResourceWithStreamingResponse: + def __init__(self, telemetry: AsyncTelemetryResource) -> None: + self._telemetry = telemetry + + self.events = async_to_streamed_response_wrapper( + telemetry.events, + ) + self.stream = async_to_streamed_response_wrapper( + telemetry.stream, + ) diff --git a/src/kernel/resources/credential_providers.py b/src/kernel/resources/credential_providers.py new file mode 100644 index 00000000..de079a8c --- /dev/null +++ b/src/kernel/resources/credential_providers.py @@ -0,0 +1,776 @@ +# 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 ( + credential_provider_list_params, + credential_provider_create_params, + credential_provider_update_params, +) +from .._types import Body, Omit, Query, Headers, NoneType, NotGiven, omit, not_given +from .._utils import path_template, 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 ..pagination import SyncOffsetPagination, AsyncOffsetPagination +from .._base_client import AsyncPaginator, make_request_options +from ..types.credential_provider import CredentialProvider +from ..types.credential_provider_test_result import CredentialProviderTestResult +from ..types.credential_provider_list_items_response import CredentialProviderListItemsResponse + +__all__ = ["CredentialProvidersResource", "AsyncCredentialProvidersResource"] + + +class CredentialProvidersResource(SyncAPIResource): + """Configure external credential providers like 1Password.""" + + @cached_property + def with_raw_response(self) -> CredentialProvidersResourceWithRawResponse: + """ + 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/kernel/kernel-python-sdk#accessing-raw-response-data-eg-headers + """ + return CredentialProvidersResourceWithRawResponse(self) + + @cached_property + def with_streaming_response(self) -> CredentialProvidersResourceWithStreamingResponse: + """ + An alternative to `.with_raw_response` that doesn't eagerly read the response body. + + For more information, see https://www.github.com/kernel/kernel-python-sdk#with_streaming_response + """ + return CredentialProvidersResourceWithStreamingResponse(self) + + def create( + self, + *, + token: str, + name: str, + provider_type: Literal["onepassword"], + cache_ttl_seconds: int | 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, + ) -> CredentialProvider: + """ + Configure an external credential provider (e.g., 1Password) for automatic + credential lookup. + + Args: + token: Service account token for the provider (e.g., 1Password service account token) + + name: Human-readable name for this provider instance (unique per org) + + provider_type: Type of credential provider + + cache_ttl_seconds: How long to cache credential lists (default 300 seconds) + + 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( + "/org/credential_providers", + body=maybe_transform( + { + "token": token, + "name": name, + "provider_type": provider_type, + "cache_ttl_seconds": cache_ttl_seconds, + }, + credential_provider_create_params.CredentialProviderCreateParams, + ), + options=make_request_options( + extra_headers=extra_headers, extra_query=extra_query, extra_body=extra_body, timeout=timeout + ), + cast_to=CredentialProvider, + ) + + def retrieve( + 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, + ) -> CredentialProvider: + """ + Retrieve a credential provider by its ID. + + 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( + path_template("/org/credential_providers/{id}", id=id), + options=make_request_options( + extra_headers=extra_headers, extra_query=extra_query, extra_body=extra_body, timeout=timeout + ), + cast_to=CredentialProvider, + ) + + def update( + self, + id: str, + *, + token: str | Omit = omit, + cache_ttl_seconds: int | Omit = omit, + enabled: bool | Omit = omit, + name: str | Omit = omit, + priority: int | 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, + ) -> CredentialProvider: + """ + Update a credential provider's configuration. + + Args: + token: New service account token (to rotate credentials) + + cache_ttl_seconds: How long to cache credential lists + + enabled: Whether the provider is enabled for credential lookups + + name: Human-readable name for this provider instance + + priority: Priority order for credential lookups (lower numbers are checked first) + + 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( + path_template("/org/credential_providers/{id}", id=id), + body=maybe_transform( + { + "token": token, + "cache_ttl_seconds": cache_ttl_seconds, + "enabled": enabled, + "name": name, + "priority": priority, + }, + credential_provider_update_params.CredentialProviderUpdateParams, + ), + options=make_request_options( + extra_headers=extra_headers, extra_query=extra_query, extra_body=extra_body, timeout=timeout + ), + cast_to=CredentialProvider, + ) + + def list( + self, + *, + limit: int | Omit = omit, + offset: int | Omit = omit, + query: 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, + ) -> SyncOffsetPagination[CredentialProvider]: + """ + List external credential providers configured for the organization. + + Args: + limit: Limit the number of credential providers to return. + + offset: Offset the number of credential providers to return. + + query: Case-insensitive substring match against credential provider name. IDs match by + exact value. + + 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_api_list( + "/org/credential_providers", + page=SyncOffsetPagination[CredentialProvider], + options=make_request_options( + extra_headers=extra_headers, + extra_query=extra_query, + extra_body=extra_body, + timeout=timeout, + query=maybe_transform( + { + "limit": limit, + "offset": offset, + "query": query, + }, + credential_provider_list_params.CredentialProviderListParams, + ), + ), + model=CredentialProvider, + ) + + 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, + ) -> None: + """ + Delete a credential provider by its ID. + + 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}") + extra_headers = {"Accept": "*/*", **(extra_headers or {})} + return self._delete( + path_template("/org/credential_providers/{id}", id=id), + options=make_request_options( + extra_headers=extra_headers, extra_query=extra_query, extra_body=extra_body, timeout=timeout + ), + cast_to=NoneType, + ) + + def list_items( + 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, + ) -> CredentialProviderListItemsResponse: + """ + Returns available credential items (e.g., 1Password login items) from the + provider. + + 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( + path_template("/org/credential_providers/{id}/items", id=id), + options=make_request_options( + extra_headers=extra_headers, extra_query=extra_query, extra_body=extra_body, timeout=timeout + ), + cast_to=CredentialProviderListItemsResponse, + ) + + def test( + 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, + ) -> CredentialProviderTestResult: + """ + Validate the credential provider's token and list accessible vaults. + + 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( + path_template("/org/credential_providers/{id}/test", id=id), + options=make_request_options( + extra_headers=extra_headers, extra_query=extra_query, extra_body=extra_body, timeout=timeout + ), + cast_to=CredentialProviderTestResult, + ) + + +class AsyncCredentialProvidersResource(AsyncAPIResource): + """Configure external credential providers like 1Password.""" + + @cached_property + def with_raw_response(self) -> AsyncCredentialProvidersResourceWithRawResponse: + """ + 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/kernel/kernel-python-sdk#accessing-raw-response-data-eg-headers + """ + return AsyncCredentialProvidersResourceWithRawResponse(self) + + @cached_property + def with_streaming_response(self) -> AsyncCredentialProvidersResourceWithStreamingResponse: + """ + An alternative to `.with_raw_response` that doesn't eagerly read the response body. + + For more information, see https://www.github.com/kernel/kernel-python-sdk#with_streaming_response + """ + return AsyncCredentialProvidersResourceWithStreamingResponse(self) + + async def create( + self, + *, + token: str, + name: str, + provider_type: Literal["onepassword"], + cache_ttl_seconds: int | 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, + ) -> CredentialProvider: + """ + Configure an external credential provider (e.g., 1Password) for automatic + credential lookup. + + Args: + token: Service account token for the provider (e.g., 1Password service account token) + + name: Human-readable name for this provider instance (unique per org) + + provider_type: Type of credential provider + + cache_ttl_seconds: How long to cache credential lists (default 300 seconds) + + 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( + "/org/credential_providers", + body=await async_maybe_transform( + { + "token": token, + "name": name, + "provider_type": provider_type, + "cache_ttl_seconds": cache_ttl_seconds, + }, + credential_provider_create_params.CredentialProviderCreateParams, + ), + options=make_request_options( + extra_headers=extra_headers, extra_query=extra_query, extra_body=extra_body, timeout=timeout + ), + cast_to=CredentialProvider, + ) + + async def retrieve( + 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, + ) -> CredentialProvider: + """ + Retrieve a credential provider by its ID. + + 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( + path_template("/org/credential_providers/{id}", id=id), + options=make_request_options( + extra_headers=extra_headers, extra_query=extra_query, extra_body=extra_body, timeout=timeout + ), + cast_to=CredentialProvider, + ) + + async def update( + self, + id: str, + *, + token: str | Omit = omit, + cache_ttl_seconds: int | Omit = omit, + enabled: bool | Omit = omit, + name: str | Omit = omit, + priority: int | 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, + ) -> CredentialProvider: + """ + Update a credential provider's configuration. + + Args: + token: New service account token (to rotate credentials) + + cache_ttl_seconds: How long to cache credential lists + + enabled: Whether the provider is enabled for credential lookups + + name: Human-readable name for this provider instance + + priority: Priority order for credential lookups (lower numbers are checked first) + + 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( + path_template("/org/credential_providers/{id}", id=id), + body=await async_maybe_transform( + { + "token": token, + "cache_ttl_seconds": cache_ttl_seconds, + "enabled": enabled, + "name": name, + "priority": priority, + }, + credential_provider_update_params.CredentialProviderUpdateParams, + ), + options=make_request_options( + extra_headers=extra_headers, extra_query=extra_query, extra_body=extra_body, timeout=timeout + ), + cast_to=CredentialProvider, + ) + + def list( + self, + *, + limit: int | Omit = omit, + offset: int | Omit = omit, + query: 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, + ) -> AsyncPaginator[CredentialProvider, AsyncOffsetPagination[CredentialProvider]]: + """ + List external credential providers configured for the organization. + + Args: + limit: Limit the number of credential providers to return. + + offset: Offset the number of credential providers to return. + + query: Case-insensitive substring match against credential provider name. IDs match by + exact value. + + 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_api_list( + "/org/credential_providers", + page=AsyncOffsetPagination[CredentialProvider], + options=make_request_options( + extra_headers=extra_headers, + extra_query=extra_query, + extra_body=extra_body, + timeout=timeout, + query=maybe_transform( + { + "limit": limit, + "offset": offset, + "query": query, + }, + credential_provider_list_params.CredentialProviderListParams, + ), + ), + model=CredentialProvider, + ) + + 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, + ) -> None: + """ + Delete a credential provider by its ID. + + 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}") + extra_headers = {"Accept": "*/*", **(extra_headers or {})} + return await self._delete( + path_template("/org/credential_providers/{id}", id=id), + options=make_request_options( + extra_headers=extra_headers, extra_query=extra_query, extra_body=extra_body, timeout=timeout + ), + cast_to=NoneType, + ) + + async def list_items( + 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, + ) -> CredentialProviderListItemsResponse: + """ + Returns available credential items (e.g., 1Password login items) from the + provider. + + 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( + path_template("/org/credential_providers/{id}/items", id=id), + options=make_request_options( + extra_headers=extra_headers, extra_query=extra_query, extra_body=extra_body, timeout=timeout + ), + cast_to=CredentialProviderListItemsResponse, + ) + + async def test( + 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, + ) -> CredentialProviderTestResult: + """ + Validate the credential provider's token and list accessible vaults. + + 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( + path_template("/org/credential_providers/{id}/test", id=id), + options=make_request_options( + extra_headers=extra_headers, extra_query=extra_query, extra_body=extra_body, timeout=timeout + ), + cast_to=CredentialProviderTestResult, + ) + + +class CredentialProvidersResourceWithRawResponse: + def __init__(self, credential_providers: CredentialProvidersResource) -> None: + self._credential_providers = credential_providers + + self.create = to_raw_response_wrapper( + credential_providers.create, + ) + self.retrieve = to_raw_response_wrapper( + credential_providers.retrieve, + ) + self.update = to_raw_response_wrapper( + credential_providers.update, + ) + self.list = to_raw_response_wrapper( + credential_providers.list, + ) + self.delete = to_raw_response_wrapper( + credential_providers.delete, + ) + self.list_items = to_raw_response_wrapper( + credential_providers.list_items, + ) + self.test = to_raw_response_wrapper( + credential_providers.test, + ) + + +class AsyncCredentialProvidersResourceWithRawResponse: + def __init__(self, credential_providers: AsyncCredentialProvidersResource) -> None: + self._credential_providers = credential_providers + + self.create = async_to_raw_response_wrapper( + credential_providers.create, + ) + self.retrieve = async_to_raw_response_wrapper( + credential_providers.retrieve, + ) + self.update = async_to_raw_response_wrapper( + credential_providers.update, + ) + self.list = async_to_raw_response_wrapper( + credential_providers.list, + ) + self.delete = async_to_raw_response_wrapper( + credential_providers.delete, + ) + self.list_items = async_to_raw_response_wrapper( + credential_providers.list_items, + ) + self.test = async_to_raw_response_wrapper( + credential_providers.test, + ) + + +class CredentialProvidersResourceWithStreamingResponse: + def __init__(self, credential_providers: CredentialProvidersResource) -> None: + self._credential_providers = credential_providers + + self.create = to_streamed_response_wrapper( + credential_providers.create, + ) + self.retrieve = to_streamed_response_wrapper( + credential_providers.retrieve, + ) + self.update = to_streamed_response_wrapper( + credential_providers.update, + ) + self.list = to_streamed_response_wrapper( + credential_providers.list, + ) + self.delete = to_streamed_response_wrapper( + credential_providers.delete, + ) + self.list_items = to_streamed_response_wrapper( + credential_providers.list_items, + ) + self.test = to_streamed_response_wrapper( + credential_providers.test, + ) + + +class AsyncCredentialProvidersResourceWithStreamingResponse: + def __init__(self, credential_providers: AsyncCredentialProvidersResource) -> None: + self._credential_providers = credential_providers + + self.create = async_to_streamed_response_wrapper( + credential_providers.create, + ) + self.retrieve = async_to_streamed_response_wrapper( + credential_providers.retrieve, + ) + self.update = async_to_streamed_response_wrapper( + credential_providers.update, + ) + self.list = async_to_streamed_response_wrapper( + credential_providers.list, + ) + self.delete = async_to_streamed_response_wrapper( + credential_providers.delete, + ) + self.list_items = async_to_streamed_response_wrapper( + credential_providers.list_items, + ) + self.test = async_to_streamed_response_wrapper( + credential_providers.test, + ) diff --git a/src/kernel/resources/credentials.py b/src/kernel/resources/credentials.py new file mode 100644 index 00000000..f1a3ac01 --- /dev/null +++ b/src/kernel/resources/credentials.py @@ -0,0 +1,733 @@ +# File generated from our OpenAPI spec by Stainless. See CONTRIBUTING.md for details. + +from __future__ import annotations + +from typing import Dict, Optional + +import httpx + +from ..types import credential_list_params, credential_create_params, credential_update_params +from .._types import Body, Omit, Query, Headers, NoneType, NotGiven, SequenceNotStr, omit, not_given +from .._utils import path_template, 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 ..pagination import SyncOffsetPagination, AsyncOffsetPagination +from .._base_client import AsyncPaginator, make_request_options +from ..types.credential import Credential +from ..types.credential_totp_code_response import CredentialTotpCodeResponse + +__all__ = ["CredentialsResource", "AsyncCredentialsResource"] + + +class CredentialsResource(SyncAPIResource): + """Create and manage credentials for authentication.""" + + @cached_property + def with_raw_response(self) -> CredentialsResourceWithRawResponse: + """ + 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/kernel/kernel-python-sdk#accessing-raw-response-data-eg-headers + """ + return CredentialsResourceWithRawResponse(self) + + @cached_property + def with_streaming_response(self) -> CredentialsResourceWithStreamingResponse: + """ + An alternative to `.with_raw_response` that doesn't eagerly read the response body. + + For more information, see https://www.github.com/kernel/kernel-python-sdk#with_streaming_response + """ + return CredentialsResourceWithStreamingResponse(self) + + def create( + self, + *, + domain: str, + name: str, + values: Dict[str, str], + sso_provider: str | Omit = omit, + totp_secret: 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, + ) -> Credential: + """ + Create a new credential for storing login information. + + Args: + domain: Target domain this credential is for + + name: Unique name for the credential within the project + + values: Field name to value mapping (e.g., username, password) + + sso_provider: If set, indicates this credential should be used with the specified SSO provider + (e.g., google, github, microsoft). When the target site has a matching SSO + button, it will be clicked first before filling credential values on the + identity provider's login page. + + totp_secret: Base32-encoded TOTP secret for generating one-time passwords. Used for automatic + 2FA during login. + + 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( + "/credentials", + body=maybe_transform( + { + "domain": domain, + "name": name, + "values": values, + "sso_provider": sso_provider, + "totp_secret": totp_secret, + }, + credential_create_params.CredentialCreateParams, + ), + options=make_request_options( + extra_headers=extra_headers, extra_query=extra_query, extra_body=extra_body, timeout=timeout + ), + cast_to=Credential, + ) + + def retrieve( + self, + id_or_name: 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, + ) -> Credential: + """Retrieve a credential by its ID or name. + + Credential values are not returned. + + 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_or_name: + raise ValueError(f"Expected a non-empty value for `id_or_name` but received {id_or_name!r}") + return self._get( + path_template("/credentials/{id_or_name}", id_or_name=id_or_name), + options=make_request_options( + extra_headers=extra_headers, extra_query=extra_query, extra_body=extra_body, timeout=timeout + ), + cast_to=Credential, + ) + + def update( + self, + id_or_name: str, + *, + name: str | Omit = omit, + remove_value_keys: SequenceNotStr[str] | Omit = omit, + sso_provider: Optional[str] | Omit = omit, + totp_secret: str | Omit = omit, + values: Dict[str, 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, + ) -> Credential: + """Update a credential's name or values. + + When values are provided, they are merged + with existing values (new keys are added, existing keys are overwritten). + + Args: + name: New name for the credential + + remove_value_keys: Field names to remove from the credential's stored values. Removals are applied + before `values` are merged, so a key present in both is kept with its new value. + + sso_provider: If set, indicates this credential should be used with the specified SSO + provider. Set to empty string or null to remove. + + totp_secret: Base32-encoded TOTP secret for generating one-time passwords. Spaces and + formatting are automatically normalized. Set to empty string to remove. + + values: Field name to value mapping. Values are merged with existing values (new keys + added, existing keys overwritten). + + 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_or_name: + raise ValueError(f"Expected a non-empty value for `id_or_name` but received {id_or_name!r}") + return self._patch( + path_template("/credentials/{id_or_name}", id_or_name=id_or_name), + body=maybe_transform( + { + "name": name, + "remove_value_keys": remove_value_keys, + "sso_provider": sso_provider, + "totp_secret": totp_secret, + "values": values, + }, + credential_update_params.CredentialUpdateParams, + ), + options=make_request_options( + extra_headers=extra_headers, extra_query=extra_query, extra_body=extra_body, timeout=timeout + ), + cast_to=Credential, + ) + + def list( + self, + *, + domain: str | Omit = omit, + limit: int | Omit = omit, + offset: int | Omit = omit, + query: 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, + ) -> SyncOffsetPagination[Credential]: + """List credentials in the resolved project. + + Credential values are not returned. + + Args: + domain: Filter by domain + + limit: Maximum number of results to return + + offset: Number of results to skip + + query: Case-insensitive substring match against credential name or domain. IDs match by + exact value. + + 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_api_list( + "/credentials", + page=SyncOffsetPagination[Credential], + options=make_request_options( + extra_headers=extra_headers, + extra_query=extra_query, + extra_body=extra_body, + timeout=timeout, + query=maybe_transform( + { + "domain": domain, + "limit": limit, + "offset": offset, + "query": query, + }, + credential_list_params.CredentialListParams, + ), + ), + model=Credential, + ) + + def delete( + self, + id_or_name: 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, + ) -> None: + """ + Delete a credential by its ID or name. + + 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_or_name: + raise ValueError(f"Expected a non-empty value for `id_or_name` but received {id_or_name!r}") + extra_headers = {"Accept": "*/*", **(extra_headers or {})} + return self._delete( + path_template("/credentials/{id_or_name}", id_or_name=id_or_name), + options=make_request_options( + extra_headers=extra_headers, extra_query=extra_query, extra_body=extra_body, timeout=timeout + ), + cast_to=NoneType, + ) + + def totp_code( + self, + id_or_name: 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, + ) -> CredentialTotpCodeResponse: + """ + Returns the current 6-digit TOTP code for a credential with a configured + totp_secret. Use this to complete 2FA setup on sites or when you need a fresh + code. + + 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_or_name: + raise ValueError(f"Expected a non-empty value for `id_or_name` but received {id_or_name!r}") + return self._get( + path_template("/credentials/{id_or_name}/totp-code", id_or_name=id_or_name), + options=make_request_options( + extra_headers=extra_headers, extra_query=extra_query, extra_body=extra_body, timeout=timeout + ), + cast_to=CredentialTotpCodeResponse, + ) + + +class AsyncCredentialsResource(AsyncAPIResource): + """Create and manage credentials for authentication.""" + + @cached_property + def with_raw_response(self) -> AsyncCredentialsResourceWithRawResponse: + """ + 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/kernel/kernel-python-sdk#accessing-raw-response-data-eg-headers + """ + return AsyncCredentialsResourceWithRawResponse(self) + + @cached_property + def with_streaming_response(self) -> AsyncCredentialsResourceWithStreamingResponse: + """ + An alternative to `.with_raw_response` that doesn't eagerly read the response body. + + For more information, see https://www.github.com/kernel/kernel-python-sdk#with_streaming_response + """ + return AsyncCredentialsResourceWithStreamingResponse(self) + + async def create( + self, + *, + domain: str, + name: str, + values: Dict[str, str], + sso_provider: str | Omit = omit, + totp_secret: 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, + ) -> Credential: + """ + Create a new credential for storing login information. + + Args: + domain: Target domain this credential is for + + name: Unique name for the credential within the project + + values: Field name to value mapping (e.g., username, password) + + sso_provider: If set, indicates this credential should be used with the specified SSO provider + (e.g., google, github, microsoft). When the target site has a matching SSO + button, it will be clicked first before filling credential values on the + identity provider's login page. + + totp_secret: Base32-encoded TOTP secret for generating one-time passwords. Used for automatic + 2FA during login. + + 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( + "/credentials", + body=await async_maybe_transform( + { + "domain": domain, + "name": name, + "values": values, + "sso_provider": sso_provider, + "totp_secret": totp_secret, + }, + credential_create_params.CredentialCreateParams, + ), + options=make_request_options( + extra_headers=extra_headers, extra_query=extra_query, extra_body=extra_body, timeout=timeout + ), + cast_to=Credential, + ) + + async def retrieve( + self, + id_or_name: 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, + ) -> Credential: + """Retrieve a credential by its ID or name. + + Credential values are not returned. + + 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_or_name: + raise ValueError(f"Expected a non-empty value for `id_or_name` but received {id_or_name!r}") + return await self._get( + path_template("/credentials/{id_or_name}", id_or_name=id_or_name), + options=make_request_options( + extra_headers=extra_headers, extra_query=extra_query, extra_body=extra_body, timeout=timeout + ), + cast_to=Credential, + ) + + async def update( + self, + id_or_name: str, + *, + name: str | Omit = omit, + remove_value_keys: SequenceNotStr[str] | Omit = omit, + sso_provider: Optional[str] | Omit = omit, + totp_secret: str | Omit = omit, + values: Dict[str, 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, + ) -> Credential: + """Update a credential's name or values. + + When values are provided, they are merged + with existing values (new keys are added, existing keys are overwritten). + + Args: + name: New name for the credential + + remove_value_keys: Field names to remove from the credential's stored values. Removals are applied + before `values` are merged, so a key present in both is kept with its new value. + + sso_provider: If set, indicates this credential should be used with the specified SSO + provider. Set to empty string or null to remove. + + totp_secret: Base32-encoded TOTP secret for generating one-time passwords. Spaces and + formatting are automatically normalized. Set to empty string to remove. + + values: Field name to value mapping. Values are merged with existing values (new keys + added, existing keys overwritten). + + 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_or_name: + raise ValueError(f"Expected a non-empty value for `id_or_name` but received {id_or_name!r}") + return await self._patch( + path_template("/credentials/{id_or_name}", id_or_name=id_or_name), + body=await async_maybe_transform( + { + "name": name, + "remove_value_keys": remove_value_keys, + "sso_provider": sso_provider, + "totp_secret": totp_secret, + "values": values, + }, + credential_update_params.CredentialUpdateParams, + ), + options=make_request_options( + extra_headers=extra_headers, extra_query=extra_query, extra_body=extra_body, timeout=timeout + ), + cast_to=Credential, + ) + + def list( + self, + *, + domain: str | Omit = omit, + limit: int | Omit = omit, + offset: int | Omit = omit, + query: 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, + ) -> AsyncPaginator[Credential, AsyncOffsetPagination[Credential]]: + """List credentials in the resolved project. + + Credential values are not returned. + + Args: + domain: Filter by domain + + limit: Maximum number of results to return + + offset: Number of results to skip + + query: Case-insensitive substring match against credential name or domain. IDs match by + exact value. + + 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_api_list( + "/credentials", + page=AsyncOffsetPagination[Credential], + options=make_request_options( + extra_headers=extra_headers, + extra_query=extra_query, + extra_body=extra_body, + timeout=timeout, + query=maybe_transform( + { + "domain": domain, + "limit": limit, + "offset": offset, + "query": query, + }, + credential_list_params.CredentialListParams, + ), + ), + model=Credential, + ) + + async def delete( + self, + id_or_name: 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, + ) -> None: + """ + Delete a credential by its ID or name. + + 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_or_name: + raise ValueError(f"Expected a non-empty value for `id_or_name` but received {id_or_name!r}") + extra_headers = {"Accept": "*/*", **(extra_headers or {})} + return await self._delete( + path_template("/credentials/{id_or_name}", id_or_name=id_or_name), + options=make_request_options( + extra_headers=extra_headers, extra_query=extra_query, extra_body=extra_body, timeout=timeout + ), + cast_to=NoneType, + ) + + async def totp_code( + self, + id_or_name: 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, + ) -> CredentialTotpCodeResponse: + """ + Returns the current 6-digit TOTP code for a credential with a configured + totp_secret. Use this to complete 2FA setup on sites or when you need a fresh + code. + + 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_or_name: + raise ValueError(f"Expected a non-empty value for `id_or_name` but received {id_or_name!r}") + return await self._get( + path_template("/credentials/{id_or_name}/totp-code", id_or_name=id_or_name), + options=make_request_options( + extra_headers=extra_headers, extra_query=extra_query, extra_body=extra_body, timeout=timeout + ), + cast_to=CredentialTotpCodeResponse, + ) + + +class CredentialsResourceWithRawResponse: + def __init__(self, credentials: CredentialsResource) -> None: + self._credentials = credentials + + self.create = to_raw_response_wrapper( + credentials.create, + ) + self.retrieve = to_raw_response_wrapper( + credentials.retrieve, + ) + self.update = to_raw_response_wrapper( + credentials.update, + ) + self.list = to_raw_response_wrapper( + credentials.list, + ) + self.delete = to_raw_response_wrapper( + credentials.delete, + ) + self.totp_code = to_raw_response_wrapper( + credentials.totp_code, + ) + + +class AsyncCredentialsResourceWithRawResponse: + def __init__(self, credentials: AsyncCredentialsResource) -> None: + self._credentials = credentials + + self.create = async_to_raw_response_wrapper( + credentials.create, + ) + self.retrieve = async_to_raw_response_wrapper( + credentials.retrieve, + ) + self.update = async_to_raw_response_wrapper( + credentials.update, + ) + self.list = async_to_raw_response_wrapper( + credentials.list, + ) + self.delete = async_to_raw_response_wrapper( + credentials.delete, + ) + self.totp_code = async_to_raw_response_wrapper( + credentials.totp_code, + ) + + +class CredentialsResourceWithStreamingResponse: + def __init__(self, credentials: CredentialsResource) -> None: + self._credentials = credentials + + self.create = to_streamed_response_wrapper( + credentials.create, + ) + self.retrieve = to_streamed_response_wrapper( + credentials.retrieve, + ) + self.update = to_streamed_response_wrapper( + credentials.update, + ) + self.list = to_streamed_response_wrapper( + credentials.list, + ) + self.delete = to_streamed_response_wrapper( + credentials.delete, + ) + self.totp_code = to_streamed_response_wrapper( + credentials.totp_code, + ) + + +class AsyncCredentialsResourceWithStreamingResponse: + def __init__(self, credentials: AsyncCredentialsResource) -> None: + self._credentials = credentials + + self.create = async_to_streamed_response_wrapper( + credentials.create, + ) + self.retrieve = async_to_streamed_response_wrapper( + credentials.retrieve, + ) + self.update = async_to_streamed_response_wrapper( + credentials.update, + ) + self.list = async_to_streamed_response_wrapper( + credentials.list, + ) + self.delete = async_to_streamed_response_wrapper( + credentials.delete, + ) + self.totp_code = async_to_streamed_response_wrapper( + credentials.totp_code, + ) diff --git a/src/kernel/resources/deployments.py b/src/kernel/resources/deployments.py new file mode 100644 index 00000000..c6de9212 --- /dev/null +++ b/src/kernel/resources/deployments.py @@ -0,0 +1,654 @@ +# File generated from our OpenAPI spec by Stainless. See CONTRIBUTING.md for details. + +from __future__ import annotations + +from typing import Any, Dict, Mapping, cast +from typing_extensions import Literal + +import httpx + +from ..types import deployment_list_params, deployment_create_params, deployment_follow_params +from .._files import deepcopy_with_paths +from .._types import Body, Omit, Query, Headers, NoneType, NotGiven, FileTypes, omit, not_given +from .._utils import extract_files, path_template, 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 ..pagination import SyncOffsetPagination, AsyncOffsetPagination +from .._base_client import AsyncPaginator, make_request_options +from ..types.deployment_list_response import DeploymentListResponse +from ..types.deployment_create_response import DeploymentCreateResponse +from ..types.deployment_follow_response import DeploymentFollowResponse +from ..types.deployment_retrieve_response import DeploymentRetrieveResponse + +__all__ = ["DeploymentsResource", "AsyncDeploymentsResource"] + + +class DeploymentsResource(SyncAPIResource): + """Create and manage app deployments and stream deployment events.""" + + @cached_property + def with_raw_response(self) -> DeploymentsResourceWithRawResponse: + """ + 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/kernel/kernel-python-sdk#accessing-raw-response-data-eg-headers + """ + return DeploymentsResourceWithRawResponse(self) + + @cached_property + def with_streaming_response(self) -> DeploymentsResourceWithStreamingResponse: + """ + An alternative to `.with_raw_response` that doesn't eagerly read the response body. + + For more information, see https://www.github.com/kernel/kernel-python-sdk#with_streaming_response + """ + return DeploymentsResourceWithStreamingResponse(self) + + def create( + self, + *, + entrypoint_rel_path: str | Omit = omit, + env_vars: Dict[str, str] | Omit = omit, + file: FileTypes | Omit = omit, + force: bool | Omit = omit, + region: Literal["aws.us-east-1a"] | Omit = omit, + source: deployment_create_params.Source | Omit = omit, + version: 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, + ) -> DeploymentCreateResponse: + """ + Create a new deployment. + + Args: + entrypoint_rel_path: Relative path to the entrypoint of the application + + env_vars: Map of environment variables to set for the deployed application. Each key-value + pair represents an environment variable. + + file: ZIP file containing the application source directory + + force: Allow overwriting an existing app version + + region: Region for deployment. Currently we only support "aws.us-east-1a" + + source: Source from which to fetch application code. + + version: Version of the application. Can be any string. + + 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 + """ + body = deepcopy_with_paths( + { + "entrypoint_rel_path": entrypoint_rel_path, + "env_vars": env_vars, + "file": file, + "force": force, + "region": region, + "source": source, + "version": version, + }, + [["file"]], + ) + files = extract_files(cast(Mapping[str, object], body), paths=[["file"]]) + # It should be noted that the actual Content-Type header that will be + # sent to the server will contain a `boundary` parameter, e.g. + # multipart/form-data; boundary=---abc-- + extra_headers = {"Content-Type": "multipart/form-data", **(extra_headers or {})} + return self._post( + "/deployments", + body=maybe_transform(body, deployment_create_params.DeploymentCreateParams), + files=files, + options=make_request_options( + extra_headers=extra_headers, extra_query=extra_query, extra_body=extra_body, timeout=timeout + ), + cast_to=DeploymentCreateResponse, + ) + + def retrieve( + 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, + ) -> DeploymentRetrieveResponse: + """ + Get information about a deployment's 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 + """ + if not id: + raise ValueError(f"Expected a non-empty value for `id` but received {id!r}") + return self._get( + path_template("/deployments/{id}", id=id), + options=make_request_options( + extra_headers=extra_headers, extra_query=extra_query, extra_body=extra_body, timeout=timeout + ), + cast_to=DeploymentRetrieveResponse, + ) + + def list( + self, + *, + app_name: str | Omit = omit, + app_version: str | Omit = omit, + limit: int | Omit = omit, + offset: int | Omit = omit, + query: 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, + ) -> SyncOffsetPagination[DeploymentListResponse]: + """List deployments. + + Optionally filter by application name and version. + + Args: + app_name: Filter results by application name. + + app_version: Filter results by application version. Requires app_name to be set. + + limit: Limit the number of deployments to return. + + offset: Offset the number of deployments to return. + + query: Search deployments by ID or app name. + + 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_api_list( + "/deployments", + page=SyncOffsetPagination[DeploymentListResponse], + options=make_request_options( + extra_headers=extra_headers, + extra_query=extra_query, + extra_body=extra_body, + timeout=timeout, + query=maybe_transform( + { + "app_name": app_name, + "app_version": app_version, + "limit": limit, + "offset": offset, + "query": query, + }, + deployment_list_params.DeploymentListParams, + ), + ), + model=DeploymentListResponse, + ) + + 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, + ) -> None: + """Stops a running deployment and marks it for deletion. + + If the deployment is + already in a terminal state (stopped or failed), returns immediately. + + 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}") + extra_headers = {"Accept": "*/*", **(extra_headers or {})} + return self._delete( + path_template("/deployments/{id}", id=id), + options=make_request_options( + extra_headers=extra_headers, extra_query=extra_query, extra_body=extra_body, timeout=timeout + ), + cast_to=NoneType, + ) + + def follow( + self, + id: str, + *, + since: 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, + ) -> Stream[DeploymentFollowResponse]: + """ + Establishes a Server-Sent Events (SSE) stream that delivers real-time logs and + status updates for a deployment. The stream terminates automatically once the + deployment reaches a terminal state. + + Args: + since: Show logs since the given time (RFC timestamps or durations like 5m). + + 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}") + extra_headers = {"Accept": "text/event-stream", **(extra_headers or {})} + return self._get( + path_template("/deployments/{id}/events", id=id), + options=make_request_options( + extra_headers=extra_headers, + extra_query=extra_query, + extra_body=extra_body, + timeout=timeout, + query=maybe_transform({"since": since}, deployment_follow_params.DeploymentFollowParams), + ), + cast_to=cast( + Any, DeploymentFollowResponse + ), # Union types cannot be passed in as arguments in the type system + stream=True, + stream_cls=Stream[DeploymentFollowResponse], + ) + + +class AsyncDeploymentsResource(AsyncAPIResource): + """Create and manage app deployments and stream deployment events.""" + + @cached_property + def with_raw_response(self) -> AsyncDeploymentsResourceWithRawResponse: + """ + 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/kernel/kernel-python-sdk#accessing-raw-response-data-eg-headers + """ + return AsyncDeploymentsResourceWithRawResponse(self) + + @cached_property + def with_streaming_response(self) -> AsyncDeploymentsResourceWithStreamingResponse: + """ + An alternative to `.with_raw_response` that doesn't eagerly read the response body. + + For more information, see https://www.github.com/kernel/kernel-python-sdk#with_streaming_response + """ + return AsyncDeploymentsResourceWithStreamingResponse(self) + + async def create( + self, + *, + entrypoint_rel_path: str | Omit = omit, + env_vars: Dict[str, str] | Omit = omit, + file: FileTypes | Omit = omit, + force: bool | Omit = omit, + region: Literal["aws.us-east-1a"] | Omit = omit, + source: deployment_create_params.Source | Omit = omit, + version: 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, + ) -> DeploymentCreateResponse: + """ + Create a new deployment. + + Args: + entrypoint_rel_path: Relative path to the entrypoint of the application + + env_vars: Map of environment variables to set for the deployed application. Each key-value + pair represents an environment variable. + + file: ZIP file containing the application source directory + + force: Allow overwriting an existing app version + + region: Region for deployment. Currently we only support "aws.us-east-1a" + + source: Source from which to fetch application code. + + version: Version of the application. Can be any string. + + 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 + """ + body = deepcopy_with_paths( + { + "entrypoint_rel_path": entrypoint_rel_path, + "env_vars": env_vars, + "file": file, + "force": force, + "region": region, + "source": source, + "version": version, + }, + [["file"]], + ) + files = extract_files(cast(Mapping[str, object], body), paths=[["file"]]) + # It should be noted that the actual Content-Type header that will be + # sent to the server will contain a `boundary` parameter, e.g. + # multipart/form-data; boundary=---abc-- + extra_headers = {"Content-Type": "multipart/form-data", **(extra_headers or {})} + return await self._post( + "/deployments", + body=await async_maybe_transform(body, deployment_create_params.DeploymentCreateParams), + files=files, + options=make_request_options( + extra_headers=extra_headers, extra_query=extra_query, extra_body=extra_body, timeout=timeout + ), + cast_to=DeploymentCreateResponse, + ) + + async def retrieve( + 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, + ) -> DeploymentRetrieveResponse: + """ + Get information about a deployment's 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 + """ + if not id: + raise ValueError(f"Expected a non-empty value for `id` but received {id!r}") + return await self._get( + path_template("/deployments/{id}", id=id), + options=make_request_options( + extra_headers=extra_headers, extra_query=extra_query, extra_body=extra_body, timeout=timeout + ), + cast_to=DeploymentRetrieveResponse, + ) + + def list( + self, + *, + app_name: str | Omit = omit, + app_version: str | Omit = omit, + limit: int | Omit = omit, + offset: int | Omit = omit, + query: 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, + ) -> AsyncPaginator[DeploymentListResponse, AsyncOffsetPagination[DeploymentListResponse]]: + """List deployments. + + Optionally filter by application name and version. + + Args: + app_name: Filter results by application name. + + app_version: Filter results by application version. Requires app_name to be set. + + limit: Limit the number of deployments to return. + + offset: Offset the number of deployments to return. + + query: Search deployments by ID or app name. + + 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_api_list( + "/deployments", + page=AsyncOffsetPagination[DeploymentListResponse], + options=make_request_options( + extra_headers=extra_headers, + extra_query=extra_query, + extra_body=extra_body, + timeout=timeout, + query=maybe_transform( + { + "app_name": app_name, + "app_version": app_version, + "limit": limit, + "offset": offset, + "query": query, + }, + deployment_list_params.DeploymentListParams, + ), + ), + model=DeploymentListResponse, + ) + + 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, + ) -> None: + """Stops a running deployment and marks it for deletion. + + If the deployment is + already in a terminal state (stopped or failed), returns immediately. + + 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}") + extra_headers = {"Accept": "*/*", **(extra_headers or {})} + return await self._delete( + path_template("/deployments/{id}", id=id), + options=make_request_options( + extra_headers=extra_headers, extra_query=extra_query, extra_body=extra_body, timeout=timeout + ), + cast_to=NoneType, + ) + + async def follow( + self, + id: str, + *, + since: 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, + ) -> AsyncStream[DeploymentFollowResponse]: + """ + Establishes a Server-Sent Events (SSE) stream that delivers real-time logs and + status updates for a deployment. The stream terminates automatically once the + deployment reaches a terminal state. + + Args: + since: Show logs since the given time (RFC timestamps or durations like 5m). + + 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}") + extra_headers = {"Accept": "text/event-stream", **(extra_headers or {})} + return await self._get( + path_template("/deployments/{id}/events", id=id), + options=make_request_options( + extra_headers=extra_headers, + extra_query=extra_query, + extra_body=extra_body, + timeout=timeout, + query=await async_maybe_transform({"since": since}, deployment_follow_params.DeploymentFollowParams), + ), + cast_to=cast( + Any, DeploymentFollowResponse + ), # Union types cannot be passed in as arguments in the type system + stream=True, + stream_cls=AsyncStream[DeploymentFollowResponse], + ) + + +class DeploymentsResourceWithRawResponse: + def __init__(self, deployments: DeploymentsResource) -> None: + self._deployments = deployments + + self.create = to_raw_response_wrapper( + deployments.create, + ) + self.retrieve = to_raw_response_wrapper( + deployments.retrieve, + ) + self.list = to_raw_response_wrapper( + deployments.list, + ) + self.delete = to_raw_response_wrapper( + deployments.delete, + ) + self.follow = to_raw_response_wrapper( + deployments.follow, + ) + + +class AsyncDeploymentsResourceWithRawResponse: + def __init__(self, deployments: AsyncDeploymentsResource) -> None: + self._deployments = deployments + + self.create = async_to_raw_response_wrapper( + deployments.create, + ) + self.retrieve = async_to_raw_response_wrapper( + deployments.retrieve, + ) + self.list = async_to_raw_response_wrapper( + deployments.list, + ) + self.delete = async_to_raw_response_wrapper( + deployments.delete, + ) + self.follow = async_to_raw_response_wrapper( + deployments.follow, + ) + + +class DeploymentsResourceWithStreamingResponse: + def __init__(self, deployments: DeploymentsResource) -> None: + self._deployments = deployments + + self.create = to_streamed_response_wrapper( + deployments.create, + ) + self.retrieve = to_streamed_response_wrapper( + deployments.retrieve, + ) + self.list = to_streamed_response_wrapper( + deployments.list, + ) + self.delete = to_streamed_response_wrapper( + deployments.delete, + ) + self.follow = to_streamed_response_wrapper( + deployments.follow, + ) + + +class AsyncDeploymentsResourceWithStreamingResponse: + def __init__(self, deployments: AsyncDeploymentsResource) -> None: + self._deployments = deployments + + self.create = async_to_streamed_response_wrapper( + deployments.create, + ) + self.retrieve = async_to_streamed_response_wrapper( + deployments.retrieve, + ) + self.list = async_to_streamed_response_wrapper( + deployments.list, + ) + self.delete = async_to_streamed_response_wrapper( + deployments.delete, + ) + self.follow = async_to_streamed_response_wrapper( + deployments.follow, + ) diff --git a/src/kernel/resources/extensions.py b/src/kernel/resources/extensions.py new file mode 100644 index 00000000..81a1ff9a --- /dev/null +++ b/src/kernel/resources/extensions.py @@ -0,0 +1,708 @@ +# File generated from our OpenAPI spec by Stainless. See CONTRIBUTING.md for details. + +from __future__ import annotations + +from typing import Mapping, cast +from typing_extensions import Literal + +import httpx + +from ..types import extension_list_params, extension_upload_params, extension_download_from_chrome_store_params +from .._files import deepcopy_with_paths +from .._types import Body, Omit, Query, Headers, NoneType, NotGiven, FileTypes, omit, not_given +from .._utils import extract_files, path_template, maybe_transform, async_maybe_transform +from .._compat import cached_property +from .._resource import SyncAPIResource, AsyncAPIResource +from .._response import ( + BinaryAPIResponse, + AsyncBinaryAPIResponse, + StreamedBinaryAPIResponse, + AsyncStreamedBinaryAPIResponse, + to_raw_response_wrapper, + to_streamed_response_wrapper, + async_to_raw_response_wrapper, + to_custom_raw_response_wrapper, + async_to_streamed_response_wrapper, + to_custom_streamed_response_wrapper, + async_to_custom_raw_response_wrapper, + async_to_custom_streamed_response_wrapper, +) +from ..pagination import SyncOffsetPagination, AsyncOffsetPagination +from .._base_client import AsyncPaginator, make_request_options +from ..types.extension_get_response import ExtensionGetResponse +from ..types.extension_list_response import ExtensionListResponse +from ..types.extension_upload_response import ExtensionUploadResponse + +__all__ = ["ExtensionsResource", "AsyncExtensionsResource"] + + +class ExtensionsResource(SyncAPIResource): + """Create, list, retrieve, and delete browser extensions.""" + + @cached_property + def with_raw_response(self) -> ExtensionsResourceWithRawResponse: + """ + 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/kernel/kernel-python-sdk#accessing-raw-response-data-eg-headers + """ + return ExtensionsResourceWithRawResponse(self) + + @cached_property + def with_streaming_response(self) -> ExtensionsResourceWithStreamingResponse: + """ + An alternative to `.with_raw_response` that doesn't eagerly read the response body. + + For more information, see https://www.github.com/kernel/kernel-python-sdk#with_streaming_response + """ + return ExtensionsResourceWithStreamingResponse(self) + + def list( + self, + *, + limit: int | Omit = omit, + name: str | Omit = omit, + offset: int | Omit = omit, + query: 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, + ) -> SyncOffsetPagination[ExtensionListResponse]: + """ + List extensions in the resolved project. + + Args: + limit: Limit the number of extensions to return. + + name: Exact-match filter on extension name using the database collation. In + production, matching is case- and accent-insensitive. During the default-project + migration, unscoped requests prefer a concrete default-project extension over a + legacy unscoped extension with the same name. + + offset: Offset the number of extensions to return. + + query: Case-insensitive substring match against extension name. IDs match by exact + value. + + 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_api_list( + "/extensions", + page=SyncOffsetPagination[ExtensionListResponse], + options=make_request_options( + extra_headers=extra_headers, + extra_query=extra_query, + extra_body=extra_body, + timeout=timeout, + query=maybe_transform( + { + "limit": limit, + "name": name, + "offset": offset, + "query": query, + }, + extension_list_params.ExtensionListParams, + ), + ), + model=ExtensionListResponse, + ) + + def delete( + self, + id_or_name: 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, + ) -> None: + """ + Delete an extension by its ID or by its name. + + 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_or_name: + raise ValueError(f"Expected a non-empty value for `id_or_name` but received {id_or_name!r}") + extra_headers = {"Accept": "*/*", **(extra_headers or {})} + return self._delete( + path_template("/extensions/{id_or_name}", id_or_name=id_or_name), + options=make_request_options( + extra_headers=extra_headers, extra_query=extra_query, extra_body=extra_body, timeout=timeout + ), + cast_to=NoneType, + ) + + def download( + self, + id_or_name: 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, + ) -> BinaryAPIResponse: + """ + Download the extension as a ZIP archive by ID or name. + + 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_or_name: + raise ValueError(f"Expected a non-empty value for `id_or_name` but received {id_or_name!r}") + extra_headers = {"Accept": "application/octet-stream", **(extra_headers or {})} + return self._get( + path_template("/extensions/{id_or_name}", id_or_name=id_or_name), + options=make_request_options( + extra_headers=extra_headers, extra_query=extra_query, extra_body=extra_body, timeout=timeout + ), + cast_to=BinaryAPIResponse, + ) + + def download_from_chrome_store( + self, + *, + url: str, + os: Literal["win", "mac", "linux"] | 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, + ) -> BinaryAPIResponse: + """ + Returns a ZIP archive containing the unpacked extension fetched from the Chrome + Web Store. + + Args: + url: Chrome Web Store URL for the extension. + + os: Target operating system for the extension package. Defaults to linux. + + 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": "application/octet-stream", **(extra_headers or {})} + return self._get( + "/extensions/from_chrome_store", + options=make_request_options( + extra_headers=extra_headers, + extra_query=extra_query, + extra_body=extra_body, + timeout=timeout, + query=maybe_transform( + { + "url": url, + "os": os, + }, + extension_download_from_chrome_store_params.ExtensionDownloadFromChromeStoreParams, + ), + ), + cast_to=BinaryAPIResponse, + ) + + def get( + self, + id_or_name: 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, + ) -> ExtensionGetResponse: + """ + Get an extension's metadata (name, size, timestamps) by ID or name, without + downloading the archive. + + 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_or_name: + raise ValueError(f"Expected a non-empty value for `id_or_name` but received {id_or_name!r}") + return self._get( + path_template("/extensions/{id_or_name}/metadata", id_or_name=id_or_name), + options=make_request_options( + extra_headers=extra_headers, extra_query=extra_query, extra_body=extra_body, timeout=timeout + ), + cast_to=ExtensionGetResponse, + ) + + def upload( + self, + *, + file: FileTypes, + name: 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, + ) -> ExtensionUploadResponse: + """Upload a zip file containing an unpacked browser extension. + + Optionally provide a + unique name for later reference. + + Args: + file: ZIP file containing the browser extension. + + name: Optional unique name within the project to reference this extension. + + 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 + """ + body = deepcopy_with_paths( + { + "file": file, + "name": name, + }, + [["file"]], + ) + files = extract_files(cast(Mapping[str, object], body), paths=[["file"]]) + # It should be noted that the actual Content-Type header that will be + # sent to the server will contain a `boundary` parameter, e.g. + # multipart/form-data; boundary=---abc-- + extra_headers = {"Content-Type": "multipart/form-data", **(extra_headers or {})} + return self._post( + "/extensions", + body=maybe_transform(body, extension_upload_params.ExtensionUploadParams), + files=files, + options=make_request_options( + extra_headers=extra_headers, extra_query=extra_query, extra_body=extra_body, timeout=timeout + ), + cast_to=ExtensionUploadResponse, + ) + + +class AsyncExtensionsResource(AsyncAPIResource): + """Create, list, retrieve, and delete browser extensions.""" + + @cached_property + def with_raw_response(self) -> AsyncExtensionsResourceWithRawResponse: + """ + 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/kernel/kernel-python-sdk#accessing-raw-response-data-eg-headers + """ + return AsyncExtensionsResourceWithRawResponse(self) + + @cached_property + def with_streaming_response(self) -> AsyncExtensionsResourceWithStreamingResponse: + """ + An alternative to `.with_raw_response` that doesn't eagerly read the response body. + + For more information, see https://www.github.com/kernel/kernel-python-sdk#with_streaming_response + """ + return AsyncExtensionsResourceWithStreamingResponse(self) + + def list( + self, + *, + limit: int | Omit = omit, + name: str | Omit = omit, + offset: int | Omit = omit, + query: 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, + ) -> AsyncPaginator[ExtensionListResponse, AsyncOffsetPagination[ExtensionListResponse]]: + """ + List extensions in the resolved project. + + Args: + limit: Limit the number of extensions to return. + + name: Exact-match filter on extension name using the database collation. In + production, matching is case- and accent-insensitive. During the default-project + migration, unscoped requests prefer a concrete default-project extension over a + legacy unscoped extension with the same name. + + offset: Offset the number of extensions to return. + + query: Case-insensitive substring match against extension name. IDs match by exact + value. + + 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_api_list( + "/extensions", + page=AsyncOffsetPagination[ExtensionListResponse], + options=make_request_options( + extra_headers=extra_headers, + extra_query=extra_query, + extra_body=extra_body, + timeout=timeout, + query=maybe_transform( + { + "limit": limit, + "name": name, + "offset": offset, + "query": query, + }, + extension_list_params.ExtensionListParams, + ), + ), + model=ExtensionListResponse, + ) + + async def delete( + self, + id_or_name: 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, + ) -> None: + """ + Delete an extension by its ID or by its name. + + 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_or_name: + raise ValueError(f"Expected a non-empty value for `id_or_name` but received {id_or_name!r}") + extra_headers = {"Accept": "*/*", **(extra_headers or {})} + return await self._delete( + path_template("/extensions/{id_or_name}", id_or_name=id_or_name), + options=make_request_options( + extra_headers=extra_headers, extra_query=extra_query, extra_body=extra_body, timeout=timeout + ), + cast_to=NoneType, + ) + + async def download( + self, + id_or_name: 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, + ) -> AsyncBinaryAPIResponse: + """ + Download the extension as a ZIP archive by ID or name. + + 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_or_name: + raise ValueError(f"Expected a non-empty value for `id_or_name` but received {id_or_name!r}") + extra_headers = {"Accept": "application/octet-stream", **(extra_headers or {})} + return await self._get( + path_template("/extensions/{id_or_name}", id_or_name=id_or_name), + options=make_request_options( + extra_headers=extra_headers, extra_query=extra_query, extra_body=extra_body, timeout=timeout + ), + cast_to=AsyncBinaryAPIResponse, + ) + + async def download_from_chrome_store( + self, + *, + url: str, + os: Literal["win", "mac", "linux"] | 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, + ) -> AsyncBinaryAPIResponse: + """ + Returns a ZIP archive containing the unpacked extension fetched from the Chrome + Web Store. + + Args: + url: Chrome Web Store URL for the extension. + + os: Target operating system for the extension package. Defaults to linux. + + 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": "application/octet-stream", **(extra_headers or {})} + return await self._get( + "/extensions/from_chrome_store", + options=make_request_options( + extra_headers=extra_headers, + extra_query=extra_query, + extra_body=extra_body, + timeout=timeout, + query=await async_maybe_transform( + { + "url": url, + "os": os, + }, + extension_download_from_chrome_store_params.ExtensionDownloadFromChromeStoreParams, + ), + ), + cast_to=AsyncBinaryAPIResponse, + ) + + async def get( + self, + id_or_name: 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, + ) -> ExtensionGetResponse: + """ + Get an extension's metadata (name, size, timestamps) by ID or name, without + downloading the archive. + + 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_or_name: + raise ValueError(f"Expected a non-empty value for `id_or_name` but received {id_or_name!r}") + return await self._get( + path_template("/extensions/{id_or_name}/metadata", id_or_name=id_or_name), + options=make_request_options( + extra_headers=extra_headers, extra_query=extra_query, extra_body=extra_body, timeout=timeout + ), + cast_to=ExtensionGetResponse, + ) + + async def upload( + self, + *, + file: FileTypes, + name: 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, + ) -> ExtensionUploadResponse: + """Upload a zip file containing an unpacked browser extension. + + Optionally provide a + unique name for later reference. + + Args: + file: ZIP file containing the browser extension. + + name: Optional unique name within the project to reference this extension. + + 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 + """ + body = deepcopy_with_paths( + { + "file": file, + "name": name, + }, + [["file"]], + ) + files = extract_files(cast(Mapping[str, object], body), paths=[["file"]]) + # It should be noted that the actual Content-Type header that will be + # sent to the server will contain a `boundary` parameter, e.g. + # multipart/form-data; boundary=---abc-- + extra_headers = {"Content-Type": "multipart/form-data", **(extra_headers or {})} + return await self._post( + "/extensions", + body=await async_maybe_transform(body, extension_upload_params.ExtensionUploadParams), + files=files, + options=make_request_options( + extra_headers=extra_headers, extra_query=extra_query, extra_body=extra_body, timeout=timeout + ), + cast_to=ExtensionUploadResponse, + ) + + +class ExtensionsResourceWithRawResponse: + def __init__(self, extensions: ExtensionsResource) -> None: + self._extensions = extensions + + self.list = to_raw_response_wrapper( + extensions.list, + ) + self.delete = to_raw_response_wrapper( + extensions.delete, + ) + self.download = to_custom_raw_response_wrapper( + extensions.download, + BinaryAPIResponse, + ) + self.download_from_chrome_store = to_custom_raw_response_wrapper( + extensions.download_from_chrome_store, + BinaryAPIResponse, + ) + self.get = to_raw_response_wrapper( + extensions.get, + ) + self.upload = to_raw_response_wrapper( + extensions.upload, + ) + + +class AsyncExtensionsResourceWithRawResponse: + def __init__(self, extensions: AsyncExtensionsResource) -> None: + self._extensions = extensions + + self.list = async_to_raw_response_wrapper( + extensions.list, + ) + self.delete = async_to_raw_response_wrapper( + extensions.delete, + ) + self.download = async_to_custom_raw_response_wrapper( + extensions.download, + AsyncBinaryAPIResponse, + ) + self.download_from_chrome_store = async_to_custom_raw_response_wrapper( + extensions.download_from_chrome_store, + AsyncBinaryAPIResponse, + ) + self.get = async_to_raw_response_wrapper( + extensions.get, + ) + self.upload = async_to_raw_response_wrapper( + extensions.upload, + ) + + +class ExtensionsResourceWithStreamingResponse: + def __init__(self, extensions: ExtensionsResource) -> None: + self._extensions = extensions + + self.list = to_streamed_response_wrapper( + extensions.list, + ) + self.delete = to_streamed_response_wrapper( + extensions.delete, + ) + self.download = to_custom_streamed_response_wrapper( + extensions.download, + StreamedBinaryAPIResponse, + ) + self.download_from_chrome_store = to_custom_streamed_response_wrapper( + extensions.download_from_chrome_store, + StreamedBinaryAPIResponse, + ) + self.get = to_streamed_response_wrapper( + extensions.get, + ) + self.upload = to_streamed_response_wrapper( + extensions.upload, + ) + + +class AsyncExtensionsResourceWithStreamingResponse: + def __init__(self, extensions: AsyncExtensionsResource) -> None: + self._extensions = extensions + + self.list = async_to_streamed_response_wrapper( + extensions.list, + ) + self.delete = async_to_streamed_response_wrapper( + extensions.delete, + ) + self.download = async_to_custom_streamed_response_wrapper( + extensions.download, + AsyncStreamedBinaryAPIResponse, + ) + self.download_from_chrome_store = async_to_custom_streamed_response_wrapper( + extensions.download_from_chrome_store, + AsyncStreamedBinaryAPIResponse, + ) + self.get = async_to_streamed_response_wrapper( + extensions.get, + ) + self.upload = async_to_streamed_response_wrapper( + extensions.upload, + ) diff --git a/src/kernel/resources/invocations.py b/src/kernel/resources/invocations.py new file mode 100644 index 00000000..30db7dac --- /dev/null +++ b/src/kernel/resources/invocations.py @@ -0,0 +1,853 @@ +# File generated from our OpenAPI spec by Stainless. See CONTRIBUTING.md for details. + +from __future__ import annotations + +from typing import Any, cast +from typing_extensions import Literal + +import httpx + +from ..types import invocation_list_params, invocation_create_params, invocation_follow_params, invocation_update_params +from .._types import Body, Omit, Query, Headers, NoneType, NotGiven, omit, not_given +from .._utils import path_template, 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 ..pagination import SyncOffsetPagination, AsyncOffsetPagination +from .._base_client import AsyncPaginator, make_request_options +from ..types.invocation_list_response import InvocationListResponse +from ..types.invocation_create_response import InvocationCreateResponse +from ..types.invocation_follow_response import InvocationFollowResponse +from ..types.invocation_update_response import InvocationUpdateResponse +from ..types.invocation_retrieve_response import InvocationRetrieveResponse +from ..types.invocation_list_browsers_response import InvocationListBrowsersResponse + +__all__ = ["InvocationsResource", "AsyncInvocationsResource"] + + +class InvocationsResource(SyncAPIResource): + """Invoke actions and stream or query invocation status and events.""" + + @cached_property + def with_raw_response(self) -> InvocationsResourceWithRawResponse: + """ + 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/kernel/kernel-python-sdk#accessing-raw-response-data-eg-headers + """ + return InvocationsResourceWithRawResponse(self) + + @cached_property + def with_streaming_response(self) -> InvocationsResourceWithStreamingResponse: + """ + An alternative to `.with_raw_response` that doesn't eagerly read the response body. + + For more information, see https://www.github.com/kernel/kernel-python-sdk#with_streaming_response + """ + return InvocationsResourceWithStreamingResponse(self) + + def create( + self, + *, + action_name: str, + app_name: str, + version: str, + async_: bool | Omit = omit, + async_timeout_seconds: int | Omit = omit, + payload: 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, + ) -> InvocationCreateResponse: + """ + Invoke an action. + + Args: + action_name: Name of the action to invoke + + app_name: Name of the application + + version: Version of the application + + async_: If true, invoke asynchronously. When set, the API responds 202 Accepted with + status "queued". + + async_timeout_seconds: Timeout in seconds for async invocations (min 10, max 3600). Only applies when + async is true. + + payload: Input data for the action, sent as a JSON string. + + 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( + "/invocations", + body=maybe_transform( + { + "action_name": action_name, + "app_name": app_name, + "version": version, + "async_": async_, + "async_timeout_seconds": async_timeout_seconds, + "payload": payload, + }, + invocation_create_params.InvocationCreateParams, + ), + options=make_request_options( + extra_headers=extra_headers, extra_query=extra_query, extra_body=extra_body, timeout=timeout + ), + cast_to=InvocationCreateResponse, + ) + + def retrieve( + 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, + ) -> InvocationRetrieveResponse: + """ + Get details about an invocation's status and output. + + 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( + path_template("/invocations/{id}", id=id), + options=make_request_options( + extra_headers=extra_headers, extra_query=extra_query, extra_body=extra_body, timeout=timeout + ), + cast_to=InvocationRetrieveResponse, + ) + + def update( + self, + id: str, + *, + status: Literal["succeeded", "failed"], + output: 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, + ) -> InvocationUpdateResponse: + """Update an invocation's status or output. + + This can be used to cancel an + invocation by setting the status to "failed". + + Args: + status: New status for the invocation. + + output: Updated output of the invocation rendered as JSON string. + + 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( + path_template("/invocations/{id}", id=id), + body=maybe_transform( + { + "status": status, + "output": output, + }, + invocation_update_params.InvocationUpdateParams, + ), + options=make_request_options( + extra_headers=extra_headers, extra_query=extra_query, extra_body=extra_body, timeout=timeout + ), + cast_to=InvocationUpdateResponse, + ) + + def list( + self, + *, + action_name: str | Omit = omit, + app_name: str | Omit = omit, + deployment_id: str | Omit = omit, + limit: int | Omit = omit, + offset: int | Omit = omit, + query: str | Omit = omit, + since: str | Omit = omit, + status: Literal["queued", "running", "succeeded", "failed"] | Omit = omit, + version: 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, + ) -> SyncOffsetPagination[InvocationListResponse]: + """List invocations. + + Optionally filter by application name, action name, status, + deployment ID, or start time. + + Args: + action_name: Filter results by action name. + + app_name: Filter results by application name. + + deployment_id: Filter results by deployment ID. + + limit: Limit the number of invocations to return. + + offset: Offset the number of invocations to return. + + query: Search invocations by ID, app name, or action name. + + since: Show invocations that have started since the given time (RFC timestamps or + durations like 5m). + + status: Filter results by invocation status. + + version: Filter results by application version. + + 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_api_list( + "/invocations", + page=SyncOffsetPagination[InvocationListResponse], + options=make_request_options( + extra_headers=extra_headers, + extra_query=extra_query, + extra_body=extra_body, + timeout=timeout, + query=maybe_transform( + { + "action_name": action_name, + "app_name": app_name, + "deployment_id": deployment_id, + "limit": limit, + "offset": offset, + "query": query, + "since": since, + "status": status, + "version": version, + }, + invocation_list_params.InvocationListParams, + ), + ), + model=InvocationListResponse, + ) + + def delete_browsers( + 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, + ) -> None: + """ + Delete all browser sessions created within the specified invocation. + + 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}") + extra_headers = {"Accept": "*/*", **(extra_headers or {})} + return self._delete( + path_template("/invocations/{id}/browsers", id=id), + options=make_request_options( + extra_headers=extra_headers, extra_query=extra_query, extra_body=extra_body, timeout=timeout + ), + cast_to=NoneType, + ) + + def follow( + self, + id: str, + *, + since: 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, + ) -> Stream[InvocationFollowResponse]: + """ + Establishes a Server-Sent Events (SSE) stream that delivers real-time logs and + status updates for an invocation. The stream terminates automatically once the + invocation reaches a terminal state. + + Args: + since: Show logs since the given time (RFC timestamps or durations like 5m). + + 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}") + extra_headers = {"Accept": "text/event-stream", **(extra_headers or {})} + return self._get( + path_template("/invocations/{id}/events", id=id), + options=make_request_options( + extra_headers=extra_headers, + extra_query=extra_query, + extra_body=extra_body, + timeout=timeout, + query=maybe_transform({"since": since}, invocation_follow_params.InvocationFollowParams), + ), + cast_to=cast( + Any, InvocationFollowResponse + ), # Union types cannot be passed in as arguments in the type system + stream=True, + stream_cls=Stream[InvocationFollowResponse], + ) + + def list_browsers( + 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, + ) -> InvocationListBrowsersResponse: + """ + Returns all active browser sessions created within the specified invocation. + + 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( + path_template("/invocations/{id}/browsers", id=id), + options=make_request_options( + extra_headers=extra_headers, extra_query=extra_query, extra_body=extra_body, timeout=timeout + ), + cast_to=InvocationListBrowsersResponse, + ) + + +class AsyncInvocationsResource(AsyncAPIResource): + """Invoke actions and stream or query invocation status and events.""" + + @cached_property + def with_raw_response(self) -> AsyncInvocationsResourceWithRawResponse: + """ + 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/kernel/kernel-python-sdk#accessing-raw-response-data-eg-headers + """ + return AsyncInvocationsResourceWithRawResponse(self) + + @cached_property + def with_streaming_response(self) -> AsyncInvocationsResourceWithStreamingResponse: + """ + An alternative to `.with_raw_response` that doesn't eagerly read the response body. + + For more information, see https://www.github.com/kernel/kernel-python-sdk#with_streaming_response + """ + return AsyncInvocationsResourceWithStreamingResponse(self) + + async def create( + self, + *, + action_name: str, + app_name: str, + version: str, + async_: bool | Omit = omit, + async_timeout_seconds: int | Omit = omit, + payload: 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, + ) -> InvocationCreateResponse: + """ + Invoke an action. + + Args: + action_name: Name of the action to invoke + + app_name: Name of the application + + version: Version of the application + + async_: If true, invoke asynchronously. When set, the API responds 202 Accepted with + status "queued". + + async_timeout_seconds: Timeout in seconds for async invocations (min 10, max 3600). Only applies when + async is true. + + payload: Input data for the action, sent as a JSON string. + + 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( + "/invocations", + body=await async_maybe_transform( + { + "action_name": action_name, + "app_name": app_name, + "version": version, + "async_": async_, + "async_timeout_seconds": async_timeout_seconds, + "payload": payload, + }, + invocation_create_params.InvocationCreateParams, + ), + options=make_request_options( + extra_headers=extra_headers, extra_query=extra_query, extra_body=extra_body, timeout=timeout + ), + cast_to=InvocationCreateResponse, + ) + + async def retrieve( + 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, + ) -> InvocationRetrieveResponse: + """ + Get details about an invocation's status and output. + + 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( + path_template("/invocations/{id}", id=id), + options=make_request_options( + extra_headers=extra_headers, extra_query=extra_query, extra_body=extra_body, timeout=timeout + ), + cast_to=InvocationRetrieveResponse, + ) + + async def update( + self, + id: str, + *, + status: Literal["succeeded", "failed"], + output: 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, + ) -> InvocationUpdateResponse: + """Update an invocation's status or output. + + This can be used to cancel an + invocation by setting the status to "failed". + + Args: + status: New status for the invocation. + + output: Updated output of the invocation rendered as JSON string. + + 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( + path_template("/invocations/{id}", id=id), + body=await async_maybe_transform( + { + "status": status, + "output": output, + }, + invocation_update_params.InvocationUpdateParams, + ), + options=make_request_options( + extra_headers=extra_headers, extra_query=extra_query, extra_body=extra_body, timeout=timeout + ), + cast_to=InvocationUpdateResponse, + ) + + def list( + self, + *, + action_name: str | Omit = omit, + app_name: str | Omit = omit, + deployment_id: str | Omit = omit, + limit: int | Omit = omit, + offset: int | Omit = omit, + query: str | Omit = omit, + since: str | Omit = omit, + status: Literal["queued", "running", "succeeded", "failed"] | Omit = omit, + version: 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, + ) -> AsyncPaginator[InvocationListResponse, AsyncOffsetPagination[InvocationListResponse]]: + """List invocations. + + Optionally filter by application name, action name, status, + deployment ID, or start time. + + Args: + action_name: Filter results by action name. + + app_name: Filter results by application name. + + deployment_id: Filter results by deployment ID. + + limit: Limit the number of invocations to return. + + offset: Offset the number of invocations to return. + + query: Search invocations by ID, app name, or action name. + + since: Show invocations that have started since the given time (RFC timestamps or + durations like 5m). + + status: Filter results by invocation status. + + version: Filter results by application version. + + 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_api_list( + "/invocations", + page=AsyncOffsetPagination[InvocationListResponse], + options=make_request_options( + extra_headers=extra_headers, + extra_query=extra_query, + extra_body=extra_body, + timeout=timeout, + query=maybe_transform( + { + "action_name": action_name, + "app_name": app_name, + "deployment_id": deployment_id, + "limit": limit, + "offset": offset, + "query": query, + "since": since, + "status": status, + "version": version, + }, + invocation_list_params.InvocationListParams, + ), + ), + model=InvocationListResponse, + ) + + async def delete_browsers( + 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, + ) -> None: + """ + Delete all browser sessions created within the specified invocation. + + 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}") + extra_headers = {"Accept": "*/*", **(extra_headers or {})} + return await self._delete( + path_template("/invocations/{id}/browsers", id=id), + options=make_request_options( + extra_headers=extra_headers, extra_query=extra_query, extra_body=extra_body, timeout=timeout + ), + cast_to=NoneType, + ) + + async def follow( + self, + id: str, + *, + since: 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, + ) -> AsyncStream[InvocationFollowResponse]: + """ + Establishes a Server-Sent Events (SSE) stream that delivers real-time logs and + status updates for an invocation. The stream terminates automatically once the + invocation reaches a terminal state. + + Args: + since: Show logs since the given time (RFC timestamps or durations like 5m). + + 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}") + extra_headers = {"Accept": "text/event-stream", **(extra_headers or {})} + return await self._get( + path_template("/invocations/{id}/events", id=id), + options=make_request_options( + extra_headers=extra_headers, + extra_query=extra_query, + extra_body=extra_body, + timeout=timeout, + query=await async_maybe_transform({"since": since}, invocation_follow_params.InvocationFollowParams), + ), + cast_to=cast( + Any, InvocationFollowResponse + ), # Union types cannot be passed in as arguments in the type system + stream=True, + stream_cls=AsyncStream[InvocationFollowResponse], + ) + + async def list_browsers( + 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, + ) -> InvocationListBrowsersResponse: + """ + Returns all active browser sessions created within the specified invocation. + + 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( + path_template("/invocations/{id}/browsers", id=id), + options=make_request_options( + extra_headers=extra_headers, extra_query=extra_query, extra_body=extra_body, timeout=timeout + ), + cast_to=InvocationListBrowsersResponse, + ) + + +class InvocationsResourceWithRawResponse: + def __init__(self, invocations: InvocationsResource) -> None: + self._invocations = invocations + + self.create = to_raw_response_wrapper( + invocations.create, + ) + self.retrieve = to_raw_response_wrapper( + invocations.retrieve, + ) + self.update = to_raw_response_wrapper( + invocations.update, + ) + self.list = to_raw_response_wrapper( + invocations.list, + ) + self.delete_browsers = to_raw_response_wrapper( + invocations.delete_browsers, + ) + self.follow = to_raw_response_wrapper( + invocations.follow, + ) + self.list_browsers = to_raw_response_wrapper( + invocations.list_browsers, + ) + + +class AsyncInvocationsResourceWithRawResponse: + def __init__(self, invocations: AsyncInvocationsResource) -> None: + self._invocations = invocations + + self.create = async_to_raw_response_wrapper( + invocations.create, + ) + self.retrieve = async_to_raw_response_wrapper( + invocations.retrieve, + ) + self.update = async_to_raw_response_wrapper( + invocations.update, + ) + self.list = async_to_raw_response_wrapper( + invocations.list, + ) + self.delete_browsers = async_to_raw_response_wrapper( + invocations.delete_browsers, + ) + self.follow = async_to_raw_response_wrapper( + invocations.follow, + ) + self.list_browsers = async_to_raw_response_wrapper( + invocations.list_browsers, + ) + + +class InvocationsResourceWithStreamingResponse: + def __init__(self, invocations: InvocationsResource) -> None: + self._invocations = invocations + + self.create = to_streamed_response_wrapper( + invocations.create, + ) + self.retrieve = to_streamed_response_wrapper( + invocations.retrieve, + ) + self.update = to_streamed_response_wrapper( + invocations.update, + ) + self.list = to_streamed_response_wrapper( + invocations.list, + ) + self.delete_browsers = to_streamed_response_wrapper( + invocations.delete_browsers, + ) + self.follow = to_streamed_response_wrapper( + invocations.follow, + ) + self.list_browsers = to_streamed_response_wrapper( + invocations.list_browsers, + ) + + +class AsyncInvocationsResourceWithStreamingResponse: + def __init__(self, invocations: AsyncInvocationsResource) -> None: + self._invocations = invocations + + self.create = async_to_streamed_response_wrapper( + invocations.create, + ) + self.retrieve = async_to_streamed_response_wrapper( + invocations.retrieve, + ) + self.update = async_to_streamed_response_wrapper( + invocations.update, + ) + self.list = async_to_streamed_response_wrapper( + invocations.list, + ) + self.delete_browsers = async_to_streamed_response_wrapper( + invocations.delete_browsers, + ) + self.follow = async_to_streamed_response_wrapper( + invocations.follow, + ) + self.list_browsers = async_to_streamed_response_wrapper( + invocations.list_browsers, + ) diff --git a/src/kernel/resources/organization/__init__.py b/src/kernel/resources/organization/__init__.py new file mode 100644 index 00000000..68c28d60 --- /dev/null +++ b/src/kernel/resources/organization/__init__.py @@ -0,0 +1,33 @@ +# File generated from our OpenAPI spec by Stainless. See CONTRIBUTING.md for details. + +from .limits import ( + LimitsResource, + AsyncLimitsResource, + LimitsResourceWithRawResponse, + AsyncLimitsResourceWithRawResponse, + LimitsResourceWithStreamingResponse, + AsyncLimitsResourceWithStreamingResponse, +) +from .organization import ( + OrganizationResource, + AsyncOrganizationResource, + OrganizationResourceWithRawResponse, + AsyncOrganizationResourceWithRawResponse, + OrganizationResourceWithStreamingResponse, + AsyncOrganizationResourceWithStreamingResponse, +) + +__all__ = [ + "LimitsResource", + "AsyncLimitsResource", + "LimitsResourceWithRawResponse", + "AsyncLimitsResourceWithRawResponse", + "LimitsResourceWithStreamingResponse", + "AsyncLimitsResourceWithStreamingResponse", + "OrganizationResource", + "AsyncOrganizationResource", + "OrganizationResourceWithRawResponse", + "AsyncOrganizationResourceWithRawResponse", + "OrganizationResourceWithStreamingResponse", + "AsyncOrganizationResourceWithStreamingResponse", +] diff --git a/src/kernel/resources/organization/limits.py b/src/kernel/resources/organization/limits.py new file mode 100644 index 00000000..d30bfbfa --- /dev/null +++ b/src/kernel/resources/organization/limits.py @@ -0,0 +1,245 @@ +# File generated from our OpenAPI spec by Stainless. See CONTRIBUTING.md for details. + +from __future__ import annotations + +from typing import Optional + +import httpx + +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 +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.organization import limit_update_params +from ...types.organization.org_limits import OrgLimits + +__all__ = ["LimitsResource", "AsyncLimitsResource"] + + +class LimitsResource(SyncAPIResource): + """Read and manage organization-level limits.""" + + @cached_property + def with_raw_response(self) -> LimitsResourceWithRawResponse: + """ + 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/kernel/kernel-python-sdk#accessing-raw-response-data-eg-headers + """ + return LimitsResourceWithRawResponse(self) + + @cached_property + def with_streaming_response(self) -> LimitsResourceWithStreamingResponse: + """ + An alternative to `.with_raw_response` that doesn't eagerly read the response body. + + For more information, see https://www.github.com/kernel/kernel-python-sdk#with_streaming_response + """ + return LimitsResourceWithStreamingResponse(self) + + def retrieve( + 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, + ) -> OrgLimits: + """ + Get the organization's concurrency limit — the maximum browsers running at once + across on-demand sessions and browser pool reservations — and the default + per-project concurrency cap applied to projects without an explicit override. + """ + return self._get( + "/org/limits", + options=make_request_options( + extra_headers=extra_headers, extra_query=extra_query, extra_body=extra_body, timeout=timeout + ), + cast_to=OrgLimits, + ) + + def update( + self, + *, + default_project_max_concurrent_sessions: Optional[int] | 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, + ) -> OrgLimits: + """ + Set the default per-project concurrency cap applied to projects without an + explicit override. Set the value to 0 to remove the default; omit to leave it + unchanged. The default cannot exceed the organization's concurrency limit. + + Args: + default_project_max_concurrent_sessions: Default maximum concurrent browsers for projects without an explicit override. + Set to 0 to remove the default; omit to leave unchanged. Cannot exceed the + organization's concurrency limit. + + 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._patch( + "/org/limits", + body=maybe_transform( + {"default_project_max_concurrent_sessions": default_project_max_concurrent_sessions}, + limit_update_params.LimitUpdateParams, + ), + options=make_request_options( + extra_headers=extra_headers, extra_query=extra_query, extra_body=extra_body, timeout=timeout + ), + cast_to=OrgLimits, + ) + + +class AsyncLimitsResource(AsyncAPIResource): + """Read and manage organization-level limits.""" + + @cached_property + def with_raw_response(self) -> AsyncLimitsResourceWithRawResponse: + """ + 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/kernel/kernel-python-sdk#accessing-raw-response-data-eg-headers + """ + return AsyncLimitsResourceWithRawResponse(self) + + @cached_property + def with_streaming_response(self) -> AsyncLimitsResourceWithStreamingResponse: + """ + An alternative to `.with_raw_response` that doesn't eagerly read the response body. + + For more information, see https://www.github.com/kernel/kernel-python-sdk#with_streaming_response + """ + return AsyncLimitsResourceWithStreamingResponse(self) + + async def retrieve( + 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, + ) -> OrgLimits: + """ + Get the organization's concurrency limit — the maximum browsers running at once + across on-demand sessions and browser pool reservations — and the default + per-project concurrency cap applied to projects without an explicit override. + """ + return await self._get( + "/org/limits", + options=make_request_options( + extra_headers=extra_headers, extra_query=extra_query, extra_body=extra_body, timeout=timeout + ), + cast_to=OrgLimits, + ) + + async def update( + self, + *, + default_project_max_concurrent_sessions: Optional[int] | 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, + ) -> OrgLimits: + """ + Set the default per-project concurrency cap applied to projects without an + explicit override. Set the value to 0 to remove the default; omit to leave it + unchanged. The default cannot exceed the organization's concurrency limit. + + Args: + default_project_max_concurrent_sessions: Default maximum concurrent browsers for projects without an explicit override. + Set to 0 to remove the default; omit to leave unchanged. Cannot exceed the + organization's concurrency limit. + + 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._patch( + "/org/limits", + body=await async_maybe_transform( + {"default_project_max_concurrent_sessions": default_project_max_concurrent_sessions}, + limit_update_params.LimitUpdateParams, + ), + options=make_request_options( + extra_headers=extra_headers, extra_query=extra_query, extra_body=extra_body, timeout=timeout + ), + cast_to=OrgLimits, + ) + + +class LimitsResourceWithRawResponse: + def __init__(self, limits: LimitsResource) -> None: + self._limits = limits + + self.retrieve = to_raw_response_wrapper( + limits.retrieve, + ) + self.update = to_raw_response_wrapper( + limits.update, + ) + + +class AsyncLimitsResourceWithRawResponse: + def __init__(self, limits: AsyncLimitsResource) -> None: + self._limits = limits + + self.retrieve = async_to_raw_response_wrapper( + limits.retrieve, + ) + self.update = async_to_raw_response_wrapper( + limits.update, + ) + + +class LimitsResourceWithStreamingResponse: + def __init__(self, limits: LimitsResource) -> None: + self._limits = limits + + self.retrieve = to_streamed_response_wrapper( + limits.retrieve, + ) + self.update = to_streamed_response_wrapper( + limits.update, + ) + + +class AsyncLimitsResourceWithStreamingResponse: + def __init__(self, limits: AsyncLimitsResource) -> None: + self._limits = limits + + self.retrieve = async_to_streamed_response_wrapper( + limits.retrieve, + ) + self.update = async_to_streamed_response_wrapper( + limits.update, + ) diff --git a/src/kernel/resources/organization/organization.py b/src/kernel/resources/organization/organization.py new file mode 100644 index 00000000..21163122 --- /dev/null +++ b/src/kernel/resources/organization/organization.py @@ -0,0 +1,108 @@ +# File generated from our OpenAPI spec by Stainless. See CONTRIBUTING.md for details. + +from __future__ import annotations + +from .limits import ( + LimitsResource, + AsyncLimitsResource, + LimitsResourceWithRawResponse, + AsyncLimitsResourceWithRawResponse, + LimitsResourceWithStreamingResponse, + AsyncLimitsResourceWithStreamingResponse, +) +from ..._compat import cached_property +from ..._resource import SyncAPIResource, AsyncAPIResource + +__all__ = ["OrganizationResource", "AsyncOrganizationResource"] + + +class OrganizationResource(SyncAPIResource): + @cached_property + def limits(self) -> LimitsResource: + """Read and manage organization-level limits.""" + return LimitsResource(self._client) + + @cached_property + def with_raw_response(self) -> OrganizationResourceWithRawResponse: + """ + 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/kernel/kernel-python-sdk#accessing-raw-response-data-eg-headers + """ + return OrganizationResourceWithRawResponse(self) + + @cached_property + def with_streaming_response(self) -> OrganizationResourceWithStreamingResponse: + """ + An alternative to `.with_raw_response` that doesn't eagerly read the response body. + + For more information, see https://www.github.com/kernel/kernel-python-sdk#with_streaming_response + """ + return OrganizationResourceWithStreamingResponse(self) + + +class AsyncOrganizationResource(AsyncAPIResource): + @cached_property + def limits(self) -> AsyncLimitsResource: + """Read and manage organization-level limits.""" + return AsyncLimitsResource(self._client) + + @cached_property + def with_raw_response(self) -> AsyncOrganizationResourceWithRawResponse: + """ + 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/kernel/kernel-python-sdk#accessing-raw-response-data-eg-headers + """ + return AsyncOrganizationResourceWithRawResponse(self) + + @cached_property + def with_streaming_response(self) -> AsyncOrganizationResourceWithStreamingResponse: + """ + An alternative to `.with_raw_response` that doesn't eagerly read the response body. + + For more information, see https://www.github.com/kernel/kernel-python-sdk#with_streaming_response + """ + return AsyncOrganizationResourceWithStreamingResponse(self) + + +class OrganizationResourceWithRawResponse: + def __init__(self, organization: OrganizationResource) -> None: + self._organization = organization + + @cached_property + def limits(self) -> LimitsResourceWithRawResponse: + """Read and manage organization-level limits.""" + return LimitsResourceWithRawResponse(self._organization.limits) + + +class AsyncOrganizationResourceWithRawResponse: + def __init__(self, organization: AsyncOrganizationResource) -> None: + self._organization = organization + + @cached_property + def limits(self) -> AsyncLimitsResourceWithRawResponse: + """Read and manage organization-level limits.""" + return AsyncLimitsResourceWithRawResponse(self._organization.limits) + + +class OrganizationResourceWithStreamingResponse: + def __init__(self, organization: OrganizationResource) -> None: + self._organization = organization + + @cached_property + def limits(self) -> LimitsResourceWithStreamingResponse: + """Read and manage organization-level limits.""" + return LimitsResourceWithStreamingResponse(self._organization.limits) + + +class AsyncOrganizationResourceWithStreamingResponse: + def __init__(self, organization: AsyncOrganizationResource) -> None: + self._organization = organization + + @cached_property + def limits(self) -> AsyncLimitsResourceWithStreamingResponse: + """Read and manage organization-level limits.""" + return AsyncLimitsResourceWithStreamingResponse(self._organization.limits) diff --git a/src/kernel/resources/profiles.py b/src/kernel/resources/profiles.py new file mode 100644 index 00000000..8642940c --- /dev/null +++ b/src/kernel/resources/profiles.py @@ -0,0 +1,686 @@ +# 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 profile_list_params, profile_create_params, profile_update_params, profile_download_params +from .._types import Body, Omit, Query, Headers, NoneType, NotGiven, omit, not_given +from .._utils import path_template, maybe_transform, async_maybe_transform +from .._compat import cached_property +from .._resource import SyncAPIResource, AsyncAPIResource +from .._response import ( + BinaryAPIResponse, + AsyncBinaryAPIResponse, + StreamedBinaryAPIResponse, + AsyncStreamedBinaryAPIResponse, + to_raw_response_wrapper, + to_streamed_response_wrapper, + async_to_raw_response_wrapper, + to_custom_raw_response_wrapper, + async_to_streamed_response_wrapper, + to_custom_streamed_response_wrapper, + async_to_custom_raw_response_wrapper, + async_to_custom_streamed_response_wrapper, +) +from ..pagination import SyncOffsetPagination, AsyncOffsetPagination +from .._base_client import AsyncPaginator, make_request_options +from ..types.profile import Profile + +__all__ = ["ProfilesResource", "AsyncProfilesResource"] + + +class ProfilesResource(SyncAPIResource): + """Create, list, retrieve, and delete browser profiles.""" + + @cached_property + def with_raw_response(self) -> ProfilesResourceWithRawResponse: + """ + 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/kernel/kernel-python-sdk#accessing-raw-response-data-eg-headers + """ + return ProfilesResourceWithRawResponse(self) + + @cached_property + def with_streaming_response(self) -> ProfilesResourceWithStreamingResponse: + """ + An alternative to `.with_raw_response` that doesn't eagerly read the response body. + + For more information, see https://www.github.com/kernel/kernel-python-sdk#with_streaming_response + """ + return ProfilesResourceWithStreamingResponse(self) + + def create( + self, + *, + name: 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, + ) -> Profile: + """ + Create a browser profile that can be used to load state into future browser + sessions. + + Args: + name: Optional name of the profile. Must be unique within the logical project; during + the default-project migration, unscoped profiles and profiles in the org default + project are treated as the same project. + + 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( + "/profiles", + body=maybe_transform({"name": name}, profile_create_params.ProfileCreateParams), + options=make_request_options( + extra_headers=extra_headers, extra_query=extra_query, extra_body=extra_body, timeout=timeout + ), + cast_to=Profile, + ) + + def retrieve( + self, + id_or_name: 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, + ) -> Profile: + """ + Retrieve details for a single profile by its ID or name. + + 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_or_name: + raise ValueError(f"Expected a non-empty value for `id_or_name` but received {id_or_name!r}") + return self._get( + path_template("/profiles/{id_or_name}", id_or_name=id_or_name), + options=make_request_options( + extra_headers=extra_headers, extra_query=extra_query, extra_body=extra_body, timeout=timeout + ), + cast_to=Profile, + ) + + def update( + self, + id_or_name: str, + *, + name: 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, + ) -> Profile: + """Update a profile's name. + + Names must be unique within the logical project; during + the default-project migration, unscoped profiles and profiles in the org default + project are treated as the same project. Duplicate-name conflicts are checked + before update but are best-effort because there is no backing unique index. + Renaming a profile while a browser session references it by name may prevent + that session's changes from saving; prefer renaming when the profile is not in + use. + + Args: + name: New profile name. Must be unique within the logical project; during the + default-project migration, unscoped profiles and profiles in the org default + project are treated as the same project. + + 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_or_name: + raise ValueError(f"Expected a non-empty value for `id_or_name` but received {id_or_name!r}") + return self._patch( + path_template("/profiles/{id_or_name}", id_or_name=id_or_name), + body=maybe_transform({"name": name}, profile_update_params.ProfileUpdateParams), + options=make_request_options( + extra_headers=extra_headers, extra_query=extra_query, extra_body=extra_body, timeout=timeout + ), + cast_to=Profile, + ) + + def list( + self, + *, + limit: int | Omit = omit, + name: str | Omit = omit, + offset: int | Omit = omit, + query: 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, + ) -> SyncOffsetPagination[Profile]: + """ + List profiles with optional filtering and pagination. + + Args: + limit: Limit the number of profiles to return. + + name: Exact-match filter on profile name using the database collation. In production, + matching is case- and accent-insensitive. During the default-project migration, + unscoped requests prefer a concrete default-project profile over a legacy + unscoped profile with the same name. + + offset: Offset the number of profiles to return. + + query: Case-insensitive substring match against profile name or 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 + """ + return self._get_api_list( + "/profiles", + page=SyncOffsetPagination[Profile], + options=make_request_options( + extra_headers=extra_headers, + extra_query=extra_query, + extra_body=extra_body, + timeout=timeout, + query=maybe_transform( + { + "limit": limit, + "name": name, + "offset": offset, + "query": query, + }, + profile_list_params.ProfileListParams, + ), + ), + model=Profile, + ) + + def delete( + self, + id_or_name: 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, + ) -> None: + """ + Delete a profile by its ID or by its name. + + 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_or_name: + raise ValueError(f"Expected a non-empty value for `id_or_name` but received {id_or_name!r}") + extra_headers = {"Accept": "*/*", **(extra_headers or {})} + return self._delete( + path_template("/profiles/{id_or_name}", id_or_name=id_or_name), + options=make_request_options( + extra_headers=extra_headers, extra_query=extra_query, extra_body=extra_body, timeout=timeout + ), + cast_to=NoneType, + ) + + def download( + self, + id_or_name: str, + *, + format: Literal["tar.zst", "tar"] | 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, + ) -> BinaryAPIResponse: + """Downloads the profile in its stored format by default. + + Current profiles are + returned as zstd-compressed tar archives, while legacy profiles remain JSON. Set + `format=tar` to decompress current profiles during download; legacy profiles + remain JSON. + + Args: + format: Response format for current profile archives. Legacy profiles are always + returned as JSON. + + 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_or_name: + raise ValueError(f"Expected a non-empty value for `id_or_name` but received {id_or_name!r}") + extra_headers = {"Accept": "application/octet-stream", **(extra_headers or {})} + return self._get( + path_template("/profiles/{id_or_name}/download", id_or_name=id_or_name), + options=make_request_options( + extra_headers=extra_headers, + extra_query=extra_query, + extra_body=extra_body, + timeout=timeout, + query=maybe_transform({"format": format}, profile_download_params.ProfileDownloadParams), + ), + cast_to=BinaryAPIResponse, + ) + + +class AsyncProfilesResource(AsyncAPIResource): + """Create, list, retrieve, and delete browser profiles.""" + + @cached_property + def with_raw_response(self) -> AsyncProfilesResourceWithRawResponse: + """ + 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/kernel/kernel-python-sdk#accessing-raw-response-data-eg-headers + """ + return AsyncProfilesResourceWithRawResponse(self) + + @cached_property + def with_streaming_response(self) -> AsyncProfilesResourceWithStreamingResponse: + """ + An alternative to `.with_raw_response` that doesn't eagerly read the response body. + + For more information, see https://www.github.com/kernel/kernel-python-sdk#with_streaming_response + """ + return AsyncProfilesResourceWithStreamingResponse(self) + + async def create( + self, + *, + name: 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, + ) -> Profile: + """ + Create a browser profile that can be used to load state into future browser + sessions. + + Args: + name: Optional name of the profile. Must be unique within the logical project; during + the default-project migration, unscoped profiles and profiles in the org default + project are treated as the same project. + + 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( + "/profiles", + body=await async_maybe_transform({"name": name}, profile_create_params.ProfileCreateParams), + options=make_request_options( + extra_headers=extra_headers, extra_query=extra_query, extra_body=extra_body, timeout=timeout + ), + cast_to=Profile, + ) + + async def retrieve( + self, + id_or_name: 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, + ) -> Profile: + """ + Retrieve details for a single profile by its ID or name. + + 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_or_name: + raise ValueError(f"Expected a non-empty value for `id_or_name` but received {id_or_name!r}") + return await self._get( + path_template("/profiles/{id_or_name}", id_or_name=id_or_name), + options=make_request_options( + extra_headers=extra_headers, extra_query=extra_query, extra_body=extra_body, timeout=timeout + ), + cast_to=Profile, + ) + + async def update( + self, + id_or_name: str, + *, + name: 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, + ) -> Profile: + """Update a profile's name. + + Names must be unique within the logical project; during + the default-project migration, unscoped profiles and profiles in the org default + project are treated as the same project. Duplicate-name conflicts are checked + before update but are best-effort because there is no backing unique index. + Renaming a profile while a browser session references it by name may prevent + that session's changes from saving; prefer renaming when the profile is not in + use. + + Args: + name: New profile name. Must be unique within the logical project; during the + default-project migration, unscoped profiles and profiles in the org default + project are treated as the same project. + + 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_or_name: + raise ValueError(f"Expected a non-empty value for `id_or_name` but received {id_or_name!r}") + return await self._patch( + path_template("/profiles/{id_or_name}", id_or_name=id_or_name), + body=await async_maybe_transform({"name": name}, profile_update_params.ProfileUpdateParams), + options=make_request_options( + extra_headers=extra_headers, extra_query=extra_query, extra_body=extra_body, timeout=timeout + ), + cast_to=Profile, + ) + + def list( + self, + *, + limit: int | Omit = omit, + name: str | Omit = omit, + offset: int | Omit = omit, + query: 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, + ) -> AsyncPaginator[Profile, AsyncOffsetPagination[Profile]]: + """ + List profiles with optional filtering and pagination. + + Args: + limit: Limit the number of profiles to return. + + name: Exact-match filter on profile name using the database collation. In production, + matching is case- and accent-insensitive. During the default-project migration, + unscoped requests prefer a concrete default-project profile over a legacy + unscoped profile with the same name. + + offset: Offset the number of profiles to return. + + query: Case-insensitive substring match against profile name or 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 + """ + return self._get_api_list( + "/profiles", + page=AsyncOffsetPagination[Profile], + options=make_request_options( + extra_headers=extra_headers, + extra_query=extra_query, + extra_body=extra_body, + timeout=timeout, + query=maybe_transform( + { + "limit": limit, + "name": name, + "offset": offset, + "query": query, + }, + profile_list_params.ProfileListParams, + ), + ), + model=Profile, + ) + + async def delete( + self, + id_or_name: 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, + ) -> None: + """ + Delete a profile by its ID or by its name. + + 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_or_name: + raise ValueError(f"Expected a non-empty value for `id_or_name` but received {id_or_name!r}") + extra_headers = {"Accept": "*/*", **(extra_headers or {})} + return await self._delete( + path_template("/profiles/{id_or_name}", id_or_name=id_or_name), + options=make_request_options( + extra_headers=extra_headers, extra_query=extra_query, extra_body=extra_body, timeout=timeout + ), + cast_to=NoneType, + ) + + async def download( + self, + id_or_name: str, + *, + format: Literal["tar.zst", "tar"] | 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, + ) -> AsyncBinaryAPIResponse: + """Downloads the profile in its stored format by default. + + Current profiles are + returned as zstd-compressed tar archives, while legacy profiles remain JSON. Set + `format=tar` to decompress current profiles during download; legacy profiles + remain JSON. + + Args: + format: Response format for current profile archives. Legacy profiles are always + returned as JSON. + + 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_or_name: + raise ValueError(f"Expected a non-empty value for `id_or_name` but received {id_or_name!r}") + extra_headers = {"Accept": "application/octet-stream", **(extra_headers or {})} + return await self._get( + path_template("/profiles/{id_or_name}/download", id_or_name=id_or_name), + options=make_request_options( + extra_headers=extra_headers, + extra_query=extra_query, + extra_body=extra_body, + timeout=timeout, + query=await async_maybe_transform({"format": format}, profile_download_params.ProfileDownloadParams), + ), + cast_to=AsyncBinaryAPIResponse, + ) + + +class ProfilesResourceWithRawResponse: + def __init__(self, profiles: ProfilesResource) -> None: + self._profiles = profiles + + self.create = to_raw_response_wrapper( + profiles.create, + ) + self.retrieve = to_raw_response_wrapper( + profiles.retrieve, + ) + self.update = to_raw_response_wrapper( + profiles.update, + ) + self.list = to_raw_response_wrapper( + profiles.list, + ) + self.delete = to_raw_response_wrapper( + profiles.delete, + ) + self.download = to_custom_raw_response_wrapper( + profiles.download, + BinaryAPIResponse, + ) + + +class AsyncProfilesResourceWithRawResponse: + def __init__(self, profiles: AsyncProfilesResource) -> None: + self._profiles = profiles + + self.create = async_to_raw_response_wrapper( + profiles.create, + ) + self.retrieve = async_to_raw_response_wrapper( + profiles.retrieve, + ) + self.update = async_to_raw_response_wrapper( + profiles.update, + ) + self.list = async_to_raw_response_wrapper( + profiles.list, + ) + self.delete = async_to_raw_response_wrapper( + profiles.delete, + ) + self.download = async_to_custom_raw_response_wrapper( + profiles.download, + AsyncBinaryAPIResponse, + ) + + +class ProfilesResourceWithStreamingResponse: + def __init__(self, profiles: ProfilesResource) -> None: + self._profiles = profiles + + self.create = to_streamed_response_wrapper( + profiles.create, + ) + self.retrieve = to_streamed_response_wrapper( + profiles.retrieve, + ) + self.update = to_streamed_response_wrapper( + profiles.update, + ) + self.list = to_streamed_response_wrapper( + profiles.list, + ) + self.delete = to_streamed_response_wrapper( + profiles.delete, + ) + self.download = to_custom_streamed_response_wrapper( + profiles.download, + StreamedBinaryAPIResponse, + ) + + +class AsyncProfilesResourceWithStreamingResponse: + def __init__(self, profiles: AsyncProfilesResource) -> None: + self._profiles = profiles + + self.create = async_to_streamed_response_wrapper( + profiles.create, + ) + self.retrieve = async_to_streamed_response_wrapper( + profiles.retrieve, + ) + self.update = async_to_streamed_response_wrapper( + profiles.update, + ) + self.list = async_to_streamed_response_wrapper( + profiles.list, + ) + self.delete = async_to_streamed_response_wrapper( + profiles.delete, + ) + self.download = async_to_custom_streamed_response_wrapper( + profiles.download, + AsyncStreamedBinaryAPIResponse, + ) diff --git a/src/kernel/resources/projects/__init__.py b/src/kernel/resources/projects/__init__.py new file mode 100644 index 00000000..41263893 --- /dev/null +++ b/src/kernel/resources/projects/__init__.py @@ -0,0 +1,33 @@ +# File generated from our OpenAPI spec by Stainless. See CONTRIBUTING.md for details. + +from .limits import ( + LimitsResource, + AsyncLimitsResource, + LimitsResourceWithRawResponse, + AsyncLimitsResourceWithRawResponse, + LimitsResourceWithStreamingResponse, + AsyncLimitsResourceWithStreamingResponse, +) +from .projects import ( + ProjectsResource, + AsyncProjectsResource, + ProjectsResourceWithRawResponse, + AsyncProjectsResourceWithRawResponse, + ProjectsResourceWithStreamingResponse, + AsyncProjectsResourceWithStreamingResponse, +) + +__all__ = [ + "LimitsResource", + "AsyncLimitsResource", + "LimitsResourceWithRawResponse", + "AsyncLimitsResourceWithRawResponse", + "LimitsResourceWithStreamingResponse", + "AsyncLimitsResourceWithStreamingResponse", + "ProjectsResource", + "AsyncProjectsResource", + "ProjectsResourceWithRawResponse", + "AsyncProjectsResourceWithRawResponse", + "ProjectsResourceWithStreamingResponse", + "AsyncProjectsResourceWithStreamingResponse", +] diff --git a/src/kernel/resources/projects/limits.py b/src/kernel/resources/projects/limits.py new file mode 100644 index 00000000..87227768 --- /dev/null +++ b/src/kernel/resources/projects/limits.py @@ -0,0 +1,309 @@ +# File generated from our OpenAPI spec by Stainless. See CONTRIBUTING.md for details. + +from __future__ import annotations + +from typing import Optional + +import httpx + +from ..._types import Body, Omit, Query, Headers, NotGiven, omit, not_given +from ..._utils import path_template, 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.projects import limit_update_params +from ...types.projects.project_limits import ProjectLimits + +__all__ = ["LimitsResource", "AsyncLimitsResource"] + + +class LimitsResource(SyncAPIResource): + """ + Create and manage projects for resource isolation within an organization. + When projects are disabled for the organization, project operations return + `404` with code `projects_disabled`. + """ + + @cached_property + def with_raw_response(self) -> LimitsResourceWithRawResponse: + """ + 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/kernel/kernel-python-sdk#accessing-raw-response-data-eg-headers + """ + return LimitsResourceWithRawResponse(self) + + @cached_property + def with_streaming_response(self) -> LimitsResourceWithStreamingResponse: + """ + An alternative to `.with_raw_response` that doesn't eagerly read the response body. + + For more information, see https://www.github.com/kernel/kernel-python-sdk#with_streaming_response + """ + return LimitsResourceWithStreamingResponse(self) + + def retrieve( + 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, + ) -> ProjectLimits: + """Get the resource limit overrides for a project. + + Null values mean no + project-level cap (org limit applies). + + 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( + path_template("/org/projects/{id}/limits", id=id), + options=make_request_options( + extra_headers=extra_headers, extra_query=extra_query, extra_body=extra_body, timeout=timeout + ), + cast_to=ProjectLimits, + ) + + def update( + self, + id: str, + *, + max_concurrent_invocations: Optional[int] | Omit = omit, + max_concurrent_sessions: Optional[int] | Omit = omit, + max_pooled_sessions: Optional[int] | 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, + ) -> ProjectLimits: + """Update resource limit overrides for a project. + + Only fields present in the + request are modified. Set a field to 0 to remove that limit cap; omit a field to + leave it unchanged. + + Args: + max_concurrent_invocations: Maximum concurrent app invocations for this project. Set to 0 to remove the cap; + omit to leave unchanged. + + max_concurrent_sessions: Maximum concurrent browsers for this project, covering both on-demand sessions + and browser pool reservations. Set to 0 to remove the cap; omit to leave + unchanged. + + max_pooled_sessions: Deprecated: pooled browsers now count toward `max_concurrent_sessions`. Requests + that set this field are rejected with a 400. + + 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( + path_template("/org/projects/{id}/limits", id=id), + body=maybe_transform( + { + "max_concurrent_invocations": max_concurrent_invocations, + "max_concurrent_sessions": max_concurrent_sessions, + "max_pooled_sessions": max_pooled_sessions, + }, + limit_update_params.LimitUpdateParams, + ), + options=make_request_options( + extra_headers=extra_headers, extra_query=extra_query, extra_body=extra_body, timeout=timeout + ), + cast_to=ProjectLimits, + ) + + +class AsyncLimitsResource(AsyncAPIResource): + """ + Create and manage projects for resource isolation within an organization. + When projects are disabled for the organization, project operations return + `404` with code `projects_disabled`. + """ + + @cached_property + def with_raw_response(self) -> AsyncLimitsResourceWithRawResponse: + """ + 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/kernel/kernel-python-sdk#accessing-raw-response-data-eg-headers + """ + return AsyncLimitsResourceWithRawResponse(self) + + @cached_property + def with_streaming_response(self) -> AsyncLimitsResourceWithStreamingResponse: + """ + An alternative to `.with_raw_response` that doesn't eagerly read the response body. + + For more information, see https://www.github.com/kernel/kernel-python-sdk#with_streaming_response + """ + return AsyncLimitsResourceWithStreamingResponse(self) + + async def retrieve( + 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, + ) -> ProjectLimits: + """Get the resource limit overrides for a project. + + Null values mean no + project-level cap (org limit applies). + + 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( + path_template("/org/projects/{id}/limits", id=id), + options=make_request_options( + extra_headers=extra_headers, extra_query=extra_query, extra_body=extra_body, timeout=timeout + ), + cast_to=ProjectLimits, + ) + + async def update( + self, + id: str, + *, + max_concurrent_invocations: Optional[int] | Omit = omit, + max_concurrent_sessions: Optional[int] | Omit = omit, + max_pooled_sessions: Optional[int] | 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, + ) -> ProjectLimits: + """Update resource limit overrides for a project. + + Only fields present in the + request are modified. Set a field to 0 to remove that limit cap; omit a field to + leave it unchanged. + + Args: + max_concurrent_invocations: Maximum concurrent app invocations for this project. Set to 0 to remove the cap; + omit to leave unchanged. + + max_concurrent_sessions: Maximum concurrent browsers for this project, covering both on-demand sessions + and browser pool reservations. Set to 0 to remove the cap; omit to leave + unchanged. + + max_pooled_sessions: Deprecated: pooled browsers now count toward `max_concurrent_sessions`. Requests + that set this field are rejected with a 400. + + 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( + path_template("/org/projects/{id}/limits", id=id), + body=await async_maybe_transform( + { + "max_concurrent_invocations": max_concurrent_invocations, + "max_concurrent_sessions": max_concurrent_sessions, + "max_pooled_sessions": max_pooled_sessions, + }, + limit_update_params.LimitUpdateParams, + ), + options=make_request_options( + extra_headers=extra_headers, extra_query=extra_query, extra_body=extra_body, timeout=timeout + ), + cast_to=ProjectLimits, + ) + + +class LimitsResourceWithRawResponse: + def __init__(self, limits: LimitsResource) -> None: + self._limits = limits + + self.retrieve = to_raw_response_wrapper( + limits.retrieve, + ) + self.update = to_raw_response_wrapper( + limits.update, + ) + + +class AsyncLimitsResourceWithRawResponse: + def __init__(self, limits: AsyncLimitsResource) -> None: + self._limits = limits + + self.retrieve = async_to_raw_response_wrapper( + limits.retrieve, + ) + self.update = async_to_raw_response_wrapper( + limits.update, + ) + + +class LimitsResourceWithStreamingResponse: + def __init__(self, limits: LimitsResource) -> None: + self._limits = limits + + self.retrieve = to_streamed_response_wrapper( + limits.retrieve, + ) + self.update = to_streamed_response_wrapper( + limits.update, + ) + + +class AsyncLimitsResourceWithStreamingResponse: + def __init__(self, limits: AsyncLimitsResource) -> None: + self._limits = limits + + self.retrieve = async_to_streamed_response_wrapper( + limits.retrieve, + ) + self.update = async_to_streamed_response_wrapper( + limits.update, + ) diff --git a/src/kernel/resources/projects/projects.py b/src/kernel/resources/projects/projects.py new file mode 100644 index 00000000..2c3d3569 --- /dev/null +++ b/src/kernel/resources/projects/projects.py @@ -0,0 +1,634 @@ +# 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 .limits import ( + LimitsResource, + AsyncLimitsResource, + LimitsResourceWithRawResponse, + AsyncLimitsResourceWithRawResponse, + LimitsResourceWithStreamingResponse, + AsyncLimitsResourceWithStreamingResponse, +) +from ...types import project_list_params, project_create_params, project_update_params +from ..._types import Body, Omit, Query, Headers, NoneType, NotGiven, omit, not_given +from ..._utils import path_template, 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 ...pagination import SyncOffsetPagination, AsyncOffsetPagination +from ..._base_client import AsyncPaginator, make_request_options +from ...types.project import Project + +__all__ = ["ProjectsResource", "AsyncProjectsResource"] + + +class ProjectsResource(SyncAPIResource): + """ + Create and manage projects for resource isolation within an organization. + When projects are disabled for the organization, project operations return + `404` with code `projects_disabled`. + """ + + @cached_property + def limits(self) -> LimitsResource: + """ + Create and manage projects for resource isolation within an organization. + When projects are disabled for the organization, project operations return + `404` with code `projects_disabled`. + """ + return LimitsResource(self._client) + + @cached_property + def with_raw_response(self) -> ProjectsResourceWithRawResponse: + """ + 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/kernel/kernel-python-sdk#accessing-raw-response-data-eg-headers + """ + return ProjectsResourceWithRawResponse(self) + + @cached_property + def with_streaming_response(self) -> ProjectsResourceWithStreamingResponse: + """ + An alternative to `.with_raw_response` that doesn't eagerly read the response body. + + For more information, see https://www.github.com/kernel/kernel-python-sdk#with_streaming_response + """ + return ProjectsResourceWithStreamingResponse(self) + + def create( + self, + *, + name: 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, + ) -> Project: + """ + Create a new project within the authenticated organization. + + Args: + name: Project name (1-255 Unicode code points) + + 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( + "/org/projects", + body=maybe_transform({"name": name}, project_create_params.ProjectCreateParams), + options=make_request_options( + extra_headers=extra_headers, extra_query=extra_query, extra_body=extra_body, timeout=timeout + ), + cast_to=Project, + ) + + def retrieve( + 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, + ) -> Project: + """Get a project by its ID or by its name. + + Names are unique within an organization. + + 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( + path_template("/org/projects/{id}", id=id), + options=make_request_options( + extra_headers=extra_headers, extra_query=extra_query, extra_body=extra_body, timeout=timeout + ), + cast_to=Project, + ) + + def update( + self, + id: str, + *, + name: str | Omit = omit, + status: Literal["active", "archived"] | 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, + ) -> Project: + """ + Update a project's name or status. + + Args: + name: New project name (1-255 Unicode code points) + + status: New project status + + 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( + path_template("/org/projects/{id}", id=id), + body=maybe_transform( + { + "name": name, + "status": status, + }, + project_update_params.ProjectUpdateParams, + ), + options=make_request_options( + extra_headers=extra_headers, extra_query=extra_query, extra_body=extra_body, timeout=timeout + ), + cast_to=Project, + ) + + def list( + self, + *, + limit: int | Omit = omit, + name: str | Omit = omit, + offset: int | Omit = omit, + query: 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, + ) -> SyncOffsetPagination[Project]: + """ + List projects for the authenticated organization. + + Args: + limit: Maximum number of results to return + + name: Exact-match filter on project name using the database collation. In production, + matching is case- and accent-insensitive. + + offset: Number of results to skip + + query: Case-insensitive substring match against project name + + 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_api_list( + "/org/projects", + page=SyncOffsetPagination[Project], + options=make_request_options( + extra_headers=extra_headers, + extra_query=extra_query, + extra_body=extra_body, + timeout=timeout, + query=maybe_transform( + { + "limit": limit, + "name": name, + "offset": offset, + "query": query, + }, + project_list_params.ProjectListParams, + ), + ), + model=Project, + ) + + 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, + ) -> None: + """Soft-delete a project. + + The project must be empty (no active resources). + + 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}") + extra_headers = {"Accept": "*/*", **(extra_headers or {})} + return self._delete( + path_template("/org/projects/{id}", id=id), + options=make_request_options( + extra_headers=extra_headers, extra_query=extra_query, extra_body=extra_body, timeout=timeout + ), + cast_to=NoneType, + ) + + +class AsyncProjectsResource(AsyncAPIResource): + """ + Create and manage projects for resource isolation within an organization. + When projects are disabled for the organization, project operations return + `404` with code `projects_disabled`. + """ + + @cached_property + def limits(self) -> AsyncLimitsResource: + """ + Create and manage projects for resource isolation within an organization. + When projects are disabled for the organization, project operations return + `404` with code `projects_disabled`. + """ + return AsyncLimitsResource(self._client) + + @cached_property + def with_raw_response(self) -> AsyncProjectsResourceWithRawResponse: + """ + 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/kernel/kernel-python-sdk#accessing-raw-response-data-eg-headers + """ + return AsyncProjectsResourceWithRawResponse(self) + + @cached_property + def with_streaming_response(self) -> AsyncProjectsResourceWithStreamingResponse: + """ + An alternative to `.with_raw_response` that doesn't eagerly read the response body. + + For more information, see https://www.github.com/kernel/kernel-python-sdk#with_streaming_response + """ + return AsyncProjectsResourceWithStreamingResponse(self) + + async def create( + self, + *, + name: 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, + ) -> Project: + """ + Create a new project within the authenticated organization. + + Args: + name: Project name (1-255 Unicode code points) + + 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( + "/org/projects", + body=await async_maybe_transform({"name": name}, project_create_params.ProjectCreateParams), + options=make_request_options( + extra_headers=extra_headers, extra_query=extra_query, extra_body=extra_body, timeout=timeout + ), + cast_to=Project, + ) + + async def retrieve( + 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, + ) -> Project: + """Get a project by its ID or by its name. + + Names are unique within an organization. + + 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( + path_template("/org/projects/{id}", id=id), + options=make_request_options( + extra_headers=extra_headers, extra_query=extra_query, extra_body=extra_body, timeout=timeout + ), + cast_to=Project, + ) + + async def update( + self, + id: str, + *, + name: str | Omit = omit, + status: Literal["active", "archived"] | 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, + ) -> Project: + """ + Update a project's name or status. + + Args: + name: New project name (1-255 Unicode code points) + + status: New project status + + 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( + path_template("/org/projects/{id}", id=id), + body=await async_maybe_transform( + { + "name": name, + "status": status, + }, + project_update_params.ProjectUpdateParams, + ), + options=make_request_options( + extra_headers=extra_headers, extra_query=extra_query, extra_body=extra_body, timeout=timeout + ), + cast_to=Project, + ) + + def list( + self, + *, + limit: int | Omit = omit, + name: str | Omit = omit, + offset: int | Omit = omit, + query: 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, + ) -> AsyncPaginator[Project, AsyncOffsetPagination[Project]]: + """ + List projects for the authenticated organization. + + Args: + limit: Maximum number of results to return + + name: Exact-match filter on project name using the database collation. In production, + matching is case- and accent-insensitive. + + offset: Number of results to skip + + query: Case-insensitive substring match against project name + + 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_api_list( + "/org/projects", + page=AsyncOffsetPagination[Project], + options=make_request_options( + extra_headers=extra_headers, + extra_query=extra_query, + extra_body=extra_body, + timeout=timeout, + query=maybe_transform( + { + "limit": limit, + "name": name, + "offset": offset, + "query": query, + }, + project_list_params.ProjectListParams, + ), + ), + model=Project, + ) + + 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, + ) -> None: + """Soft-delete a project. + + The project must be empty (no active resources). + + 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}") + extra_headers = {"Accept": "*/*", **(extra_headers or {})} + return await self._delete( + path_template("/org/projects/{id}", id=id), + options=make_request_options( + extra_headers=extra_headers, extra_query=extra_query, extra_body=extra_body, timeout=timeout + ), + cast_to=NoneType, + ) + + +class ProjectsResourceWithRawResponse: + def __init__(self, projects: ProjectsResource) -> None: + self._projects = projects + + self.create = to_raw_response_wrapper( + projects.create, + ) + self.retrieve = to_raw_response_wrapper( + projects.retrieve, + ) + self.update = to_raw_response_wrapper( + projects.update, + ) + self.list = to_raw_response_wrapper( + projects.list, + ) + self.delete = to_raw_response_wrapper( + projects.delete, + ) + + @cached_property + def limits(self) -> LimitsResourceWithRawResponse: + """ + Create and manage projects for resource isolation within an organization. + When projects are disabled for the organization, project operations return + `404` with code `projects_disabled`. + """ + return LimitsResourceWithRawResponse(self._projects.limits) + + +class AsyncProjectsResourceWithRawResponse: + def __init__(self, projects: AsyncProjectsResource) -> None: + self._projects = projects + + self.create = async_to_raw_response_wrapper( + projects.create, + ) + self.retrieve = async_to_raw_response_wrapper( + projects.retrieve, + ) + self.update = async_to_raw_response_wrapper( + projects.update, + ) + self.list = async_to_raw_response_wrapper( + projects.list, + ) + self.delete = async_to_raw_response_wrapper( + projects.delete, + ) + + @cached_property + def limits(self) -> AsyncLimitsResourceWithRawResponse: + """ + Create and manage projects for resource isolation within an organization. + When projects are disabled for the organization, project operations return + `404` with code `projects_disabled`. + """ + return AsyncLimitsResourceWithRawResponse(self._projects.limits) + + +class ProjectsResourceWithStreamingResponse: + def __init__(self, projects: ProjectsResource) -> None: + self._projects = projects + + self.create = to_streamed_response_wrapper( + projects.create, + ) + self.retrieve = to_streamed_response_wrapper( + projects.retrieve, + ) + self.update = to_streamed_response_wrapper( + projects.update, + ) + self.list = to_streamed_response_wrapper( + projects.list, + ) + self.delete = to_streamed_response_wrapper( + projects.delete, + ) + + @cached_property + def limits(self) -> LimitsResourceWithStreamingResponse: + """ + Create and manage projects for resource isolation within an organization. + When projects are disabled for the organization, project operations return + `404` with code `projects_disabled`. + """ + return LimitsResourceWithStreamingResponse(self._projects.limits) + + +class AsyncProjectsResourceWithStreamingResponse: + def __init__(self, projects: AsyncProjectsResource) -> None: + self._projects = projects + + self.create = async_to_streamed_response_wrapper( + projects.create, + ) + self.retrieve = async_to_streamed_response_wrapper( + projects.retrieve, + ) + self.update = async_to_streamed_response_wrapper( + projects.update, + ) + self.list = async_to_streamed_response_wrapper( + projects.list, + ) + self.delete = async_to_streamed_response_wrapper( + projects.delete, + ) + + @cached_property + def limits(self) -> AsyncLimitsResourceWithStreamingResponse: + """ + Create and manage projects for resource isolation within an organization. + When projects are disabled for the organization, project operations return + `404` with code `projects_disabled`. + """ + return AsyncLimitsResourceWithStreamingResponse(self._projects.limits) diff --git a/src/kernel/resources/proxies.py b/src/kernel/resources/proxies.py new file mode 100644 index 00000000..08f97e37 --- /dev/null +++ b/src/kernel/resources/proxies.py @@ -0,0 +1,726 @@ +# 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 proxy_list_params, proxy_check_params, proxy_create_params, proxy_update_params +from .._types import Body, Omit, Query, Headers, NoneType, NotGiven, SequenceNotStr, omit, not_given +from .._utils import path_template, 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 ..pagination import SyncOffsetPagination, AsyncOffsetPagination +from .._base_client import AsyncPaginator, make_request_options +from ..types.proxy_list_response import ProxyListResponse +from ..types.proxy_check_response import ProxyCheckResponse +from ..types.proxy_create_response import ProxyCreateResponse +from ..types.proxy_update_response import ProxyUpdateResponse +from ..types.proxy_retrieve_response import ProxyRetrieveResponse + +__all__ = ["ProxiesResource", "AsyncProxiesResource"] + + +class ProxiesResource(SyncAPIResource): + """Create and manage proxy configurations for routing browser traffic.""" + + @cached_property + def with_raw_response(self) -> ProxiesResourceWithRawResponse: + """ + 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/kernel/kernel-python-sdk#accessing-raw-response-data-eg-headers + """ + return ProxiesResourceWithRawResponse(self) + + @cached_property + def with_streaming_response(self) -> ProxiesResourceWithStreamingResponse: + """ + An alternative to `.with_raw_response` that doesn't eagerly read the response body. + + For more information, see https://www.github.com/kernel/kernel-python-sdk#with_streaming_response + """ + return ProxiesResourceWithStreamingResponse(self) + + def create( + self, + *, + type: Literal["datacenter", "isp", "residential", "mobile", "custom"], + bypass_hosts: SequenceNotStr[str] | Omit = omit, + config: proxy_create_params.Config | Omit = omit, + name: str | Omit = omit, + protocol: Literal["http", "https"] | 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, + ) -> ProxyCreateResponse: + """ + Create a new proxy configuration in the resolved project. + + Args: + type: Proxy type to use. In terms of quality for avoiding bot-detection, from best to + worst: `mobile` > `residential` > `isp` > `datacenter`. + + bypass_hosts: Hostnames that should bypass the parent proxy and connect directly. + + config: Configuration specific to the selected proxy `type`. + + name: Readable name of the proxy. + + protocol: Protocol to use for the proxy connection. + + 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( + "/proxies", + body=maybe_transform( + { + "type": type, + "bypass_hosts": bypass_hosts, + "config": config, + "name": name, + "protocol": protocol, + }, + proxy_create_params.ProxyCreateParams, + ), + options=make_request_options( + extra_headers=extra_headers, extra_query=extra_query, extra_body=extra_body, timeout=timeout + ), + cast_to=ProxyCreateResponse, + ) + + def retrieve( + 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, + ) -> ProxyRetrieveResponse: + """ + Retrieve a proxy in the resolved project by ID. + + 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( + path_template("/proxies/{id}", id=id), + options=make_request_options( + extra_headers=extra_headers, extra_query=extra_query, extra_body=extra_body, timeout=timeout + ), + cast_to=ProxyRetrieveResponse, + ) + + def update( + self, + id: str, + *, + name: 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, + ) -> ProxyUpdateResponse: + """Update a proxy's name. + + Proxy names are not unique and are not ID-or-name + addressable on this endpoint; duplicate names are allowed. Name-based + session-create lookups can remain ambiguous until callers resolve proxies by ID + or the API adds a stronger uniqueness contract. + + Args: + name: New proxy name. Proxy names are trimmed and length-checked only; duplicates are + allowed because proxies are updated by ID, not by name. + + 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( + path_template("/proxies/{id}", id=id), + body=maybe_transform({"name": name}, proxy_update_params.ProxyUpdateParams), + options=make_request_options( + extra_headers=extra_headers, extra_query=extra_query, extra_body=extra_body, timeout=timeout + ), + cast_to=ProxyUpdateResponse, + ) + + def list( + self, + *, + limit: int | Omit = omit, + name: str | Omit = omit, + offset: int | Omit = omit, + query: 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, + ) -> SyncOffsetPagination[ProxyListResponse]: + """ + List proxies in the resolved project. + + Args: + limit: Limit the number of proxies to return. + + name: Exact-match filter on proxy name using the database collation. In production, + matching is case- and accent-insensitive. Names are not required to be unique, + so multiple proxies may match. + + offset: Offset the number of proxies to return. + + query: Case-insensitive substring match against proxy name, host, or IP address. IDs + match by exact value. + + 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_api_list( + "/proxies", + page=SyncOffsetPagination[ProxyListResponse], + options=make_request_options( + extra_headers=extra_headers, + extra_query=extra_query, + extra_body=extra_body, + timeout=timeout, + query=maybe_transform( + { + "limit": limit, + "name": name, + "offset": offset, + "query": query, + }, + proxy_list_params.ProxyListParams, + ), + ), + model=ProxyListResponse, + ) + + 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, + ) -> None: + """Soft delete a proxy. + + Sessions referencing it are not modified. + + 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}") + extra_headers = {"Accept": "*/*", **(extra_headers or {})} + return self._delete( + path_template("/proxies/{id}", id=id), + options=make_request_options( + extra_headers=extra_headers, extra_query=extra_query, extra_body=extra_body, timeout=timeout + ), + cast_to=NoneType, + ) + + def check( + self, + id: str, + *, + url: 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, + ) -> ProxyCheckResponse: + """Run a health check on the proxy to verify it's working. + + Optionally specify a URL + to test reachability against a specific target. For ISP and datacenter proxies, + this reliably tests whether the target site is reachable from the proxy's stable + exit IP. For residential and mobile proxies, the exit node varies between + requests, so this validates proxy configuration and connectivity rather than + guaranteeing site-specific reachability. + + Args: + url: An optional URL to test reachability against. If provided, the proxy check will + test connectivity to this URL instead of the default test URLs. Only HTTP and + HTTPS schemes are allowed, and the URL must resolve to a public IP address. For + ISP and datacenter proxies, the exit IP is stable, so a successful check + reliably indicates that subsequent browser sessions will reach the target site + with the same IP. For residential and mobile proxies, the exit node changes + between requests, so a successful check validates proxy configuration but does + not guarantee that a subsequent browser session will use the same exit IP or + reach the same site — it is useful for verifying credentials and connectivity, + not for predicting site-specific behavior. When provided, the check result does + not update the proxy's health status, since a failure may indicate a problem + with the target site rather than the proxy itself. + + 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( + path_template("/proxies/{id}/check", id=id), + body=maybe_transform({"url": url}, proxy_check_params.ProxyCheckParams), + options=make_request_options( + extra_headers=extra_headers, extra_query=extra_query, extra_body=extra_body, timeout=timeout + ), + cast_to=ProxyCheckResponse, + ) + + +class AsyncProxiesResource(AsyncAPIResource): + """Create and manage proxy configurations for routing browser traffic.""" + + @cached_property + def with_raw_response(self) -> AsyncProxiesResourceWithRawResponse: + """ + 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/kernel/kernel-python-sdk#accessing-raw-response-data-eg-headers + """ + return AsyncProxiesResourceWithRawResponse(self) + + @cached_property + def with_streaming_response(self) -> AsyncProxiesResourceWithStreamingResponse: + """ + An alternative to `.with_raw_response` that doesn't eagerly read the response body. + + For more information, see https://www.github.com/kernel/kernel-python-sdk#with_streaming_response + """ + return AsyncProxiesResourceWithStreamingResponse(self) + + async def create( + self, + *, + type: Literal["datacenter", "isp", "residential", "mobile", "custom"], + bypass_hosts: SequenceNotStr[str] | Omit = omit, + config: proxy_create_params.Config | Omit = omit, + name: str | Omit = omit, + protocol: Literal["http", "https"] | 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, + ) -> ProxyCreateResponse: + """ + Create a new proxy configuration in the resolved project. + + Args: + type: Proxy type to use. In terms of quality for avoiding bot-detection, from best to + worst: `mobile` > `residential` > `isp` > `datacenter`. + + bypass_hosts: Hostnames that should bypass the parent proxy and connect directly. + + config: Configuration specific to the selected proxy `type`. + + name: Readable name of the proxy. + + protocol: Protocol to use for the proxy connection. + + 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( + "/proxies", + body=await async_maybe_transform( + { + "type": type, + "bypass_hosts": bypass_hosts, + "config": config, + "name": name, + "protocol": protocol, + }, + proxy_create_params.ProxyCreateParams, + ), + options=make_request_options( + extra_headers=extra_headers, extra_query=extra_query, extra_body=extra_body, timeout=timeout + ), + cast_to=ProxyCreateResponse, + ) + + async def retrieve( + 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, + ) -> ProxyRetrieveResponse: + """ + Retrieve a proxy in the resolved project by ID. + + 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( + path_template("/proxies/{id}", id=id), + options=make_request_options( + extra_headers=extra_headers, extra_query=extra_query, extra_body=extra_body, timeout=timeout + ), + cast_to=ProxyRetrieveResponse, + ) + + async def update( + self, + id: str, + *, + name: 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, + ) -> ProxyUpdateResponse: + """Update a proxy's name. + + Proxy names are not unique and are not ID-or-name + addressable on this endpoint; duplicate names are allowed. Name-based + session-create lookups can remain ambiguous until callers resolve proxies by ID + or the API adds a stronger uniqueness contract. + + Args: + name: New proxy name. Proxy names are trimmed and length-checked only; duplicates are + allowed because proxies are updated by ID, not by name. + + 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( + path_template("/proxies/{id}", id=id), + body=await async_maybe_transform({"name": name}, proxy_update_params.ProxyUpdateParams), + options=make_request_options( + extra_headers=extra_headers, extra_query=extra_query, extra_body=extra_body, timeout=timeout + ), + cast_to=ProxyUpdateResponse, + ) + + def list( + self, + *, + limit: int | Omit = omit, + name: str | Omit = omit, + offset: int | Omit = omit, + query: 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, + ) -> AsyncPaginator[ProxyListResponse, AsyncOffsetPagination[ProxyListResponse]]: + """ + List proxies in the resolved project. + + Args: + limit: Limit the number of proxies to return. + + name: Exact-match filter on proxy name using the database collation. In production, + matching is case- and accent-insensitive. Names are not required to be unique, + so multiple proxies may match. + + offset: Offset the number of proxies to return. + + query: Case-insensitive substring match against proxy name, host, or IP address. IDs + match by exact value. + + 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_api_list( + "/proxies", + page=AsyncOffsetPagination[ProxyListResponse], + options=make_request_options( + extra_headers=extra_headers, + extra_query=extra_query, + extra_body=extra_body, + timeout=timeout, + query=maybe_transform( + { + "limit": limit, + "name": name, + "offset": offset, + "query": query, + }, + proxy_list_params.ProxyListParams, + ), + ), + model=ProxyListResponse, + ) + + 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, + ) -> None: + """Soft delete a proxy. + + Sessions referencing it are not modified. + + 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}") + extra_headers = {"Accept": "*/*", **(extra_headers or {})} + return await self._delete( + path_template("/proxies/{id}", id=id), + options=make_request_options( + extra_headers=extra_headers, extra_query=extra_query, extra_body=extra_body, timeout=timeout + ), + cast_to=NoneType, + ) + + async def check( + self, + id: str, + *, + url: 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, + ) -> ProxyCheckResponse: + """Run a health check on the proxy to verify it's working. + + Optionally specify a URL + to test reachability against a specific target. For ISP and datacenter proxies, + this reliably tests whether the target site is reachable from the proxy's stable + exit IP. For residential and mobile proxies, the exit node varies between + requests, so this validates proxy configuration and connectivity rather than + guaranteeing site-specific reachability. + + Args: + url: An optional URL to test reachability against. If provided, the proxy check will + test connectivity to this URL instead of the default test URLs. Only HTTP and + HTTPS schemes are allowed, and the URL must resolve to a public IP address. For + ISP and datacenter proxies, the exit IP is stable, so a successful check + reliably indicates that subsequent browser sessions will reach the target site + with the same IP. For residential and mobile proxies, the exit node changes + between requests, so a successful check validates proxy configuration but does + not guarantee that a subsequent browser session will use the same exit IP or + reach the same site — it is useful for verifying credentials and connectivity, + not for predicting site-specific behavior. When provided, the check result does + not update the proxy's health status, since a failure may indicate a problem + with the target site rather than the proxy itself. + + 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( + path_template("/proxies/{id}/check", id=id), + body=await async_maybe_transform({"url": url}, proxy_check_params.ProxyCheckParams), + options=make_request_options( + extra_headers=extra_headers, extra_query=extra_query, extra_body=extra_body, timeout=timeout + ), + cast_to=ProxyCheckResponse, + ) + + +class ProxiesResourceWithRawResponse: + def __init__(self, proxies: ProxiesResource) -> None: + self._proxies = proxies + + self.create = to_raw_response_wrapper( + proxies.create, + ) + self.retrieve = to_raw_response_wrapper( + proxies.retrieve, + ) + self.update = to_raw_response_wrapper( + proxies.update, + ) + self.list = to_raw_response_wrapper( + proxies.list, + ) + self.delete = to_raw_response_wrapper( + proxies.delete, + ) + self.check = to_raw_response_wrapper( + proxies.check, + ) + + +class AsyncProxiesResourceWithRawResponse: + def __init__(self, proxies: AsyncProxiesResource) -> None: + self._proxies = proxies + + self.create = async_to_raw_response_wrapper( + proxies.create, + ) + self.retrieve = async_to_raw_response_wrapper( + proxies.retrieve, + ) + self.update = async_to_raw_response_wrapper( + proxies.update, + ) + self.list = async_to_raw_response_wrapper( + proxies.list, + ) + self.delete = async_to_raw_response_wrapper( + proxies.delete, + ) + self.check = async_to_raw_response_wrapper( + proxies.check, + ) + + +class ProxiesResourceWithStreamingResponse: + def __init__(self, proxies: ProxiesResource) -> None: + self._proxies = proxies + + self.create = to_streamed_response_wrapper( + proxies.create, + ) + self.retrieve = to_streamed_response_wrapper( + proxies.retrieve, + ) + self.update = to_streamed_response_wrapper( + proxies.update, + ) + self.list = to_streamed_response_wrapper( + proxies.list, + ) + self.delete = to_streamed_response_wrapper( + proxies.delete, + ) + self.check = to_streamed_response_wrapper( + proxies.check, + ) + + +class AsyncProxiesResourceWithStreamingResponse: + def __init__(self, proxies: AsyncProxiesResource) -> None: + self._proxies = proxies + + self.create = async_to_streamed_response_wrapper( + proxies.create, + ) + self.retrieve = async_to_streamed_response_wrapper( + proxies.retrieve, + ) + self.update = async_to_streamed_response_wrapper( + proxies.update, + ) + self.list = async_to_streamed_response_wrapper( + proxies.list, + ) + self.delete = async_to_streamed_response_wrapper( + proxies.delete, + ) + self.check = async_to_streamed_response_wrapper( + proxies.check, + ) diff --git a/src/kernel/types/__init__.py b/src/kernel/types/__init__.py new file mode 100644 index 00000000..ab58edbc --- /dev/null +++ b/src/kernel/types/__init__.py @@ -0,0 +1,128 @@ +# File generated from our OpenAPI spec by Stainless. See CONTRIBUTING.md for details. + +from __future__ import annotations + +from . import browsers +from .. import _compat +from .tags import Tags as Tags +from .shared import ( + LogEvent as LogEvent, + AppAction as AppAction, + ErrorEvent as ErrorEvent, + ErrorModel as ErrorModel, + ErrorDetail as ErrorDetail, + BrowserProfile as BrowserProfile, + HeartbeatEvent as HeartbeatEvent, + BrowserViewport as BrowserViewport, + BrowserExtension as BrowserExtension, +) +from .api_key import APIKey as APIKey +from .profile import Profile as Profile +from .project import Project as Project +from .credential import Credential as Credential +from .tags_param import TagsParam as TagsParam +from .browser_pool import BrowserPool as BrowserPool +from .browser_usage import BrowserUsage as BrowserUsage +from .app_list_params import AppListParams as AppListParams +from .audit_log_entry import AuditLogEntry as AuditLogEntry +from .created_api_key import CreatedAPIKey as CreatedAPIKey +from .browser_pool_ref import BrowserPoolRef as BrowserPoolRef +from .app_list_response import AppListResponse as AppListResponse +from .proxy_list_params import ProxyListParams as ProxyListParams +from .proxy_check_params import ProxyCheckParams as ProxyCheckParams +from .api_key_list_params import APIKeyListParams as APIKeyListParams +from .browser_curl_params import BrowserCurlParams as BrowserCurlParams +from .browser_list_params import BrowserListParams as BrowserListParams +from .credential_provider import CredentialProvider as CredentialProvider +from .profile_list_params import ProfileListParams as ProfileListParams +from .project_list_params import ProjectListParams as ProjectListParams +from .proxy_create_params import ProxyCreateParams as ProxyCreateParams +from .proxy_list_response import ProxyListResponse as ProxyListResponse +from .proxy_update_params import ProxyUpdateParams as ProxyUpdateParams +from .proxy_check_response import ProxyCheckResponse as ProxyCheckResponse +from .api_key_create_params import APIKeyCreateParams as APIKeyCreateParams +from .api_key_rotate_params import APIKeyRotateParams as APIKeyRotateParams +from .api_key_update_params import APIKeyUpdateParams as APIKeyUpdateParams +from .audit_log_list_params import AuditLogListParams as AuditLogListParams +from .browser_create_params import BrowserCreateParams as BrowserCreateParams +from .browser_curl_response import BrowserCurlResponse as BrowserCurlResponse +from .browser_list_response import BrowserListResponse as BrowserListResponse +from .browser_update_params import BrowserUpdateParams as BrowserUpdateParams +from .extension_list_params import ExtensionListParams as ExtensionListParams +from .profile_create_params import ProfileCreateParams as ProfileCreateParams +from .profile_update_params import ProfileUpdateParams as ProfileUpdateParams +from .project_create_params import ProjectCreateParams as ProjectCreateParams +from .project_update_params import ProjectUpdateParams as ProjectUpdateParams +from .proxy_create_response import ProxyCreateResponse as ProxyCreateResponse +from .proxy_update_response import ProxyUpdateResponse as ProxyUpdateResponse +from .credential_list_params import CredentialListParams as CredentialListParams +from .deployment_list_params import DeploymentListParams as DeploymentListParams +from .deployment_state_event import DeploymentStateEvent as DeploymentStateEvent +from .extension_get_response import ExtensionGetResponse as ExtensionGetResponse +from .invocation_list_params import InvocationListParams as InvocationListParams +from .invocation_state_event import InvocationStateEvent as InvocationStateEvent +from .api_key_retrieve_params import APIKeyRetrieveParams as APIKeyRetrieveParams +from .browser_create_response import BrowserCreateResponse as BrowserCreateResponse +from .browser_retrieve_params import BrowserRetrieveParams as BrowserRetrieveParams +from .browser_update_response import BrowserUpdateResponse as BrowserUpdateResponse +from .extension_list_response import ExtensionListResponse as ExtensionListResponse +from .extension_upload_params import ExtensionUploadParams as ExtensionUploadParams +from .profile_download_params import ProfileDownloadParams as ProfileDownloadParams +from .proxy_retrieve_response import ProxyRetrieveResponse as ProxyRetrieveResponse +from .browser_pool_list_params import BrowserPoolListParams as BrowserPoolListParams +from .credential_create_params import CredentialCreateParams as CredentialCreateParams +from .credential_provider_item import CredentialProviderItem as CredentialProviderItem +from .credential_update_params import CredentialUpdateParams as CredentialUpdateParams +from .deployment_create_params import DeploymentCreateParams as DeploymentCreateParams +from .deployment_follow_params import DeploymentFollowParams as DeploymentFollowParams +from .deployment_list_response import DeploymentListResponse as DeploymentListResponse +from .invocation_create_params import InvocationCreateParams as InvocationCreateParams +from .invocation_follow_params import InvocationFollowParams as InvocationFollowParams +from .invocation_list_response import InvocationListResponse as InvocationListResponse +from .invocation_update_params import InvocationUpdateParams as InvocationUpdateParams +from .browser_retrieve_response import BrowserRetrieveResponse as BrowserRetrieveResponse +from .extension_upload_response import ExtensionUploadResponse as ExtensionUploadResponse +from .browser_pool_create_params import BrowserPoolCreateParams as BrowserPoolCreateParams +from .browser_pool_delete_params import BrowserPoolDeleteParams as BrowserPoolDeleteParams +from .browser_pool_update_params import BrowserPoolUpdateParams as BrowserPoolUpdateParams +from .deployment_create_response import DeploymentCreateResponse as DeploymentCreateResponse +from .deployment_follow_response import DeploymentFollowResponse as DeploymentFollowResponse +from .invocation_create_response import InvocationCreateResponse as InvocationCreateResponse +from .invocation_follow_response import InvocationFollowResponse as InvocationFollowResponse +from .invocation_update_response import InvocationUpdateResponse as InvocationUpdateResponse +from .browser_pool_acquire_params import BrowserPoolAcquireParams as BrowserPoolAcquireParams +from .browser_pool_release_params import BrowserPoolReleaseParams as BrowserPoolReleaseParams +from .deployment_retrieve_response import DeploymentRetrieveResponse as DeploymentRetrieveResponse +from .invocation_retrieve_response import InvocationRetrieveResponse as InvocationRetrieveResponse +from .audit_log_export_chunk_params import AuditLogExportChunkParams as AuditLogExportChunkParams +from .browser_pool_acquire_response import BrowserPoolAcquireResponse as BrowserPoolAcquireResponse +from .credential_totp_code_response import CredentialTotpCodeResponse as CredentialTotpCodeResponse +from .browser_load_extensions_params import BrowserLoadExtensionsParams as BrowserLoadExtensionsParams +from .credential_provider_list_params import CredentialProviderListParams as CredentialProviderListParams +from .credential_provider_test_result import CredentialProviderTestResult as CredentialProviderTestResult +from .credential_provider_create_params import CredentialProviderCreateParams as CredentialProviderCreateParams +from .credential_provider_update_params import CredentialProviderUpdateParams as CredentialProviderUpdateParams +from .invocation_list_browsers_response import InvocationListBrowsersResponse as InvocationListBrowsersResponse +from .credential_provider_list_items_response import ( + CredentialProviderListItemsResponse as CredentialProviderListItemsResponse, +) +from .extension_download_from_chrome_store_params import ( + ExtensionDownloadFromChromeStoreParams as ExtensionDownloadFromChromeStoreParams, +) + +# Rebuild cyclical models only after all modules are imported. +# This ensures that, when building the deferred (due to cyclical references) model schema, +# Pydantic can resolve the necessary references. +# See: https://github.com/pydantic/pydantic/issues/11250 for more context. +if _compat.PYDANTIC_V1: + browsers.browser_call_stack.BrowserCallStack.update_forward_refs() # type: ignore + browsers.browser_console_error_event.BrowserConsoleErrorEvent.update_forward_refs() # type: ignore + browsers.browser_console_log_event.BrowserConsoleLogEvent.update_forward_refs() # type: ignore + browsers.telemetry_events_response.TelemetryEventsResponse.update_forward_refs() # type: ignore + browsers.telemetry_stream_response.TelemetryStreamResponse.update_forward_refs() # type: ignore +else: + browsers.browser_call_stack.BrowserCallStack.model_rebuild(_parent_namespace_depth=0) + browsers.browser_console_error_event.BrowserConsoleErrorEvent.model_rebuild(_parent_namespace_depth=0) + browsers.browser_console_log_event.BrowserConsoleLogEvent.model_rebuild(_parent_namespace_depth=0) + browsers.telemetry_events_response.TelemetryEventsResponse.model_rebuild(_parent_namespace_depth=0) + browsers.telemetry_stream_response.TelemetryStreamResponse.model_rebuild(_parent_namespace_depth=0) diff --git a/src/kernel/types/api_key.py b/src/kernel/types/api_key.py new file mode 100644 index 00000000..b0d6eef2 --- /dev/null +++ b/src/kernel/types/api_key.py @@ -0,0 +1,57 @@ +# File generated from our OpenAPI spec by Stainless. See CONTRIBUTING.md for details. + +from typing import Optional +from datetime import datetime + +from .._models import BaseModel + +__all__ = ["APIKey", "CreatedBy"] + + +class CreatedBy(BaseModel): + id: str + """Kernel user ID of the creator.""" + + email: str + """Email address of the creator.""" + + name: Optional[str] = None + """Display name of the creator, if available.""" + + +class APIKey(BaseModel): + id: str + """Unique API key identifier""" + + created_at: datetime + """When the API key was created""" + + created_by: CreatedBy + + deleted_at: Optional[datetime] = None + """When the API key was deleted (soft-deleted). + + Null for keys that have not been deleted. + """ + + expires_at: Optional[datetime] = None + """When the API key expires""" + + masked_key: str + """Masked version of the API key""" + + name: str + """Label for the API key. + + API keys are not addressable by name; use the ID or key identifier for stable + references. + """ + + project_id: Optional[str] = None + """Project identifier for project-scoped API keys. Null means org-wide.""" + + project_name: Optional[str] = None + """Project name for project-scoped API keys. + + Null means the key is org-wide or the project name is unavailable. + """ diff --git a/src/kernel/types/api_key_create_params.py b/src/kernel/types/api_key_create_params.py new file mode 100644 index 00000000..2909510e --- /dev/null +++ b/src/kernel/types/api_key_create_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 import Optional +from typing_extensions import Required, TypedDict + +__all__ = ["APIKeyCreateParams"] + + +class APIKeyCreateParams(TypedDict, total=False): + name: Required[str] + """Label for the API key (1-255 characters). API keys are not addressable by name.""" + + days_to_expire: Optional[int] + """Number of days until expiry, up to 3650. Use null for never.""" + + project_id: Optional[str] + """Unique project identifier""" diff --git a/src/kernel/types/api_key_list_params.py b/src/kernel/types/api_key_list_params.py new file mode 100644 index 00000000..95b8af5f --- /dev/null +++ b/src/kernel/types/api_key_list_params.py @@ -0,0 +1,51 @@ +# File generated from our OpenAPI spec by Stainless. See CONTRIBUTING.md for details. + +from __future__ import annotations + +from typing_extensions import Literal, TypedDict + +__all__ = ["APIKeyListParams"] + + +class APIKeyListParams(TypedDict, total=False): + include_deleted: bool + """Deprecated: use status=all instead. + + When true, include deleted (soft-deleted) API keys in the results for audit + purposes. + """ + + limit: int + """Maximum number of results to return""" + + name: str + """Exact-match filter on API key name using the database collation. + + In production, matching is case- and accent-insensitive. Names are not required + to be unique, so multiple keys may match. When status=all or + include_deleted=true is set, soft-deleted keys with the same name may also + match. + """ + + offset: int + """Number of results to skip""" + + query: str + """Case-insensitive substring match against API key name, creator, and project. + + API key identifiers and masked keys match by exact value or prefix. + """ + + sort_by: Literal["created_at", "name", "expires_at"] + """Field to sort API keys by.""" + + sort_direction: Literal["asc", "desc"] + """Sort direction for API keys.""" + + status: Literal["active", "deleted", "all"] + """Filter API keys by status. + + "active" returns keys that are not deleted (default; expired-but-not-deleted + keys are still included), "deleted" returns only soft-deleted keys, "all" + returns both. + """ diff --git a/src/kernel/types/api_key_retrieve_params.py b/src/kernel/types/api_key_retrieve_params.py new file mode 100644 index 00000000..e2b9ea3b --- /dev/null +++ b/src/kernel/types/api_key_retrieve_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 TypedDict + +__all__ = ["APIKeyRetrieveParams"] + + +class APIKeyRetrieveParams(TypedDict, total=False): + include_deleted: bool + """ + When true, return the API key even if it has been deleted (soft-deleted), for + audit purposes. Defaults to false, which returns 404 for a deleted key. + """ diff --git a/src/kernel/types/api_key_rotate_params.py b/src/kernel/types/api_key_rotate_params.py new file mode 100644 index 00000000..3810e589 --- /dev/null +++ b/src/kernel/types/api_key_rotate_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 import Optional +from typing_extensions import TypedDict + +__all__ = ["APIKeyRotateParams"] + + +class APIKeyRotateParams(TypedDict, total=False): + days_to_expire: Optional[int] + """Lifetime in days for the new key, up to 3650. + + Omit to reuse the rotated key's original lifetime, or never-expires if it had + none. + """ + + expire_in_days: Optional[int] + """Grace period in days before the rotated key expires. + + Use 0 to expire it immediately. Omit for the default grace period of 7 days. + """ diff --git a/src/kernel/types/api_key_update_params.py b/src/kernel/types/api_key_update_params.py new file mode 100644 index 00000000..2e917607 --- /dev/null +++ b/src/kernel/types/api_key_update_params.py @@ -0,0 +1,12 @@ +# File generated from our OpenAPI spec by Stainless. See CONTRIBUTING.md for details. + +from __future__ import annotations + +from typing_extensions import Required, TypedDict + +__all__ = ["APIKeyUpdateParams"] + + +class APIKeyUpdateParams(TypedDict, total=False): + name: Required[str] + """New API key name""" diff --git a/src/kernel/types/app_list_params.py b/src/kernel/types/app_list_params.py new file mode 100644 index 00000000..a4a9db48 --- /dev/null +++ b/src/kernel/types/app_list_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_extensions import TypedDict + +__all__ = ["AppListParams"] + + +class AppListParams(TypedDict, total=False): + app_name: str + """Filter results by application name.""" + + limit: int + """Limit the number of apps to return.""" + + offset: int + """Offset the number of apps to return.""" + + query: str + """Search apps by name.""" + + version: str + """Filter results by version label.""" diff --git a/src/kernel/types/app_list_response.py b/src/kernel/types/app_list_response.py new file mode 100644 index 00000000..91ad648e --- /dev/null +++ b/src/kernel/types/app_list_response.py @@ -0,0 +1,39 @@ +# File generated from our OpenAPI spec by Stainless. See CONTRIBUTING.md for details. + +from typing import Dict, List +from typing_extensions import Literal + +from .._models import BaseModel +from .shared.app_action import AppAction + +__all__ = ["AppListResponse"] + + +class AppListResponse(BaseModel): + """Summary of an application version.""" + + id: str + """Unique identifier for the app version""" + + actions: List[AppAction] + """List of actions available on the app""" + + app_name: str + """Name of the application""" + + deployment: str + """Deployment ID""" + + env_vars: Dict[str, str] + """Environment variables configured for this app version. + + Values are redacted for API key, OAuth, and managed-auth callers, which receive + every key with an empty string value. Only dashboard sessions receive the actual + values. + """ + + region: Literal["aws.us-east-1a"] + """Deployment region code""" + + version: str + """Version label for the application""" diff --git a/src/kernel/types/audit_log_entry.py b/src/kernel/types/audit_log_entry.py new file mode 100644 index 00000000..dd998098 --- /dev/null +++ b/src/kernel/types/audit_log_entry.py @@ -0,0 +1,45 @@ +# File generated from our OpenAPI spec by Stainless. See CONTRIBUTING.md for details. + +from datetime import datetime + +from .._models import BaseModel + +__all__ = ["AuditLogEntry"] + + +class AuditLogEntry(BaseModel): + auth_strategy: str + """Authentication strategy used for the request.""" + + client_ip: str + """Client IP address.""" + + domain: str + """Request host.""" + + duration_ms: int + """Request duration in milliseconds.""" + + email: str + """Email of the authenticated user at request time, if any.""" + + method: str + """HTTP method.""" + + path: str + """Request path.""" + + route: str + """Matched API route pattern, if available.""" + + status: int + """HTTP response status code.""" + + timestamp: datetime + """UTC time when the request was received.""" + + user_agent: str + """User agent header.""" + + user_id: str + """ID of the authenticated user, if any.""" diff --git a/src/kernel/types/audit_log_export_chunk_params.py b/src/kernel/types/audit_log_export_chunk_params.py new file mode 100644 index 00000000..5cc07b03 --- /dev/null +++ b/src/kernel/types/audit_log_export_chunk_params.py @@ -0,0 +1,47 @@ +# File generated from our OpenAPI spec by Stainless. See CONTRIBUTING.md for details. + +from __future__ import annotations + +from typing import Union +from datetime import datetime +from typing_extensions import Literal, Required, Annotated, TypedDict + +from .._types import SequenceNotStr +from .._utils import PropertyInfo + +__all__ = ["AuditLogExportChunkParams"] + + +class AuditLogExportChunkParams(TypedDict, total=False): + end: Required[Annotated[Union[str, datetime], PropertyInfo(format="iso8601")]] + """Upper bound (exclusive) for the audit record timestamp.""" + + start: Required[Annotated[Union[str, datetime], PropertyInfo(format="iso8601")]] + """Lower bound (inclusive) for the audit record timestamp.""" + + auth_strategy: str + """Filter by authentication strategy.""" + + cursor: str + """Opaque cursor from X-Next-Cursor for the next chunk of older records.""" + + exclude_method: SequenceNotStr[str] + """Filter out results by HTTP method.""" + + format: Literal["jsonl", "jsonl.gz"] + """Encoding for the returned chunk.""" + + limit: int + """Maximum number of records to return in this chunk.""" + + method: str + """Filter by HTTP method.""" + + search: str + """Free-text search over path, user ID, email, client IP, and status.""" + + search_user_id: SequenceNotStr[str] + """Additional user IDs to OR into free-text search.""" + + service: str + """Filter by service name.""" diff --git a/src/kernel/types/audit_log_list_params.py b/src/kernel/types/audit_log_list_params.py new file mode 100644 index 00000000..b3329e70 --- /dev/null +++ b/src/kernel/types/audit_log_list_params.py @@ -0,0 +1,44 @@ +# File generated from our OpenAPI spec by Stainless. See CONTRIBUTING.md for details. + +from __future__ import annotations + +from typing import Union +from datetime import datetime +from typing_extensions import Required, Annotated, TypedDict + +from .._types import SequenceNotStr +from .._utils import PropertyInfo + +__all__ = ["AuditLogListParams"] + + +class AuditLogListParams(TypedDict, total=False): + end: Required[Annotated[Union[str, datetime], PropertyInfo(format="iso8601")]] + """Upper bound (exclusive) for the audit record timestamp.""" + + start: Required[Annotated[Union[str, datetime], PropertyInfo(format="iso8601")]] + """Lower bound (inclusive) for the audit record timestamp.""" + + auth_strategy: str + """Filter by authentication strategy.""" + + exclude_method: SequenceNotStr[str] + """Filter out results by HTTP method.""" + + limit: int + """Maximum number of results to return.""" + + method: str + """Filter by HTTP method.""" + + page_token: str + """Opaque page token from X-Next-Page-Token for the next page of older records.""" + + search: str + """Free-text search over path, user ID, email, client IP, and status.""" + + search_user_id: SequenceNotStr[str] + """Additional user IDs to OR into free-text search.""" + + service: str + """Filter by service name.""" diff --git a/src/kernel/types/auth/__init__.py b/src/kernel/types/auth/__init__.py new file mode 100644 index 00000000..a70816fb --- /dev/null +++ b/src/kernel/types/auth/__init__.py @@ -0,0 +1,15 @@ +# File generated from our OpenAPI spec by Stainless. See CONTRIBUTING.md for details. + +from __future__ import annotations + +from .managed_auth import ManagedAuth as ManagedAuth +from .login_response import LoginResponse as LoginResponse +from .connection_list_params import ConnectionListParams as ConnectionListParams +from .submit_fields_response import SubmitFieldsResponse as SubmitFieldsResponse +from .connection_login_params import ConnectionLoginParams as ConnectionLoginParams +from .connection_create_params import ConnectionCreateParams as ConnectionCreateParams +from .connection_submit_params import ConnectionSubmitParams as ConnectionSubmitParams +from .connection_update_params import ConnectionUpdateParams as ConnectionUpdateParams +from .connection_follow_response import ConnectionFollowResponse as ConnectionFollowResponse +from .connection_timeline_params import ConnectionTimelineParams as ConnectionTimelineParams +from .managed_auth_timeline_event import ManagedAuthTimelineEvent as ManagedAuthTimelineEvent diff --git a/src/kernel/types/auth/connection_create_params.py b/src/kernel/types/auth/connection_create_params.py new file mode 100644 index 00000000..ed94bf37 --- /dev/null +++ b/src/kernel/types/auth/connection_create_params.py @@ -0,0 +1,180 @@ +# File generated from our OpenAPI spec by Stainless. See CONTRIBUTING.md for details. + +from __future__ import annotations + +from typing import Optional +from typing_extensions import Required, TypedDict + +from ..._types import SequenceNotStr +from ..browsers.browser_telemetry_categories_config_param import BrowserTelemetryCategoriesConfigParam + +__all__ = ["ConnectionCreateParams", "BrowserTelemetry", "Credential", "Proxy"] + + +class ConnectionCreateParams(TypedDict, total=False): + domain: Required[str] + """Domain for authentication""" + + profile_name: Required[str] + """Name of the profile to manage authentication for. + + If the profile does not exist, it is created automatically. + """ + + allowed_domains: SequenceNotStr[str] + """Additional domains valid for this auth flow (besides the primary domain). + + Useful when login pages redirect to different domains. + + The following SSO/OAuth provider domains are automatically allowed by default + and do not need to be specified: + + - Google: accounts.google.com + - Microsoft/Azure AD: login.microsoftonline.com, login.live.com + - Okta: _.okta.com, _.oktapreview.com + - Auth0: _.auth0.com, _.us.auth0.com, _.eu.auth0.com, _.au.auth0.com + - Apple: appleid.apple.com + - GitHub: github.com + - Facebook/Meta: www.facebook.com + - LinkedIn: www.linkedin.com + - Amazon Cognito: \\**.amazoncognito.com + - OneLogin: \\**.onelogin.com + - Ping Identity: _.pingone.com, _.pingidentity.com + """ + + auto_reauth: bool + """ + Whether to permit automatic re-authentication when a scheduled health check + detects an expired session. This is an opt-in flag only — it does not check + whether re-auth is actually feasible. Even when true, re-auth only runs when the + system has what it needs to perform it (for example, saved credentials for the + required login fields), and only after a scheduled health check detects an + expired session — so this flag has no effect when `health_checks` is false. When + false, expired sessions are marked as `NEEDS_AUTH` instead of attempting + re-auth. Defaults to true. + """ + + browser_telemetry: Optional[BrowserTelemetry] + """ + Browser telemetry configuration used by this connection's browser sessions by + default. Uses the exact create-browser configuration. Can be overridden + per-login. + """ + + credential: Credential + """Reference to credentials for the auth connection. Use one of: + + - { name } for Kernel credentials + - { provider, path } for external provider item + - { provider, auto: true } for external provider domain lookup + """ + + health_check_interval: int + """Interval in seconds between automatic health checks. + + When set, the system periodically verifies the authentication status and + triggers re-authentication if needed. Maximum is 86400 (24 hours). Default is + 3600 (1 hour). The minimum depends on your plan: Enterprise: 300 (5 minutes), + Startup: 1200 (20 minutes), Hobbyist: 3600 (1 hour). + """ + + health_checks: bool + """Whether to enable periodic health checks. + + When false, the system will not automatically verify authentication status, and + `auto_reauth` has no effect on the automatic flow (since re-auth is only + triggered by a failed scheduled health check). Defaults to true. + """ + + login_url: str + """Optional login page URL to skip discovery""" + + proxy: Proxy + """Proxy selection. + + Provide either id or name. The proxy must be in the same project as the resource + referencing it. When selecting by name, the name must match exactly one active + proxy in the project. Ambiguous names return a 400; use id for stable + references. + """ + + record_session: bool + """Whether to record browser sessions for this connection by default. + + Useful for debugging. Can be overridden per-login. Defaults to false. + """ + + save_credentials: bool + """Whether to save credentials after every successful login. + + Defaults to true. One-time codes (TOTP, SMS, etc.) are not saved. + """ + + +class BrowserTelemetry(TypedDict, total=False): + """ + Browser telemetry configuration used by this connection's browser sessions by default. Uses the exact create-browser configuration. Can be overridden per-login. + """ + + browser: BrowserTelemetryCategoriesConfigParam + """Per-category capture flags. + + The operational categories (control, connection, system, captcha) are captured + whenever telemetry is enabled; set one to enabled=false to opt out. The CDP + categories (console, network, page, interaction) and screenshot are off by + default; set enabled=true to opt in. On create, provided categories layer onto + the default set. On update, provided categories merge onto the session's current + config; when no telemetry is active this falls back to the default set (matching + create). If browser is omitted or empty, the default set is used. A browser + config that disables every category stops capture on update and starts no + capture on create. + """ + + enabled: bool + """Request shortcut for browser telemetry capture. + + True enables capture; with no browser category settings it captures the default + set (control, connection, system, captcha), and any browser category settings + are layered onto that default set. On update, enabled=true resolves the config + fresh from the default set plus any provided categories, replacing the session's + current selection rather than merging onto it; omit enabled to merge categories + onto the current selection instead. False stops capture on update and starts no + capture on create. enabled=false cannot be combined with browser category + settings. + """ + + +class Credential(TypedDict, total=False): + """Reference to credentials for the auth connection. + + Use one of: + - { name } for Kernel credentials + - { provider, path } for external provider item + - { provider, auto: true } for external provider domain lookup + """ + + auto: bool + """If true, lookup by domain from the specified provider""" + + name: str + """Kernel credential name""" + + path: str + """Provider-specific path (e.g., "VaultName/ItemName" for 1Password)""" + + provider: str + """External provider name (e.g., "my-1p")""" + + +class Proxy(TypedDict, total=False): + """Proxy selection. + + Provide either id or name. The proxy must be in the same project as the resource referencing it. + When selecting by name, the name must match exactly one active proxy in the project. Ambiguous names return a 400; use id for stable references. + """ + + id: str + """Proxy ID""" + + name: str + """Proxy name""" diff --git a/src/kernel/types/auth/connection_follow_response.py b/src/kernel/types/auth/connection_follow_response.py new file mode 100644 index 00000000..7a0ecd05 --- /dev/null +++ b/src/kernel/types/auth/connection_follow_response.py @@ -0,0 +1,243 @@ +# File generated from our OpenAPI spec by Stainless. See CONTRIBUTING.md for details. + +from typing import List, Union, Optional +from datetime import datetime +from typing_extensions import Literal, Annotated, TypeAlias + +from ..._utils import PropertyInfo +from ..._models import BaseModel +from ..shared.error_event import ErrorEvent +from ..shared.heartbeat_event import HeartbeatEvent + +__all__ = [ + "ConnectionFollowResponse", + "ManagedAuthStateEvent", + "ManagedAuthStateEventChoice", + "ManagedAuthStateEventDiscoveredField", + "ManagedAuthStateEventField", + "ManagedAuthStateEventMfaOption", + "ManagedAuthStateEventPendingSSOButton", + "ManagedAuthStateEventSignInOption", +] + + +class ManagedAuthStateEventChoice(BaseModel): + """Canonical auth-flow choice awaiting user selection.""" + + id: str + """Stable choice identifier for canonical submit.""" + + label: str + """Human-readable choice label.""" + + type: Literal[ + "mfa_method", "sso_provider", "sign_in_method", "auth_method", "identifier_method", "account", "other" + ] + """Choice type.""" + + description: Optional[str] = None + """Additional context for the choice.""" + + observed_selector: Optional[str] = None + """Selector for the visible choice, when available.""" + + +class ManagedAuthStateEventDiscoveredField(BaseModel): + """A discovered form field""" + + label: str + """Field label""" + + name: str + """Field name""" + + selector: str + """CSS selector for the field""" + + type: Literal["text", "email", "password", "tel", "number", "url", "code", "totp"] + """Field type""" + + hint: Optional[str] = None + """ + Contextual help text near the field that tells the user what to enter (e.g., + "Enter the phone ending in (**_) _**-\\**\\**92") + """ + + linked_mfa_type: Optional[Literal["sms", "call", "email", "totp", "push", "password", "switch"]] = None + """ + If this field is associated with an MFA option, the type of that option (e.g., + password field linked to "Enter password" option) + """ + + placeholder: Optional[str] = None + """Field placeholder""" + + required: Optional[bool] = None + """Whether field is required""" + + +class ManagedAuthStateEventField(BaseModel): + """Canonical field awaiting user input.""" + + id: str + """Stable field identifier for canonical submit.""" + + ref: str + """Credential reference name to store the submitted value under.""" + + type: Literal["identifier", "password", "code", "totp_code", "totp_secret", "text"] + """Managed-auth field type.""" + + label: Optional[str] = None + """Human-readable label shown to the user.""" + + observed_selector: Optional[str] = None + """Selector for the visible field, when available.""" + + replace_existing: Optional[bool] = None + """ + Whether the submitted value must replace an existing credential after explicit + rejection. + """ + + required: Optional[bool] = None + """Whether this field is required.""" + + +class ManagedAuthStateEventMfaOption(BaseModel): + """An MFA method option for verification""" + + label: str + """The visible option text""" + + type: Literal["sms", "call", "email", "totp", "push", "password", "switch"] + """The MFA delivery method type. + + Includes 'password' for auth method selection pages and 'switch' for generic + method-switcher links like "Use another method" that do not name a specific + method. + """ + + description: Optional[str] = None + """Additional instructions from the site""" + + target: Optional[str] = None + """The masked destination (phone/email) if shown""" + + +class ManagedAuthStateEventPendingSSOButton(BaseModel): + """An SSO button for signing in with an external identity provider""" + + label: str + """Visible button text""" + + provider: str + """Identity provider name""" + + selector: str + """XPath selector for the button""" + + +class ManagedAuthStateEventSignInOption(BaseModel): + """A non-MFA choice presented during the auth flow (e.g. + + account selection, org picker) + """ + + id: str + """Unique identifier for this option (used to submit selection back)""" + + label: str + """Display text for the option""" + + description: Optional[str] = None + """Additional context such as email address or org name""" + + +class ManagedAuthStateEvent(BaseModel): + """An event representing the current state of a managed auth flow.""" + + event: Literal["managed_auth_state"] + """Event type identifier (always "managed_auth_state").""" + + flow_status: Literal["IN_PROGRESS", "SUCCESS", "FAILED", "EXPIRED", "CANCELED"] + """Current flow status.""" + + flow_step: Literal["DISCOVERING", "AWAITING_INPUT", "AWAITING_EXTERNAL_ACTION", "SUBMITTING", "COMPLETED"] + """Current step in the flow.""" + + timestamp: datetime + """Time the state was reported.""" + + choices: Optional[List[ManagedAuthStateEventChoice]] = None + """Canonical choices awaiting selection. + + Prefer this over pending_sso_buttons, mfa_options, and sign_in_options when + present. + """ + + discovered_fields: Optional[List[ManagedAuthStateEventDiscoveredField]] = None + """ + Fields awaiting input (present when flow_step=AWAITING_INPUT; may also be + present with AWAITING_EXTERNAL_ACTION as fallback actions). + """ + + error_code: Optional[str] = None + """Machine-readable error code (present when flow_status=FAILED).""" + + error_message: Optional[str] = None + """Error message (present when flow_status=FAILED).""" + + external_action_message: Optional[str] = None + """ + Instructions for external action (present when + flow_step=AWAITING_EXTERNAL_ACTION). + """ + + fields: Optional[List[ManagedAuthStateEventField]] = None + """Canonical fields awaiting input. + + Prefer this over discovered_fields when present. + """ + + flow_type: Optional[Literal["LOGIN", "REAUTH"]] = None + """Type of the current flow.""" + + hosted_url: Optional[str] = None + """URL to redirect user to for hosted login.""" + + live_view_url: Optional[str] = None + """Browser live view URL for debugging.""" + + mfa_options: Optional[List[ManagedAuthStateEventMfaOption]] = None + """ + MFA method options (present when flow_step=AWAITING_INPUT; may also be present + with AWAITING_EXTERNAL_ACTION as fallback actions). + """ + + pending_sso_buttons: Optional[List[ManagedAuthStateEventPendingSSOButton]] = None + """ + SSO buttons available (present when flow_step=AWAITING_INPUT; may also be + present with AWAITING_EXTERNAL_ACTION as fallback actions). + """ + + post_login_url: Optional[str] = None + """URL where the browser landed after successful login.""" + + sign_in_options: Optional[List[ManagedAuthStateEventSignInOption]] = None + """ + Non-MFA choices presented during the auth flow, such as account selection or org + pickers (present when flow_step=AWAITING_INPUT; may also be present with + AWAITING_EXTERNAL_ACTION as fallback actions). + """ + + website_error: Optional[str] = None + """Visible error message from the website (e.g., 'Incorrect password'). + + Present when the website displays an error during login. + """ + + +ConnectionFollowResponse: TypeAlias = Annotated[ + Union[ManagedAuthStateEvent, ErrorEvent, HeartbeatEvent], PropertyInfo(discriminator="event") +] diff --git a/src/kernel/types/auth/connection_list_params.py b/src/kernel/types/auth/connection_list_params.py new file mode 100644 index 00000000..dd6ca9f2 --- /dev/null +++ b/src/kernel/types/auth/connection_list_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_extensions import TypedDict + +__all__ = ["ConnectionListParams"] + + +class ConnectionListParams(TypedDict, total=False): + domain: str + """Filter by domain""" + + limit: int + """Maximum number of results to return""" + + offset: int + """Number of results to skip""" + + profile_name: str + """Filter by profile name""" + + query: str + """Search auth connections by ID, domain, or profile name.""" diff --git a/src/kernel/types/auth/connection_login_params.py b/src/kernel/types/auth/connection_login_params.py new file mode 100644 index 00000000..719ea678 --- /dev/null +++ b/src/kernel/types/auth/connection_login_params.py @@ -0,0 +1,83 @@ +# File generated from our OpenAPI spec by Stainless. See CONTRIBUTING.md for details. + +from __future__ import annotations + +from typing import Optional +from typing_extensions import TypedDict + +from ..browsers.browser_telemetry_categories_config_param import BrowserTelemetryCategoriesConfigParam + +__all__ = ["ConnectionLoginParams", "BrowserTelemetry", "Proxy"] + + +class ConnectionLoginParams(TypedDict, total=False): + browser_telemetry: Optional[BrowserTelemetry] + """Override the connection's default browser telemetry configuration for this + login. + + When omitted, the connection's browser_telemetry default is used. Uses the exact + create-browser configuration. + """ + + proxy: Proxy + """Proxy selection. + + Provide either id or name. The proxy must be in the same project as the resource + referencing it. When selecting by name, the name must match exactly one active + proxy in the project. Ambiguous names return a 400; use id for stable + references. + """ + + record_session: bool + """Override the connection's default for recording this login's browser session. + + When omitted, the connection's record_session default is used. + """ + + +class BrowserTelemetry(TypedDict, total=False): + """Override the connection's default browser telemetry configuration for this login. + + When omitted, the connection's browser_telemetry default is used. Uses the exact create-browser configuration. + """ + + browser: BrowserTelemetryCategoriesConfigParam + """Per-category capture flags. + + The operational categories (control, connection, system, captcha) are captured + whenever telemetry is enabled; set one to enabled=false to opt out. The CDP + categories (console, network, page, interaction) and screenshot are off by + default; set enabled=true to opt in. On create, provided categories layer onto + the default set. On update, provided categories merge onto the session's current + config; when no telemetry is active this falls back to the default set (matching + create). If browser is omitted or empty, the default set is used. A browser + config that disables every category stops capture on update and starts no + capture on create. + """ + + enabled: bool + """Request shortcut for browser telemetry capture. + + True enables capture; with no browser category settings it captures the default + set (control, connection, system, captcha), and any browser category settings + are layered onto that default set. On update, enabled=true resolves the config + fresh from the default set plus any provided categories, replacing the session's + current selection rather than merging onto it; omit enabled to merge categories + onto the current selection instead. False stops capture on update and starts no + capture on create. enabled=false cannot be combined with browser category + settings. + """ + + +class Proxy(TypedDict, total=False): + """Proxy selection. + + Provide either id or name. The proxy must be in the same project as the resource referencing it. + When selecting by name, the name must match exactly one active proxy in the project. Ambiguous names return a 400; use id for stable references. + """ + + id: str + """Proxy ID""" + + name: str + """Proxy name""" diff --git a/src/kernel/types/auth/connection_submit_params.py b/src/kernel/types/auth/connection_submit_params.py new file mode 100644 index 00000000..19861ed8 --- /dev/null +++ b/src/kernel/types/auth/connection_submit_params.py @@ -0,0 +1,37 @@ +# 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 TypedDict + +__all__ = ["ConnectionSubmitParams"] + + +class ConnectionSubmitParams(TypedDict, total=False): + field_values: Dict[str, str] + """Canonical map of field ID to submitted value.""" + + fields: Dict[str, str] + """Map of field name to value""" + + mfa_option_id: str + """The MFA method type to select (when mfa_options were returned)""" + + selected_choice_id: str + """Canonical choice ID selected by the user.""" + + sign_in_option_id: str + """The sign-in option ID to select (when sign_in_options were returned)""" + + sso_button_selector: str + """XPath selector for the SSO button to click (ODA). + + Use sso_provider instead for CUA. + """ + + sso_provider: str + """ + SSO provider to click, matching the provider field from pending_sso_buttons + (e.g., "google", "github"). Cannot be used with sso_button_selector. + """ diff --git a/src/kernel/types/auth/connection_timeline_params.py b/src/kernel/types/auth/connection_timeline_params.py new file mode 100644 index 00000000..79c4f5dc --- /dev/null +++ b/src/kernel/types/auth/connection_timeline_params.py @@ -0,0 +1,18 @@ +# File generated from our OpenAPI spec by Stainless. See CONTRIBUTING.md for details. + +from __future__ import annotations + +from typing_extensions import Literal, TypedDict + +__all__ = ["ConnectionTimelineParams"] + + +class ConnectionTimelineParams(TypedDict, total=False): + limit: int + """Maximum number of events to return""" + + offset: int + """Number of events to skip""" + + type: Literal["login", "reauth", "health_check"] + """Filter the timeline to a single event type.""" diff --git a/src/kernel/types/auth/connection_update_params.py b/src/kernel/types/auth/connection_update_params.py new file mode 100644 index 00000000..a14d486a --- /dev/null +++ b/src/kernel/types/auth/connection_update_params.py @@ -0,0 +1,141 @@ +# File generated from our OpenAPI spec by Stainless. See CONTRIBUTING.md for details. + +from __future__ import annotations + +from typing import Optional +from typing_extensions import TypedDict + +from ..._types import SequenceNotStr +from ..browsers.browser_telemetry_categories_config_param import BrowserTelemetryCategoriesConfigParam + +__all__ = ["ConnectionUpdateParams", "BrowserTelemetry", "Credential", "Proxy"] + + +class ConnectionUpdateParams(TypedDict, total=False): + allowed_domains: SequenceNotStr[str] + """Additional domains valid for this auth flow (replaces existing list)""" + + auto_reauth: bool + """Whether automatic re-authentication is permitted for this connection. + + This is an opt-in flag only — it does not check whether re-auth is actually + feasible. Even when true, re-auth only runs when the system has what it needs to + perform it (for example, saved credentials for the required login fields), and + only after a scheduled health check detects an expired session — so this flag + has no effect when `health_checks` is false. When false, expired sessions + detected by a health check are marked as `NEEDS_AUTH` instead of attempting + re-auth. + """ + + browser_telemetry: Optional[BrowserTelemetry] + """ + Browser telemetry configuration used by future browser sessions for this + connection. Uses the exact create-browser configuration. Set enabled to false to + disable telemetry. + """ + + credential: Credential + """Reference to credentials for the auth connection. Use one of: + + - { name } for Kernel credentials + - { provider, path } for external provider item + - { provider, auto: true } for external provider domain lookup + """ + + health_check_interval: int + """Interval in seconds between automatic health checks""" + + health_checks: bool + """Whether periodic health checks are enabled. + + When set to false, the system will not automatically verify authentication + status, and `auto_reauth` has no effect on the automatic flow (since re-auth is + only triggered by a failed scheduled health check). + """ + + login_url: str + """Login page URL. Set to empty string to clear.""" + + proxy: Proxy + """Proxy selection. + + Provide either id or name. The proxy must be in the same project as the resource + referencing it. When selecting by name, the name must match exactly one active + proxy in the project. Ambiguous names return a 400; use id for stable + references. + """ + + record_session: bool + """Whether to record browser sessions for this connection by default""" + + save_credentials: bool + """Whether to save credentials after every successful login""" + + +class BrowserTelemetry(TypedDict, total=False): + """ + Browser telemetry configuration used by future browser sessions for this connection. Uses the exact create-browser configuration. Set enabled to false to disable telemetry. + """ + + browser: BrowserTelemetryCategoriesConfigParam + """Per-category capture flags. + + The operational categories (control, connection, system, captcha) are captured + whenever telemetry is enabled; set one to enabled=false to opt out. The CDP + categories (console, network, page, interaction) and screenshot are off by + default; set enabled=true to opt in. On create, provided categories layer onto + the default set. On update, provided categories merge onto the session's current + config; when no telemetry is active this falls back to the default set (matching + create). If browser is omitted or empty, the default set is used. A browser + config that disables every category stops capture on update and starts no + capture on create. + """ + + enabled: bool + """Request shortcut for browser telemetry capture. + + True enables capture; with no browser category settings it captures the default + set (control, connection, system, captcha), and any browser category settings + are layered onto that default set. On update, enabled=true resolves the config + fresh from the default set plus any provided categories, replacing the session's + current selection rather than merging onto it; omit enabled to merge categories + onto the current selection instead. False stops capture on update and starts no + capture on create. enabled=false cannot be combined with browser category + settings. + """ + + +class Credential(TypedDict, total=False): + """Reference to credentials for the auth connection. + + Use one of: + - { name } for Kernel credentials + - { provider, path } for external provider item + - { provider, auto: true } for external provider domain lookup + """ + + auto: bool + """If true, lookup by domain from the specified provider""" + + name: str + """Kernel credential name""" + + path: str + """Provider-specific path (e.g., "VaultName/ItemName" for 1Password)""" + + provider: str + """External provider name (e.g., "my-1p")""" + + +class Proxy(TypedDict, total=False): + """Proxy selection. + + Provide either id or name. The proxy must be in the same project as the resource referencing it. + When selecting by name, the name must match exactly one active proxy in the project. Ambiguous names return a 400; use id for stable references. + """ + + id: str + """Proxy ID""" + + name: str + """Proxy name""" diff --git a/src/kernel/types/auth/login_response.py b/src/kernel/types/auth/login_response.py new file mode 100644 index 00000000..f5aed291 --- /dev/null +++ b/src/kernel/types/auth/login_response.py @@ -0,0 +1,31 @@ +# File generated from our OpenAPI spec by Stainless. See CONTRIBUTING.md for details. + +from typing import Optional +from datetime import datetime +from typing_extensions import Literal + +from ..._models import BaseModel + +__all__ = ["LoginResponse"] + + +class LoginResponse(BaseModel): + """Response from starting a login flow""" + + id: str + """Auth connection ID""" + + flow_expires_at: datetime + """When the login flow expires""" + + flow_type: Literal["LOGIN", "REAUTH"] + """Type of login flow started""" + + hosted_url: str + """URL to redirect user to for login""" + + handoff_code: Optional[str] = None + """One-time code for handoff (internal use)""" + + live_view_url: Optional[str] = None + """Browser live view URL for watching the login flow""" diff --git a/src/kernel/types/auth/managed_auth.py b/src/kernel/types/auth/managed_auth.py new file mode 100644 index 00000000..44e32270 --- /dev/null +++ b/src/kernel/types/auth/managed_auth.py @@ -0,0 +1,479 @@ +# File generated from our OpenAPI spec by Stainless. See CONTRIBUTING.md for details. + +from typing import List, Optional +from datetime import datetime +from typing_extensions import Literal + +from ..._models import BaseModel +from ..browsers.browser_telemetry_categories_config import BrowserTelemetryCategoriesConfig + +__all__ = [ + "ManagedAuth", + "BrowserTelemetry", + "Choice", + "Credential", + "DiscoveredField", + "Field", + "MfaOption", + "PendingSSOButton", + "SignInOption", +] + + +class BrowserTelemetry(BaseModel): + """ + Browser telemetry configuration used by this connection's browser sessions by default. The exact create-browser configuration is preserved and can be overridden per-login. + """ + + browser: Optional[BrowserTelemetryCategoriesConfig] = None + """Per-category capture flags. + + The operational categories (control, connection, system, captcha) are captured + whenever telemetry is enabled; set one to enabled=false to opt out. The CDP + categories (console, network, page, interaction) and screenshot are off by + default; set enabled=true to opt in. On create, provided categories layer onto + the default set. On update, provided categories merge onto the session's current + config; when no telemetry is active this falls back to the default set (matching + create). If browser is omitted or empty, the default set is used. A browser + config that disables every category stops capture on update and starts no + capture on create. + """ + + enabled: Optional[bool] = None + """Request shortcut for browser telemetry capture. + + True enables capture; with no browser category settings it captures the default + set (control, connection, system, captcha), and any browser category settings + are layered onto that default set. On update, enabled=true resolves the config + fresh from the default set plus any provided categories, replacing the session's + current selection rather than merging onto it; omit enabled to merge categories + onto the current selection instead. False stops capture on update and starts no + capture on create. enabled=false cannot be combined with browser category + settings. + """ + + +class Choice(BaseModel): + """Canonical auth-flow choice awaiting user selection.""" + + id: str + """Stable choice identifier for canonical submit.""" + + label: str + """Human-readable choice label.""" + + type: Literal[ + "mfa_method", "sso_provider", "sign_in_method", "auth_method", "identifier_method", "account", "other" + ] + """Choice type.""" + + description: Optional[str] = None + """Additional context for the choice.""" + + observed_selector: Optional[str] = None + """Selector for the visible choice, when available.""" + + +class Credential(BaseModel): + """Reference to credentials for the auth connection. + + Use one of: + - { name } for Kernel credentials + - { provider, path } for external provider item + - { provider, auto: true } for external provider domain lookup + """ + + auto: Optional[bool] = None + """If true, lookup by domain from the specified provider""" + + name: Optional[str] = None + """Kernel credential name""" + + path: Optional[str] = None + """Provider-specific path (e.g., "VaultName/ItemName" for 1Password)""" + + provider: Optional[str] = None + """External provider name (e.g., "my-1p")""" + + +class DiscoveredField(BaseModel): + """A discovered form field""" + + label: str + """Field label""" + + name: str + """Field name""" + + selector: str + """CSS selector for the field""" + + type: Literal["text", "email", "password", "tel", "number", "url", "code", "totp"] + """Field type""" + + hint: Optional[str] = None + """ + Contextual help text near the field that tells the user what to enter (e.g., + "Enter the phone ending in (**_) _**-\\**\\**92") + """ + + linked_mfa_type: Optional[Literal["sms", "call", "email", "totp", "push", "password", "switch"]] = None + """ + If this field is associated with an MFA option, the type of that option (e.g., + password field linked to "Enter password" option) + """ + + placeholder: Optional[str] = None + """Field placeholder""" + + required: Optional[bool] = None + """Whether field is required""" + + +class Field(BaseModel): + """Canonical field awaiting user input.""" + + id: str + """Stable field identifier for canonical submit.""" + + ref: str + """Credential reference name to store the submitted value under.""" + + type: Literal["identifier", "password", "code", "totp_code", "totp_secret", "text"] + """Managed-auth field type.""" + + label: Optional[str] = None + """Human-readable label shown to the user.""" + + observed_selector: Optional[str] = None + """Selector for the visible field, when available.""" + + replace_existing: Optional[bool] = None + """ + Whether the submitted value must replace an existing credential after explicit + rejection. + """ + + required: Optional[bool] = None + """Whether this field is required.""" + + +class MfaOption(BaseModel): + """An MFA method option for verification""" + + label: str + """The visible option text""" + + type: Literal["sms", "call", "email", "totp", "push", "password", "switch"] + """The MFA delivery method type. + + Includes 'password' for auth method selection pages and 'switch' for generic + method-switcher links like "Use another method" that do not name a specific + method. + """ + + description: Optional[str] = None + """Additional instructions from the site""" + + target: Optional[str] = None + """The masked destination (phone/email) if shown""" + + +class PendingSSOButton(BaseModel): + """An SSO button for signing in with an external identity provider""" + + label: str + """Visible button text""" + + provider: str + """Identity provider name""" + + selector: str + """XPath selector for the button""" + + +class SignInOption(BaseModel): + """A non-MFA choice presented during the auth flow (e.g. + + account selection, org picker) + """ + + id: str + """Unique identifier for this option (used to submit selection back)""" + + label: str + """Display text for the option""" + + description: Optional[str] = None + """Additional context such as email address or org name""" + + +class ManagedAuth(BaseModel): + """Managed authentication that keeps a profile logged into a specific domain. + + Flow fields (flow_status, flow_step, discovered_fields, mfa_options) reflect the most recent login flow and are null when no flow has been initiated. + """ + + id: str + """Unique identifier for the auth connection""" + + domain: str + """Target domain for authentication""" + + profile_name: str + """Name of the profile associated with this auth connection""" + + record_session: bool + """Whether to record browser session replays for this connection by default. + + Useful for debugging login flows. Can be overridden per-login. + """ + + save_credentials: bool + """Whether credentials are saved after every successful login. + + One-time codes (TOTP, SMS, etc.) are not saved. + """ + + status: Literal["AUTHENTICATED", "NEEDS_AUTH"] + """Current authentication status of the managed profile""" + + allowed_domains: Optional[List[str]] = None + """ + Additional domains that are valid for this auth flow (besides the primary + domain). Useful when login pages redirect to different domains. + + The following SSO/OAuth provider domains are automatically allowed by default + and do not need to be specified: + + - Google: accounts.google.com + - Microsoft/Azure AD: login.microsoftonline.com, login.live.com + - Okta: _.okta.com, _.oktapreview.com + - Auth0: _.auth0.com, _.us.auth0.com, _.eu.auth0.com, _.au.auth0.com + - Apple: appleid.apple.com + - GitHub: github.com + - Facebook/Meta: www.facebook.com + - LinkedIn: www.linkedin.com + - Amazon Cognito: \\**.amazoncognito.com + - OneLogin: \\**.onelogin.com + - Ping Identity: _.pingone.com, _.pingidentity.com + """ + + auto_reauth: Optional[bool] = None + """Whether automatic re-authentication is permitted for this connection. + + This is an opt-in flag only — it does not check whether re-auth is actually + feasible. Even when true, re-auth only runs when the system has what it needs to + perform it (for example, saved credentials for the required login fields), and + only after a scheduled health check detects an expired session — so this flag + has no effect when `health_checks` is false. When false, expired sessions + detected by a health check are marked as `NEEDS_AUTH` instead of attempting + re-auth. + """ + + browser_session_id: Optional[str] = None + """ + ID of the underlying browser session driving the current flow (present when flow + in progress). Use this to inspect or terminate the browser session via the + `/browsers` API. + """ + + browser_telemetry: Optional[BrowserTelemetry] = None + """ + Browser telemetry configuration used by this connection's browser sessions by + default. The exact create-browser configuration is preserved and can be + overridden per-login. + """ + + can_reauth: Optional[bool] = None + """ + Whether Kernel can automatically re-authenticate this connection when the + session expires. Requires a prior successful login plus either a Kernel + credential or an external credential reference. See `can_reauth_reason` for the + specific outcome. + """ + + can_reauth_reason: Optional[ + Literal[ + "external_credential", + "cua_has_credential", + "has_credential", + "viable_plans_found", + "no_requirements_recorded", + "requirements_satisfiable", + "no_prior_successful_login", + "no_credential", + "no_viable_plans", + "viable_plans_require_external_action", + "requires_external_action", + "requires_totp_without_secret", + "requires_sms_code", + "requires_email_code", + ] + ] = None + """ + Machine-readable reason for the current value of `can_reauth`. Affirmative + values (re-auth is possible): + + - `external_credential` — an external credential provider is attached + - `cua_has_credential` — CUA flow with a stored credential + - `has_credential` — Kernel credential is attached (optimistic; plan viability + not checked) + - `viable_plans_found` — at least one stored login plan can be replayed + - `no_requirements_recorded` — no recorded credential requirements to fail + against + - `requirements_satisfiable` — recorded requirements can be met by the attached + credential + + Negative values (a human must complete the login flow): + + - `no_prior_successful_login` — connection has never completed a successful + login + - `no_credential` — no Kernel or external credential attached + - `no_viable_plans` — credential attached but no replayable login plan exists + yet + - `viable_plans_require_external_action` — stored plans need an external step + (email link, push, etc.) + - `requires_external_action` — recorded requirements include an external step + - `requires_totp_without_secret` — flow needs a TOTP code but no TOTP secret is + stored + - `requires_sms_code` — flow needs an SMS code that cannot be received + automatically + - `requires_email_code` — flow needs an email code that cannot be received + automatically + """ + + choices: Optional[List[Choice]] = None + """Canonical choices awaiting selection. + + Prefer this over pending_sso_buttons, mfa_options, and sign_in_options when + present. + """ + + credential: Optional[Credential] = None + """Reference to credentials for the auth connection. Use one of: + + - { name } for Kernel credentials + - { provider, path } for external provider item + - { provider, auto: true } for external provider domain lookup + """ + + discovered_fields: Optional[List[DiscoveredField]] = None + """ + Fields awaiting input (present when flow_step=awaiting_input; may also be + present with awaiting_external_action as fallback actions) + """ + + error_code: Optional[str] = None + """Machine-readable error code (present when flow_status=failed)""" + + error_message: Optional[str] = None + """Error message (present when flow_status=failed)""" + + external_action_message: Optional[str] = None + """ + Instructions for external action (present when + flow_step=awaiting_external_action) + """ + + fields: Optional[List[Field]] = None + """Canonical fields awaiting input. + + Prefer this over discovered_fields when present. + """ + + flow_expires_at: Optional[datetime] = None + """When the current flow expires (null when no flow in progress). + + A flow past this timestamp is no longer valid and its `flow_status` will be + `EXPIRED`. Clients may start a new login to supersede a stale `IN_PROGRESS` flow + past this timestamp. + """ + + flow_status: Optional[Literal["IN_PROGRESS", "SUCCESS", "FAILED", "EXPIRED", "CANCELED"]] = None + """Current flow status (null when no flow in progress)""" + + flow_step: Optional[ + Literal["DISCOVERING", "AWAITING_INPUT", "AWAITING_EXTERNAL_ACTION", "SUBMITTING", "COMPLETED"] + ] = None + """Current step in the flow (null when no flow in progress)""" + + flow_type: Optional[Literal["LOGIN", "REAUTH"]] = None + """Type of the current flow (null when no flow in progress)""" + + health_check_interval: Optional[int] = None + """Interval in seconds between automatic health checks. + + When set, the system periodically verifies the authentication status and + triggers re-authentication if needed. Maximum is 86400 (24 hours). Default is + 3600 (1 hour). The minimum depends on your plan: Enterprise: 300 (5 minutes), + Startup: 1200 (20 minutes), Hobbyist: 3600 (1 hour). + """ + + health_checks: Optional[bool] = None + """Whether periodic health checks are enabled for this connection. + + When false, the system will not automatically verify authentication status, and + `auto_reauth` has no effect on the automatic flow (since re-auth is only + triggered by a failed scheduled health check). Manually triggering a health + check via the API still works regardless of this setting. + """ + + hosted_url: Optional[str] = None + """URL to redirect user to for hosted login (present when flow in progress)""" + + last_auth_at: Optional[datetime] = None + """Deprecated alias for `last_auth_check_at`. + + Despite the name, this is the last health-check timestamp, not the last + successful authentication. Use `last_auth_check_at` instead. + """ + + last_auth_check_at: Optional[datetime] = None + """ + When the most recent auth health check ran for this connection, regardless of + outcome. Updated on every health check and does not by itself indicate that the + profile is currently authenticated - use `status` for that. May be newer than + `flow_expires_at` when a flow is still in progress because health checks + continue to run in parallel. + """ + + live_view_url: Optional[str] = None + """Browser live view URL for debugging (present when flow in progress)""" + + login_url: Optional[str] = None + """Optional login page URL to skip discovery""" + + mfa_options: Optional[List[MfaOption]] = None + """ + MFA method options (present when flow_step=awaiting_input; may also be present + with awaiting_external_action as fallback actions) + """ + + pending_sso_buttons: Optional[List[PendingSSOButton]] = None + """ + SSO buttons available (present when flow_step=awaiting_input; may also be + present with awaiting_external_action as fallback actions) + """ + + post_login_url: Optional[str] = None + """URL where the browser landed after successful login""" + + proxy_id: Optional[str] = None + """ID of the proxy associated with this connection, if any.""" + + sign_in_options: Optional[List[SignInOption]] = None + """ + Non-MFA choices presented during the auth flow, such as account selection or org + pickers (present when flow_step=awaiting_input; may also be present with + awaiting_external_action as fallback actions). + """ + + sso_provider: Optional[str] = None + """SSO provider being used (e.g., google, github, microsoft)""" + + website_error: Optional[str] = None + """Visible error message from the website (e.g., 'Incorrect password'). + + Present when the website displays an error during login. + """ diff --git a/src/kernel/types/auth/managed_auth_timeline_event.py b/src/kernel/types/auth/managed_auth_timeline_event.py new file mode 100644 index 00000000..b2e0213b --- /dev/null +++ b/src/kernel/types/auth/managed_auth_timeline_event.py @@ -0,0 +1,83 @@ +# File generated from our OpenAPI spec by Stainless. See CONTRIBUTING.md for details. + +from typing import Optional +from datetime import datetime +from typing_extensions import Literal + +from ..._models import BaseModel + +__all__ = ["ManagedAuthTimelineEvent"] + + +class ManagedAuthTimelineEvent(BaseModel): + """ + A single event in an auth connection's history — a login attempt, an automatic re-auth attempt, or a health check. + """ + + id: str + """Identifier of the underlying login/reauth session or health check.""" + + status: Literal["IN_PROGRESS", "SUCCESS", "EXPIRED", "CANCELED", "FAILED", "AUTHENTICATED", "NEEDS_AUTH"] + """Outcome of the event. + + For login/reauth events this is the flow status (IN_PROGRESS, SUCCESS, EXPIRED, + CANCELED, FAILED). For health_check events it is the observed session state + (AUTHENTICATED, NEEDS_AUTH). + """ + + timestamp: datetime + """When the event occurred.""" + + type: Literal["login", "reauth", "health_check"] + """The kind of event. + + "login" and "reauth" are authentication attempts; "health_check" is a periodic + session-validity check. + """ + + browser_session_id: Optional[str] = None + """Browser session that produced the event, if one was created.""" + + error_code: Optional[str] = None + """Machine-readable error code. Present when a login/reauth event failed.""" + + error_message: Optional[str] = None + """Human-readable error message. Present when a login/reauth event failed.""" + + previous_status: Optional[Literal["AUTHENTICATED", "NEEDS_AUTH"]] = None + """The session state observed before this event. + + Present for health_check events that recorded a prior state. + """ + + replay_id: Optional[str] = None + """ + Replay recording ID for the event's browser session, if session recording was + enabled. + """ + + step: Optional[ + Literal[ + "INITIALIZED", + "DISCOVERING", + "AWAITING_INPUT", + "AWAITING_EXTERNAL_ACTION", + "AWAITING_HUMAN_INTERVENTION", + "SUBMITTING", + "COMPLETED", + "EXPIRED", + ] + ] = None + """The step the flow reached. Present for login/reauth events.""" + + telemetry_captured: Optional[bool] = None + """Whether browser telemetry capture started for this event's browser session.""" + + updated_at: Optional[datetime] = None + """When the event was last updated. Present for login/reauth events.""" + + website_error: Optional[str] = None + """Visible error message from the website (e.g., 'Incorrect password'). + + Present when the website displayed an error during the attempt. + """ diff --git a/src/kernel/types/auth/submit_fields_response.py b/src/kernel/types/auth/submit_fields_response.py new file mode 100644 index 00000000..1133c1b4 --- /dev/null +++ b/src/kernel/types/auth/submit_fields_response.py @@ -0,0 +1,12 @@ +# File generated from our OpenAPI spec by Stainless. See CONTRIBUTING.md for details. + +from ..._models import BaseModel + +__all__ = ["SubmitFieldsResponse"] + + +class SubmitFieldsResponse(BaseModel): + """Response from submitting field values""" + + accepted: bool + """Whether the submission was accepted for processing""" diff --git a/src/kernel/types/browser_create_params.py b/src/kernel/types/browser_create_params.py new file mode 100644 index 00000000..01ac2598 --- /dev/null +++ b/src/kernel/types/browser_create_params.py @@ -0,0 +1,159 @@ +# File generated from our OpenAPI spec by Stainless. See CONTRIBUTING.md for details. + +from __future__ import annotations + +from typing import Dict, Iterable, Optional +from typing_extensions import TypedDict + +from .tags_param import TagsParam +from .shared_params.browser_profile import BrowserProfile +from .shared_params.browser_viewport import BrowserViewport +from .shared_params.browser_extension import BrowserExtension +from .browsers.browser_telemetry_categories_config_param import BrowserTelemetryCategoriesConfigParam + +__all__ = ["BrowserCreateParams", "Telemetry"] + + +class BrowserCreateParams(TypedDict, total=False): + chrome_policy: Dict[str, object] + """Custom Chrome enterprise policy overrides applied to this browser session. + + Keys are Chrome enterprise policy names; values must match their expected types. + Blocked: kernel-managed policies (extensions, proxy, CDP/automation). See + https://chromeenterprise.google/policies/ + """ + + extensions: Iterable[BrowserExtension] + """List of browser extensions to load into the session. + + Provide each by id or name. + """ + + gpu: bool + """If true, enables GPU acceleration for the browser session. + + Requires Start-Up or Enterprise plan and headless=false. + """ + + headless: bool + """If true, launches the browser using a headless image (no VNC/GUI). + + Defaults to false. + """ + + invocation_id: str + """action invocation ID""" + + kiosk_mode: bool + """ + If true, launches the browser in kiosk mode to hide address bar and tabs in live + view. + """ + + name: str + """ + Optional human-readable name for the browser session, used to find it later in + the dashboard. Must be unique among active sessions within the project. Can be + changed later via PATCH /browsers/{id_or_name}. + """ + + profile: BrowserProfile + """Profile selection for the browser session. + + Provide either id or name. If specified, the matching profile will be loaded + into the browser session. Profiles must be created beforehand. + """ + + proxy_id: str + """Optional proxy to associate to the browser session. + + Must reference a proxy in the same project as the browser session. + """ + + start_url: str + """Optional URL to open when the browser session is created. + + Navigation is best-effort, so navigation failures do not prevent the session + from being created. + """ + + stealth: bool + """ + If true, launches the browser in stealth mode to reduce detection by anti-bot + mechanisms. + """ + + tags: TagsParam + """ + Optional user-defined key-value tags for the browser session, used to find and + group sessions later. Can be changed later via PATCH /browsers/{id_or_name}. Up + to 50 pairs. + """ + + telemetry: Optional[Telemetry] + """Telemetry configuration for the browser session. + + Set enabled to true to start capture using VM defaults, or provide browser + category settings. If omitted, null, set to an empty object ({}), set to + enabled: false without browser category settings, or all four categories are + explicitly disabled, capture is not started. + """ + + timeout_seconds: int + """The number of seconds of inactivity before the browser session is terminated. + + Activity includes CDP connections and live view connections. Defaults to 60 + seconds. Minimum allowed is 10 seconds. Maximum allowed is 259200 (72 hours). We + check for inactivity every 5 seconds, so the actual timeout behavior you will + see is +/- 5 seconds around the specified value. + """ + + viewport: BrowserViewport + """ + Initial browser window size in pixels with optional refresh rate. If omitted, + image defaults apply (1920x1080@25). For GPU images, the default is + 1920x1080@60. Arbitrary viewport dimensions and refresh rates are accepted. + Known-good presets include: 2560x1440@10, 1920x1080@25, 1920x1200@25, + 1440x900@25, 1280x800@60, 1024x768@60, 1200x800@60, 768x1024@60, 390x844@60. For + GPU images, recommended presets use one of these resolutions with refresh rates + 60, 30, 25, or 10: 800x600, 960x720, 1024x576, 1024x768, 1152x648, 1200x800, + 1280x720, 1368x768, 1440x900, 1600x900, 1920x1080, 1920x1200, 390x844, 360x250, + 768x1024, 800x1600. Viewports outside this list may exhibit unstable live view + or recording behavior. If refresh_rate is not provided, it will be automatically + determined based on the resolution (higher resolutions use lower refresh rates + to keep bandwidth reasonable). + """ + + +class Telemetry(TypedDict, total=False): + """Telemetry configuration for the browser session. + + Set enabled to true to start capture using VM defaults, or provide browser category settings. If omitted, null, set to an empty object ({}), set to enabled: false without browser category settings, or all four categories are explicitly disabled, capture is not started. + """ + + browser: BrowserTelemetryCategoriesConfigParam + """Per-category capture flags. + + The operational categories (control, connection, system, captcha) are captured + whenever telemetry is enabled; set one to enabled=false to opt out. The CDP + categories (console, network, page, interaction) and screenshot are off by + default; set enabled=true to opt in. On create, provided categories layer onto + the default set. On update, provided categories merge onto the session's current + config; when no telemetry is active this falls back to the default set (matching + create). If browser is omitted or empty, the default set is used. A browser + config that disables every category stops capture on update and starts no + capture on create. + """ + + enabled: bool + """Request shortcut for browser telemetry capture. + + True enables capture; with no browser category settings it captures the default + set (control, connection, system, captcha), and any browser category settings + are layered onto that default set. On update, enabled=true resolves the config + fresh from the default set plus any provided categories, replacing the session's + current selection rather than merging onto it; omit enabled to merge categories + onto the current selection instead. False stops capture on update and starts no + capture on create. enabled=false cannot be combined with browser category + settings. + """ diff --git a/src/kernel/types/browser_create_response.py b/src/kernel/types/browser_create_response.py new file mode 100644 index 00000000..8008e6de --- /dev/null +++ b/src/kernel/types/browser_create_response.py @@ -0,0 +1,114 @@ +# File generated from our OpenAPI spec by Stainless. See CONTRIBUTING.md for details. + +from typing import Dict, Optional +from datetime import datetime + +from .tags import Tags +from .profile import Profile +from .._models import BaseModel +from .browser_usage import BrowserUsage +from .browser_pool_ref import BrowserPoolRef +from .shared.browser_viewport import BrowserViewport +from .browsers.browser_telemetry_config import BrowserTelemetryConfig + +__all__ = ["BrowserCreateResponse"] + + +class BrowserCreateResponse(BaseModel): + cdp_ws_url: str + """Websocket URL for Chrome DevTools Protocol connections to the browser session""" + + created_at: datetime + """When the browser session was created.""" + + headless: bool + """Whether the browser session is running in headless mode.""" + + session_id: str + """Unique identifier for the browser session""" + + stealth: bool + """Whether the browser session is running in stealth mode.""" + + timeout_seconds: int + """The number of seconds of inactivity before the browser session is terminated.""" + + webdriver_ws_url: str + """Websocket URL for WebDriver BiDi connections to the browser session""" + + base_url: Optional[str] = None + """Metro-API HTTP base URL for this browser session.""" + + browser_live_view_url: Optional[str] = None + """Remote URL for live viewing the browser session. + + Only available for non-headless browsers. + """ + + chrome_policy: Optional[Dict[str, object]] = None + """ + Custom Chrome enterprise policy overrides that were applied to this browser + session, if any. Echoed back for verification. Keys are Chrome enterprise policy + names. + """ + + deleted_at: Optional[datetime] = None + """When the browser session was soft-deleted. Only present for deleted sessions.""" + + gpu: Optional[bool] = None + """ + Whether GPU acceleration is enabled for the browser session (only supported for + headful sessions). + """ + + kiosk_mode: Optional[bool] = None + """Whether the browser session is running in kiosk mode.""" + + name: Optional[str] = None + """Human-readable name of the browser session, if one was set at creation.""" + + pool: Optional[BrowserPoolRef] = None + """Browser pool this session was acquired from, if any.""" + + profile: Optional[Profile] = None + """Browser profile metadata.""" + + proxy_id: Optional[str] = None + """ID of the proxy associated with this browser session, if any.""" + + start_url: Optional[str] = None + """URL the session was asked to navigate to on creation, if any. + + Recorded for debugging. Navigation is fire-and-forget — the URL is dispatched to + the browser without waiting for it to load, and any errors (DNS failure, bad + status, timeout) are silently dropped. Captures what was requested, not what the + browser actually loaded. + """ + + tags: Optional[Tags] = None + """User-defined key-value tags that were set on this browser session, if any. + + Echoed back when present. + """ + + telemetry: Optional[BrowserTelemetryConfig] = None + """Active telemetry configuration for the session, if any.""" + + usage: Optional[BrowserUsage] = None + """Session usage metrics.""" + + viewport: Optional[BrowserViewport] = None + """ + Initial browser window size in pixels with optional refresh rate. If omitted, + image defaults apply (1920x1080@25). For GPU images, the default is + 1920x1080@60. Arbitrary viewport dimensions and refresh rates are accepted. + Known-good presets include: 2560x1440@10, 1920x1080@25, 1920x1200@25, + 1440x900@25, 1280x800@60, 1024x768@60, 1200x800@60, 768x1024@60, 390x844@60. For + GPU images, recommended presets use one of these resolutions with refresh rates + 60, 30, 25, or 10: 800x600, 960x720, 1024x576, 1024x768, 1152x648, 1200x800, + 1280x720, 1368x768, 1440x900, 1600x900, 1920x1080, 1920x1200, 390x844, 360x250, + 768x1024, 800x1600. Viewports outside this list may exhibit unstable live view + or recording behavior. If refresh_rate is not provided, it will be automatically + determined based on the resolution (higher resolutions use lower refresh rates + to keep bandwidth reasonable). + """ diff --git a/src/kernel/types/browser_curl_params.py b/src/kernel/types/browser_curl_params.py new file mode 100644 index 00000000..750bd6d3 --- /dev/null +++ b/src/kernel/types/browser_curl_params.py @@ -0,0 +1,28 @@ +# 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__ = ["BrowserCurlParams"] + + +class BrowserCurlParams(TypedDict, total=False): + url: Required[str] + """Target URL (must be http or https).""" + + body: str + """Request body (for POST/PUT/PATCH).""" + + headers: Dict[str, str] + """Custom headers merged with browser defaults.""" + + method: Literal["GET", "HEAD", "POST", "PUT", "PATCH", "DELETE", "OPTIONS"] + """HTTP method.""" + + response_encoding: Literal["utf8", "base64"] + """Encoding for the response body. Use base64 for binary content.""" + + timeout_ms: int + """Request timeout in milliseconds.""" diff --git a/src/kernel/types/browser_curl_response.py b/src/kernel/types/browser_curl_response.py new file mode 100644 index 00000000..1b288e44 --- /dev/null +++ b/src/kernel/types/browser_curl_response.py @@ -0,0 +1,23 @@ +# File generated from our OpenAPI spec by Stainless. See CONTRIBUTING.md for details. + +from typing import Dict, List + +from .._models import BaseModel + +__all__ = ["BrowserCurlResponse"] + + +class BrowserCurlResponse(BaseModel): + """Structured response from the browser curl request.""" + + body: str + """Response body (UTF-8 string or base64 depending on request).""" + + duration_ms: int + """Total request duration in milliseconds.""" + + headers: Dict[str, List[str]] + """Response headers (multi-value).""" + + status: int + """HTTP status code from target.""" diff --git a/src/kernel/types/browser_list_params.py b/src/kernel/types/browser_list_params.py new file mode 100644 index 00000000..4006dfbe --- /dev/null +++ b/src/kernel/types/browser_list_params.py @@ -0,0 +1,40 @@ +# 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, TypedDict + +__all__ = ["BrowserListParams"] + + +class BrowserListParams(TypedDict, total=False): + include_deleted: bool + """Deprecated: Use status=all instead. + + When true, includes soft-deleted browser sessions in the results alongside + active sessions. + """ + + limit: int + """Maximum number of results to return. Defaults to 20, maximum 100.""" + + offset: int + """Number of results to skip. Defaults to 0.""" + + query: str + """Search browsers by name, session ID, profile ID, proxy ID, or pool name.""" + + status: Literal["active", "deleted", "all"] + """Filter sessions by status. + + "active" returns only active sessions (default), "deleted" returns only + soft-deleted sessions, "all" returns both. + """ + + tags: Dict[str, str] + """Filter sessions by tag key-value pairs using deepObject style, e.g. + + ?tags[team]=backend&tags[env]=staging. Multiple pairs are ANDed: a session must + match every supplied pair exactly. + """ diff --git a/src/kernel/types/browser_list_response.py b/src/kernel/types/browser_list_response.py new file mode 100644 index 00000000..79e1c44f --- /dev/null +++ b/src/kernel/types/browser_list_response.py @@ -0,0 +1,114 @@ +# File generated from our OpenAPI spec by Stainless. See CONTRIBUTING.md for details. + +from typing import Dict, Optional +from datetime import datetime + +from .tags import Tags +from .profile import Profile +from .._models import BaseModel +from .browser_usage import BrowserUsage +from .browser_pool_ref import BrowserPoolRef +from .shared.browser_viewport import BrowserViewport +from .browsers.browser_telemetry_config import BrowserTelemetryConfig + +__all__ = ["BrowserListResponse"] + + +class BrowserListResponse(BaseModel): + cdp_ws_url: str + """Websocket URL for Chrome DevTools Protocol connections to the browser session""" + + created_at: datetime + """When the browser session was created.""" + + headless: bool + """Whether the browser session is running in headless mode.""" + + session_id: str + """Unique identifier for the browser session""" + + stealth: bool + """Whether the browser session is running in stealth mode.""" + + timeout_seconds: int + """The number of seconds of inactivity before the browser session is terminated.""" + + webdriver_ws_url: str + """Websocket URL for WebDriver BiDi connections to the browser session""" + + base_url: Optional[str] = None + """Metro-API HTTP base URL for this browser session.""" + + browser_live_view_url: Optional[str] = None + """Remote URL for live viewing the browser session. + + Only available for non-headless browsers. + """ + + chrome_policy: Optional[Dict[str, object]] = None + """ + Custom Chrome enterprise policy overrides that were applied to this browser + session, if any. Echoed back for verification. Keys are Chrome enterprise policy + names. + """ + + deleted_at: Optional[datetime] = None + """When the browser session was soft-deleted. Only present for deleted sessions.""" + + gpu: Optional[bool] = None + """ + Whether GPU acceleration is enabled for the browser session (only supported for + headful sessions). + """ + + kiosk_mode: Optional[bool] = None + """Whether the browser session is running in kiosk mode.""" + + name: Optional[str] = None + """Human-readable name of the browser session, if one was set at creation.""" + + pool: Optional[BrowserPoolRef] = None + """Browser pool this session was acquired from, if any.""" + + profile: Optional[Profile] = None + """Browser profile metadata.""" + + proxy_id: Optional[str] = None + """ID of the proxy associated with this browser session, if any.""" + + start_url: Optional[str] = None + """URL the session was asked to navigate to on creation, if any. + + Recorded for debugging. Navigation is fire-and-forget — the URL is dispatched to + the browser without waiting for it to load, and any errors (DNS failure, bad + status, timeout) are silently dropped. Captures what was requested, not what the + browser actually loaded. + """ + + tags: Optional[Tags] = None + """User-defined key-value tags that were set on this browser session, if any. + + Echoed back when present. + """ + + telemetry: Optional[BrowserTelemetryConfig] = None + """Active telemetry configuration for the session, if any.""" + + usage: Optional[BrowserUsage] = None + """Session usage metrics.""" + + viewport: Optional[BrowserViewport] = None + """ + Initial browser window size in pixels with optional refresh rate. If omitted, + image defaults apply (1920x1080@25). For GPU images, the default is + 1920x1080@60. Arbitrary viewport dimensions and refresh rates are accepted. + Known-good presets include: 2560x1440@10, 1920x1080@25, 1920x1200@25, + 1440x900@25, 1280x800@60, 1024x768@60, 1200x800@60, 768x1024@60, 390x844@60. For + GPU images, recommended presets use one of these resolutions with refresh rates + 60, 30, 25, or 10: 800x600, 960x720, 1024x576, 1024x768, 1152x648, 1200x800, + 1280x720, 1368x768, 1440x900, 1600x900, 1920x1080, 1920x1200, 390x844, 360x250, + 768x1024, 800x1600. Viewports outside this list may exhibit unstable live view + or recording behavior. If refresh_rate is not provided, it will be automatically + determined based on the resolution (higher resolutions use lower refresh rates + to keep bandwidth reasonable). + """ diff --git a/src/kernel/types/browser_load_extensions_params.py b/src/kernel/types/browser_load_extensions_params.py new file mode 100644 index 00000000..6212380c --- /dev/null +++ b/src/kernel/types/browser_load_extensions_params.py @@ -0,0 +1,26 @@ +# File generated from our OpenAPI spec by Stainless. See CONTRIBUTING.md for details. + +from __future__ import annotations + +from typing import Iterable +from typing_extensions import Required, TypedDict + +from .._types import FileTypes + +__all__ = ["BrowserLoadExtensionsParams", "Extension"] + + +class BrowserLoadExtensionsParams(TypedDict, total=False): + extensions: Required[Iterable[Extension]] + """List of extensions to upload and activate""" + + +class Extension(TypedDict, total=False): + name: Required[str] + """Folder name to place the extension under /home/kernel/extensions/""" + + zip_file: Required[FileTypes] + """ + Zip archive containing an unpacked Chromium extension (must include + manifest.json) + """ diff --git a/src/kernel/types/browser_pool.py b/src/kernel/types/browser_pool.py new file mode 100644 index 00000000..344ffd77 --- /dev/null +++ b/src/kernel/types/browser_pool.py @@ -0,0 +1,178 @@ +# File generated from our OpenAPI spec by Stainless. See CONTRIBUTING.md for details. + +from typing import Dict, List, Optional +from datetime import datetime + +from .._models import BaseModel +from .shared.browser_viewport import BrowserViewport +from .shared.browser_extension import BrowserExtension +from .browsers.browser_telemetry_config import BrowserTelemetryConfig + +__all__ = ["BrowserPool", "BrowserPoolConfig", "BrowserPoolConfigProfile"] + + +class BrowserPoolConfigProfile(BaseModel): + """Profile configuration for browsers in a pool. + + Provide either id or name. Profiles must + be created beforehand. Unlike single browser sessions, pools load the profile read-only + and never persist changes back to it, so save_changes is omitted here. Any save_changes + value sent on a pool profile is silently ignored rather than rejected. + """ + + id: Optional[str] = None + """Profile ID to load for browsers in this pool""" + + name: Optional[str] = None + """Profile name to load for browsers in this pool (instead of id). + + Must be 1-255 characters, using letters, numbers, dots, underscores, or hyphens. + """ + + +class BrowserPoolConfig(BaseModel): + """Configuration used to create all browsers in this pool""" + + size: int + """Number of browsers maintained in the pool. + + The maximum size is determined by your organization's pooled sessions limit (the + sum of all pool sizes cannot exceed your limit). + """ + + chrome_policy: Optional[Dict[str, object]] = None + """Custom Chrome enterprise policy overrides applied to all browsers in this pool. + + Keys are Chrome enterprise policy names; values must match their expected types. + Blocked: kernel-managed policies (extensions, proxy, CDP/automation). See + https://chromeenterprise.google/policies/ The serialized JSON payload is capped + at 5 MiB. + """ + + extensions: Optional[List[BrowserExtension]] = None + """List of browser extensions to load into the session. + + Provide each by id or name. + """ + + fill_rate_per_minute: Optional[int] = None + """Percentage of the pool to fill per minute. + + The cap is 25 for most organizations but can be raised per-organization, so only + the lower bound is enforced here. + """ + + headless: Optional[bool] = None + """If true, launches the browser using a headless image.""" + + kiosk_mode: Optional[bool] = None + """ + If true, launches the browser in kiosk mode to hide address bar and tabs in live + view. + """ + + name: Optional[str] = None + """Optional name for the browser pool. Must be unique within the project.""" + + profile: Optional[BrowserPoolConfigProfile] = None + """Profile configuration for browsers in a pool. + + Provide either id or name. Profiles must be created beforehand. Unlike single + browser sessions, pools load the profile read-only and never persist changes + back to it, so save_changes is omitted here. Any save_changes value sent on a + pool profile is silently ignored rather than rejected. + """ + + proxy_id: Optional[str] = None + """Optional proxy associated to the browser session. + + References a proxy in the same project as the browser session. + """ + + refresh_on_profile_update: Optional[bool] = None + """ + When true, flush idle browsers when the profile the pool uses is updated, so + pool browsers pick up the latest profile data. When a profile is provided during + creation, this defaults to true. Requires a profile to be set on the pool. + """ + + start_url: Optional[str] = None + """Optional URL to navigate to when a new browser is warmed into the pool. + + Best-effort: failures to navigate do not fail pool fill. Only applied to + newly-warmed browsers; browsers reused via release/acquire keep whatever URL the + previous lease left them on. Accepts any URL Chromium can resolve, including + chrome:// pages. + """ + + stealth: Optional[bool] = None + """ + If true, launches the browser in stealth mode to reduce detection by anti-bot + mechanisms. + """ + + telemetry: Optional[BrowserTelemetryConfig] = None + """ + Active telemetry configuration applied to browsers warmed into this pool, if + any. + """ + + timeout_seconds: Optional[int] = None + """ + Default idle timeout in seconds for browsers acquired from this pool before they + are destroyed. Minimum 10, maximum 259200 (72 hours). + """ + + viewport: Optional[BrowserViewport] = None + """ + Initial browser window size in pixels with optional refresh rate. If omitted, + image defaults apply (1920x1080@25). For GPU images, the default is + 1920x1080@60. Arbitrary viewport dimensions and refresh rates are accepted. + Known-good presets include: 2560x1440@10, 1920x1080@25, 1920x1200@25, + 1440x900@25, 1280x800@60, 1024x768@60, 1200x800@60, 768x1024@60, 390x844@60. For + GPU images, recommended presets use one of these resolutions with refresh rates + 60, 30, 25, or 10: 800x600, 960x720, 1024x576, 1024x768, 1152x648, 1200x800, + 1280x720, 1368x768, 1440x900, 1600x900, 1920x1080, 1920x1200, 390x844, 360x250, + 768x1024, 800x1600. Viewports outside this list may exhibit unstable live view + or recording behavior. If refresh_rate is not provided, it will be automatically + determined based on the resolution (higher resolutions use lower refresh rates + to keep bandwidth reasonable). + """ + + +class BrowserPool(BaseModel): + """A browser pool containing multiple identically configured browsers.""" + + id: str + """Unique identifier for the browser pool""" + + acquired_count: int + """Number of browsers currently acquired from the pool""" + + available_count: int + """Number of browsers currently available in the pool""" + + browser_pool_config: BrowserPoolConfig + """Configuration used to create all browsers in this pool""" + + created_at: datetime + """Timestamp when the browser pool was created""" + + extension_ids: List[str] + """Resolved extension IDs attached to the pool, in configured load order. + + Empty when no extensions are attached. Authoritative for programmatic consumers; + the extensions inside `browser_pool_config` reflect the configured selector + (echoed as sent on create). + """ + + name: Optional[str] = None + """Browser pool name, if set""" + + profile_id: Optional[str] = None + """Resolved profile ID the pool is attached to. + + Omitted when no profile is attached. Authoritative for programmatic consumers; + the profile inside `browser_pool_config` reflects the configured selector + (echoed as sent on create). + """ diff --git a/src/kernel/types/browser_pool_acquire_params.py b/src/kernel/types/browser_pool_acquire_params.py new file mode 100644 index 00000000..70567ee3 --- /dev/null +++ b/src/kernel/types/browser_pool_acquire_params.py @@ -0,0 +1,88 @@ +# File generated from our OpenAPI spec by Stainless. See CONTRIBUTING.md for details. + +from __future__ import annotations + +from typing import Optional +from typing_extensions import TypedDict + +from .tags_param import TagsParam +from .browsers.browser_telemetry_categories_config_param import BrowserTelemetryCategoriesConfigParam + +__all__ = ["BrowserPoolAcquireParams", "Telemetry"] + + +class BrowserPoolAcquireParams(TypedDict, total=False): + acquire_timeout_seconds: int + """Maximum number of seconds to wait for a browser to be available. + + Defaults to the calculated time it would take to fill the pool at the currently + configured fill rate. + """ + + name: str + """ + Optional human-readable name for the acquired browser session, used to find it + later in the dashboard. Must be unique among active sessions within the pool's + project. Applies to this lease only and is cleared when the browser is released + back to the pool. + """ + + start_url: str + """Optional URL to navigate the acquired browser to. + + Overrides the pool's start_url for this acquire only. Best-effort: failures to + navigate do not fail the acquire. + """ + + tags: TagsParam + """ + Optional user-defined key-value tags for the acquired browser session, used to + find and group sessions later. Applies to this lease only and are cleared when + the browser is released back to the pool. Up to 50 pairs. + """ + + telemetry: Optional[Telemetry] + """Telemetry override for the acquired browser, applied to this lease only. + + Merges onto the browser's current (pool-inherited) telemetry using the same + per-category semantics as PATCH /browsers: provided categories override the + current configuration, omitted categories are inherited. Set enabled to true to + resolve the config fresh from the default set, or enabled to false to stop + capture. When the browser is released back to the pool with reuse, its telemetry + is reset to the pool's baseline, so the override does not carry over to the next + lease. + """ + + +class Telemetry(TypedDict, total=False): + """Telemetry override for the acquired browser, applied to this lease only. + + Merges onto the browser's current (pool-inherited) telemetry using the same per-category semantics as PATCH /browsers: provided categories override the current configuration, omitted categories are inherited. Set enabled to true to resolve the config fresh from the default set, or enabled to false to stop capture. When the browser is released back to the pool with reuse, its telemetry is reset to the pool's baseline, so the override does not carry over to the next lease. + """ + + browser: BrowserTelemetryCategoriesConfigParam + """Per-category capture flags. + + The operational categories (control, connection, system, captcha) are captured + whenever telemetry is enabled; set one to enabled=false to opt out. The CDP + categories (console, network, page, interaction) and screenshot are off by + default; set enabled=true to opt in. On create, provided categories layer onto + the default set. On update, provided categories merge onto the session's current + config; when no telemetry is active this falls back to the default set (matching + create). If browser is omitted or empty, the default set is used. A browser + config that disables every category stops capture on update and starts no + capture on create. + """ + + enabled: bool + """Request shortcut for browser telemetry capture. + + True enables capture; with no browser category settings it captures the default + set (control, connection, system, captcha), and any browser category settings + are layered onto that default set. On update, enabled=true resolves the config + fresh from the default set plus any provided categories, replacing the session's + current selection rather than merging onto it; omit enabled to merge categories + onto the current selection instead. False stops capture on update and starts no + capture on create. enabled=false cannot be combined with browser category + settings. + """ diff --git a/src/kernel/types/browser_pool_acquire_response.py b/src/kernel/types/browser_pool_acquire_response.py new file mode 100644 index 00000000..45fe471d --- /dev/null +++ b/src/kernel/types/browser_pool_acquire_response.py @@ -0,0 +1,114 @@ +# File generated from our OpenAPI spec by Stainless. See CONTRIBUTING.md for details. + +from typing import Dict, Optional +from datetime import datetime + +from .tags import Tags +from .profile import Profile +from .._models import BaseModel +from .browser_usage import BrowserUsage +from .browser_pool_ref import BrowserPoolRef +from .shared.browser_viewport import BrowserViewport +from .browsers.browser_telemetry_config import BrowserTelemetryConfig + +__all__ = ["BrowserPoolAcquireResponse"] + + +class BrowserPoolAcquireResponse(BaseModel): + cdp_ws_url: str + """Websocket URL for Chrome DevTools Protocol connections to the browser session""" + + created_at: datetime + """When the browser session was created.""" + + headless: bool + """Whether the browser session is running in headless mode.""" + + session_id: str + """Unique identifier for the browser session""" + + stealth: bool + """Whether the browser session is running in stealth mode.""" + + timeout_seconds: int + """The number of seconds of inactivity before the browser session is terminated.""" + + webdriver_ws_url: str + """Websocket URL for WebDriver BiDi connections to the browser session""" + + base_url: Optional[str] = None + """Metro-API HTTP base URL for this browser session.""" + + browser_live_view_url: Optional[str] = None + """Remote URL for live viewing the browser session. + + Only available for non-headless browsers. + """ + + chrome_policy: Optional[Dict[str, object]] = None + """ + Custom Chrome enterprise policy overrides that were applied to this browser + session, if any. Echoed back for verification. Keys are Chrome enterprise policy + names. + """ + + deleted_at: Optional[datetime] = None + """When the browser session was soft-deleted. Only present for deleted sessions.""" + + gpu: Optional[bool] = None + """ + Whether GPU acceleration is enabled for the browser session (only supported for + headful sessions). + """ + + kiosk_mode: Optional[bool] = None + """Whether the browser session is running in kiosk mode.""" + + name: Optional[str] = None + """Human-readable name of the browser session, if one was set at creation.""" + + pool: Optional[BrowserPoolRef] = None + """Browser pool this session was acquired from, if any.""" + + profile: Optional[Profile] = None + """Browser profile metadata.""" + + proxy_id: Optional[str] = None + """ID of the proxy associated with this browser session, if any.""" + + start_url: Optional[str] = None + """URL the session was asked to navigate to on creation, if any. + + Recorded for debugging. Navigation is fire-and-forget — the URL is dispatched to + the browser without waiting for it to load, and any errors (DNS failure, bad + status, timeout) are silently dropped. Captures what was requested, not what the + browser actually loaded. + """ + + tags: Optional[Tags] = None + """User-defined key-value tags that were set on this browser session, if any. + + Echoed back when present. + """ + + telemetry: Optional[BrowserTelemetryConfig] = None + """Active telemetry configuration for the session, if any.""" + + usage: Optional[BrowserUsage] = None + """Session usage metrics.""" + + viewport: Optional[BrowserViewport] = None + """ + Initial browser window size in pixels with optional refresh rate. If omitted, + image defaults apply (1920x1080@25). For GPU images, the default is + 1920x1080@60. Arbitrary viewport dimensions and refresh rates are accepted. + Known-good presets include: 2560x1440@10, 1920x1080@25, 1920x1200@25, + 1440x900@25, 1280x800@60, 1024x768@60, 1200x800@60, 768x1024@60, 390x844@60. For + GPU images, recommended presets use one of these resolutions with refresh rates + 60, 30, 25, or 10: 800x600, 960x720, 1024x576, 1024x768, 1152x648, 1200x800, + 1280x720, 1368x768, 1440x900, 1600x900, 1920x1080, 1920x1200, 390x844, 360x250, + 768x1024, 800x1600. Viewports outside this list may exhibit unstable live view + or recording behavior. If refresh_rate is not provided, it will be automatically + determined based on the resolution (higher resolutions use lower refresh rates + to keep bandwidth reasonable). + """ diff --git a/src/kernel/types/browser_pool_create_params.py b/src/kernel/types/browser_pool_create_params.py new file mode 100644 index 00000000..376af6b6 --- /dev/null +++ b/src/kernel/types/browser_pool_create_params.py @@ -0,0 +1,177 @@ +# File generated from our OpenAPI spec by Stainless. See CONTRIBUTING.md for details. + +from __future__ import annotations + +from typing import Dict, Iterable, Optional +from typing_extensions import Required, TypedDict + +from .shared_params.browser_viewport import BrowserViewport +from .shared_params.browser_extension import BrowserExtension +from .browsers.browser_telemetry_categories_config_param import BrowserTelemetryCategoriesConfigParam + +__all__ = ["BrowserPoolCreateParams", "Profile", "Telemetry"] + + +class BrowserPoolCreateParams(TypedDict, total=False): + size: Required[int] + """Number of browsers to maintain in the pool. + + The maximum size is determined by your organization's pooled sessions limit (the + sum of all pool sizes cannot exceed your limit). + """ + + chrome_policy: Dict[str, object] + """Custom Chrome enterprise policy overrides applied to all browsers in this pool. + + Keys are Chrome enterprise policy names; values must match their expected types. + Blocked: kernel-managed policies (extensions, proxy, CDP/automation). See + https://chromeenterprise.google/policies/ The serialized JSON payload is capped + at 5 MiB. + """ + + extensions: Iterable[BrowserExtension] + """List of browser extensions to load into the session. + + Provide each by id or name. + """ + + fill_rate_per_minute: int + """Percentage of the pool to fill per minute. + + Defaults to 25. The cap is 25 for most organizations but can be raised + per-organization, so only the lower bound is enforced here. + """ + + headless: bool + """If true, launches the browser using a headless image. Defaults to false.""" + + kiosk_mode: bool + """ + If true, launches the browser in kiosk mode to hide address bar and tabs in live + view. Defaults to false. + """ + + name: str + """Optional name for the browser pool. Must be unique within the project.""" + + profile: Profile + """Profile configuration for browsers in a pool. + + Provide either id or name. Profiles must be created beforehand. Unlike single + browser sessions, pools load the profile read-only and never persist changes + back to it, so save_changes is omitted here. Any save_changes value sent on a + pool profile is silently ignored rather than rejected. + """ + + proxy_id: str + """Optional proxy to associate to the browser session. + + Must reference a proxy in the same project as the browser session. + """ + + refresh_on_profile_update: bool + """ + When true, flush idle browsers when the profile the pool uses is updated, so + pool browsers pick up the latest profile data. When a profile is provided during + creation, this defaults to true. Requires a profile to be set on the pool. + """ + + start_url: str + """Optional URL to navigate to when a new browser is warmed into the pool. + + Best-effort: failures to navigate do not fail pool fill. Only applied to + newly-warmed browsers; browsers reused via release/acquire keep whatever URL the + previous lease left them on. Accepts any URL Chromium can resolve, including + chrome:// pages. + """ + + stealth: bool + """ + If true, launches the browser in stealth mode to reduce detection by anti-bot + mechanisms. Defaults to false. + """ + + telemetry: Optional[Telemetry] + """Telemetry configuration applied to browsers warmed into this pool. + + Set enabled to true to start capture using the default set, or provide browser + category settings. If omitted, null, set to an empty object ({}), set to + enabled: false without browser category settings, or all four CDP categories are + explicitly disabled, no telemetry is configured on the pool. Only applied to + newly-warmed browsers. + """ + + timeout_seconds: int + """ + Default idle timeout in seconds for browsers acquired from this pool before they + are destroyed. Defaults to 600 seconds. Minimum 10, maximum 259200 (72 hours). + """ + + viewport: BrowserViewport + """ + Initial browser window size in pixels with optional refresh rate. If omitted, + image defaults apply (1920x1080@25). For GPU images, the default is + 1920x1080@60. Arbitrary viewport dimensions and refresh rates are accepted. + Known-good presets include: 2560x1440@10, 1920x1080@25, 1920x1200@25, + 1440x900@25, 1280x800@60, 1024x768@60, 1200x800@60, 768x1024@60, 390x844@60. For + GPU images, recommended presets use one of these resolutions with refresh rates + 60, 30, 25, or 10: 800x600, 960x720, 1024x576, 1024x768, 1152x648, 1200x800, + 1280x720, 1368x768, 1440x900, 1600x900, 1920x1080, 1920x1200, 390x844, 360x250, + 768x1024, 800x1600. Viewports outside this list may exhibit unstable live view + or recording behavior. If refresh_rate is not provided, it will be automatically + determined based on the resolution (higher resolutions use lower refresh rates + to keep bandwidth reasonable). + """ + + +class Profile(TypedDict, total=False): + """Profile configuration for browsers in a pool. + + Provide either id or name. Profiles must + be created beforehand. Unlike single browser sessions, pools load the profile read-only + and never persist changes back to it, so save_changes is omitted here. Any save_changes + value sent on a pool profile is silently ignored rather than rejected. + """ + + id: str + """Profile ID to load for browsers in this pool""" + + name: str + """Profile name to load for browsers in this pool (instead of id). + + Must be 1-255 characters, using letters, numbers, dots, underscores, or hyphens. + """ + + +class Telemetry(TypedDict, total=False): + """Telemetry configuration applied to browsers warmed into this pool. + + Set enabled to true to start capture using the default set, or provide browser category settings. If omitted, null, set to an empty object ({}), set to enabled: false without browser category settings, or all four CDP categories are explicitly disabled, no telemetry is configured on the pool. Only applied to newly-warmed browsers. + """ + + browser: BrowserTelemetryCategoriesConfigParam + """Per-category capture flags. + + The operational categories (control, connection, system, captcha) are captured + whenever telemetry is enabled; set one to enabled=false to opt out. The CDP + categories (console, network, page, interaction) and screenshot are off by + default; set enabled=true to opt in. On create, provided categories layer onto + the default set. On update, provided categories merge onto the session's current + config; when no telemetry is active this falls back to the default set (matching + create). If browser is omitted or empty, the default set is used. A browser + config that disables every category stops capture on update and starts no + capture on create. + """ + + enabled: bool + """Request shortcut for browser telemetry capture. + + True enables capture; with no browser category settings it captures the default + set (control, connection, system, captcha), and any browser category settings + are layered onto that default set. On update, enabled=true resolves the config + fresh from the default set plus any provided categories, replacing the session's + current selection rather than merging onto it; omit enabled to merge categories + onto the current selection instead. False stops capture on update and starts no + capture on create. enabled=false cannot be combined with browser category + settings. + """ diff --git a/src/kernel/types/browser_pool_delete_params.py b/src/kernel/types/browser_pool_delete_params.py new file mode 100644 index 00000000..0a63c0f1 --- /dev/null +++ b/src/kernel/types/browser_pool_delete_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 TypedDict + +__all__ = ["BrowserPoolDeleteParams"] + + +class BrowserPoolDeleteParams(TypedDict, total=False): + force: bool + """If true, force delete even if browsers are currently leased. + + Leased browsers will be terminated. + """ diff --git a/src/kernel/types/browser_pool_list_params.py b/src/kernel/types/browser_pool_list_params.py new file mode 100644 index 00000000..75fea5db --- /dev/null +++ b/src/kernel/types/browser_pool_list_params.py @@ -0,0 +1,29 @@ +# File generated from our OpenAPI spec by Stainless. See CONTRIBUTING.md for details. + +from __future__ import annotations + +from typing_extensions import TypedDict + +__all__ = ["BrowserPoolListParams"] + + +class BrowserPoolListParams(TypedDict, total=False): + limit: int + """Limit the number of browser pools to return.""" + + name: str + """Exact-match filter on browser pool name using the database collation. + + In production, matching is case- and accent-insensitive. During the + default-project migration, unscoped requests prefer a concrete default-project + browser pool over a legacy unscoped browser pool with the same name. + """ + + offset: int + """Offset the number of browser pools to return.""" + + query: str + """Case-insensitive substring match against browser pool name. + + IDs match by exact value. + """ diff --git a/src/kernel/types/browser_pool_ref.py b/src/kernel/types/browser_pool_ref.py new file mode 100644 index 00000000..326ad7b7 --- /dev/null +++ b/src/kernel/types/browser_pool_ref.py @@ -0,0 +1,17 @@ +# File generated from our OpenAPI spec by Stainless. See CONTRIBUTING.md for details. + +from typing import Optional + +from .._models import BaseModel + +__all__ = ["BrowserPoolRef"] + + +class BrowserPoolRef(BaseModel): + """Browser pool this session was acquired from, if any.""" + + id: str + """Browser pool ID""" + + name: Optional[str] = None + """Browser pool name, if set""" diff --git a/src/kernel/types/browser_pool_release_params.py b/src/kernel/types/browser_pool_release_params.py new file mode 100644 index 00000000..944e734b --- /dev/null +++ b/src/kernel/types/browser_pool_release_params.py @@ -0,0 +1,21 @@ +# File generated from our OpenAPI spec by Stainless. See CONTRIBUTING.md for details. + +from __future__ import annotations + +from typing_extensions import Required, TypedDict + +__all__ = ["BrowserPoolReleaseParams"] + + +class BrowserPoolReleaseParams(TypedDict, total=False): + session_id: Required[str] + """Browser session ID to release back to the pool""" + + reuse: bool + """Whether to reuse the browser instance or destroy it and create a new one. + + Defaults to true. A reused browser keeps the configuration it was created with, + so it does not pick up pool configuration changes made while it was in use. + Release with `reuse: false`, or flush the pool afterward, to rebuild it with the + current configuration. + """ diff --git a/src/kernel/types/browser_pool_update_params.py b/src/kernel/types/browser_pool_update_params.py new file mode 100644 index 00000000..b0956048 --- /dev/null +++ b/src/kernel/types/browser_pool_update_params.py @@ -0,0 +1,188 @@ +# File generated from our OpenAPI spec by Stainless. See CONTRIBUTING.md for details. + +from __future__ import annotations + +from typing import Dict, Iterable, Optional +from typing_extensions import TypedDict + +from .shared_params.browser_viewport import BrowserViewport +from .shared_params.browser_extension import BrowserExtension +from .browsers.browser_telemetry_categories_config_param import BrowserTelemetryCategoriesConfigParam + +__all__ = ["BrowserPoolUpdateParams", "Profile", "Telemetry"] + + +class BrowserPoolUpdateParams(TypedDict, total=False): + chrome_policy: Dict[str, object] + """ + If provided, replaces the custom Chrome enterprise policy overrides applied to + all browsers in this pool. Empty object clears any previously-set policy. Keys + are Chrome enterprise policy names; values must match their expected types. + Blocked: kernel-managed policies (extensions, proxy, CDP/automation). See + https://chromeenterprise.google/policies/ The serialized JSON payload is capped + at 5 MiB. + """ + + discard_all_idle: bool + """ + Whether to discard all idle browsers and rebuild them immediately with the new + configuration. Defaults to false. Only browsers that are idle when the update + runs are rebuilt. A browser that is in use during the update keeps its original + configuration, and if it is later released with `reuse: true` it returns to the + pool with that stale configuration until it is discarded (by this flag on a + later update, or by flushing the pool). + """ + + extensions: Iterable[BrowserExtension] + """If provided, replaces the extension list. + + Empty array clears all previously-selected extensions. Omit this field to leave + extensions unchanged. + """ + + fill_rate_per_minute: int + """If provided, replaces the percentage of the pool to fill per minute. + + The cap is 25 for most organizations but can be raised per-organization, so only + the lower bound is enforced here. + """ + + headless: bool + """If provided, replaces whether browsers launch using a headless image.""" + + kiosk_mode: bool + """If provided, replaces whether browsers launch in kiosk mode.""" + + name: str + """If provided, replaces the pool name. + + Empty string is a no-op; the pool name cannot be cleared or reset to empty once + assigned. + """ + + profile: Profile + """Profile configuration for browsers in a pool. + + Provide either id or name. Profiles must be created beforehand. Unlike single + browser sessions, pools load the profile read-only and never persist changes + back to it, so save_changes is omitted here. Any save_changes value sent on a + pool profile is silently ignored rather than rejected. + """ + + proxy_id: str + """Empty string clears the previously-selected proxy. + + Omit this field to leave the proxy unchanged. + """ + + refresh_on_profile_update: bool + """ + If provided, replaces whether idle browsers are flushed when the profile the + pool uses is updated. When the pool's profile reference is changed (including + newly attached) and this field is omitted, it defaults to true. Re-sending the + same profile reference leaves this setting unchanged. Clearing the profile also + disables this setting. Requires a profile to be set on the pool. + """ + + size: int + """If provided, replaces the number of browsers to maintain in the pool. + + The maximum size is determined by your organization's pooled sessions limit (the + sum of all pool sizes cannot exceed your limit). + """ + + start_url: str + """ + If provided, replaces the URL to navigate to when a new browser is warmed into + the pool. Empty string clears the previously-set URL. Omit this field to leave + it unchanged. + """ + + stealth: bool + """If provided, replaces whether browsers launch in stealth mode.""" + + telemetry: Optional[Telemetry] + """If provided, updates the pool's telemetry configuration. + + Omit, set to null, or set to an empty object ({}) to leave the existing + configuration unchanged. Set enabled to true to enable capture using the default + set. Set enabled to false to clear the pool's telemetry. Provide browser + category settings for per-category updates, merged onto the pool's current + configuration. Only applied to browsers warmed after the update; browsers + already in the pool keep their configuration until discarded. + """ + + timeout_seconds: int + """ + If provided, replaces the default idle timeout in seconds for browsers acquired + from this pool before they are destroyed. Minimum 10, maximum 259200 (72 hours). + """ + + viewport: BrowserViewport + """ + Initial browser window size in pixels with optional refresh rate. If omitted, + image defaults apply (1920x1080@25). For GPU images, the default is + 1920x1080@60. Arbitrary viewport dimensions and refresh rates are accepted. + Known-good presets include: 2560x1440@10, 1920x1080@25, 1920x1200@25, + 1440x900@25, 1280x800@60, 1024x768@60, 1200x800@60, 768x1024@60, 390x844@60. For + GPU images, recommended presets use one of these resolutions with refresh rates + 60, 30, 25, or 10: 800x600, 960x720, 1024x576, 1024x768, 1152x648, 1200x800, + 1280x720, 1368x768, 1440x900, 1600x900, 1920x1080, 1920x1200, 390x844, 360x250, + 768x1024, 800x1600. Viewports outside this list may exhibit unstable live view + or recording behavior. If refresh_rate is not provided, it will be automatically + determined based on the resolution (higher resolutions use lower refresh rates + to keep bandwidth reasonable). + """ + + +class Profile(TypedDict, total=False): + """Profile configuration for browsers in a pool. + + Provide either id or name. Profiles must + be created beforehand. Unlike single browser sessions, pools load the profile read-only + and never persist changes back to it, so save_changes is omitted here. Any save_changes + value sent on a pool profile is silently ignored rather than rejected. + """ + + id: str + """Profile ID to load for browsers in this pool""" + + name: str + """Profile name to load for browsers in this pool (instead of id). + + Must be 1-255 characters, using letters, numbers, dots, underscores, or hyphens. + """ + + +class Telemetry(TypedDict, total=False): + """If provided, updates the pool's telemetry configuration. + + Omit, set to null, or set to an empty object ({}) to leave the existing configuration unchanged. Set enabled to true to enable capture using the default set. Set enabled to false to clear the pool's telemetry. Provide browser category settings for per-category updates, merged onto the pool's current configuration. Only applied to browsers warmed after the update; browsers already in the pool keep their configuration until discarded. + """ + + browser: BrowserTelemetryCategoriesConfigParam + """Per-category capture flags. + + The operational categories (control, connection, system, captcha) are captured + whenever telemetry is enabled; set one to enabled=false to opt out. The CDP + categories (console, network, page, interaction) and screenshot are off by + default; set enabled=true to opt in. On create, provided categories layer onto + the default set. On update, provided categories merge onto the session's current + config; when no telemetry is active this falls back to the default set (matching + create). If browser is omitted or empty, the default set is used. A browser + config that disables every category stops capture on update and starts no + capture on create. + """ + + enabled: bool + """Request shortcut for browser telemetry capture. + + True enables capture; with no browser category settings it captures the default + set (control, connection, system, captcha), and any browser category settings + are layered onto that default set. On update, enabled=true resolves the config + fresh from the default set plus any provided categories, replacing the session's + current selection rather than merging onto it; omit enabled to merge categories + onto the current selection instead. False stops capture on update and starts no + capture on create. enabled=false cannot be combined with browser category + settings. + """ diff --git a/src/kernel/types/browser_retrieve_params.py b/src/kernel/types/browser_retrieve_params.py new file mode 100644 index 00000000..ec5e8aa1 --- /dev/null +++ b/src/kernel/types/browser_retrieve_params.py @@ -0,0 +1,12 @@ +# File generated from our OpenAPI spec by Stainless. See CONTRIBUTING.md for details. + +from __future__ import annotations + +from typing_extensions import TypedDict + +__all__ = ["BrowserRetrieveParams"] + + +class BrowserRetrieveParams(TypedDict, total=False): + include_deleted: bool + """When true, includes soft-deleted browser sessions in the lookup.""" diff --git a/src/kernel/types/browser_retrieve_response.py b/src/kernel/types/browser_retrieve_response.py new file mode 100644 index 00000000..66c1420e --- /dev/null +++ b/src/kernel/types/browser_retrieve_response.py @@ -0,0 +1,114 @@ +# File generated from our OpenAPI spec by Stainless. See CONTRIBUTING.md for details. + +from typing import Dict, Optional +from datetime import datetime + +from .tags import Tags +from .profile import Profile +from .._models import BaseModel +from .browser_usage import BrowserUsage +from .browser_pool_ref import BrowserPoolRef +from .shared.browser_viewport import BrowserViewport +from .browsers.browser_telemetry_config import BrowserTelemetryConfig + +__all__ = ["BrowserRetrieveResponse"] + + +class BrowserRetrieveResponse(BaseModel): + cdp_ws_url: str + """Websocket URL for Chrome DevTools Protocol connections to the browser session""" + + created_at: datetime + """When the browser session was created.""" + + headless: bool + """Whether the browser session is running in headless mode.""" + + session_id: str + """Unique identifier for the browser session""" + + stealth: bool + """Whether the browser session is running in stealth mode.""" + + timeout_seconds: int + """The number of seconds of inactivity before the browser session is terminated.""" + + webdriver_ws_url: str + """Websocket URL for WebDriver BiDi connections to the browser session""" + + base_url: Optional[str] = None + """Metro-API HTTP base URL for this browser session.""" + + browser_live_view_url: Optional[str] = None + """Remote URL for live viewing the browser session. + + Only available for non-headless browsers. + """ + + chrome_policy: Optional[Dict[str, object]] = None + """ + Custom Chrome enterprise policy overrides that were applied to this browser + session, if any. Echoed back for verification. Keys are Chrome enterprise policy + names. + """ + + deleted_at: Optional[datetime] = None + """When the browser session was soft-deleted. Only present for deleted sessions.""" + + gpu: Optional[bool] = None + """ + Whether GPU acceleration is enabled for the browser session (only supported for + headful sessions). + """ + + kiosk_mode: Optional[bool] = None + """Whether the browser session is running in kiosk mode.""" + + name: Optional[str] = None + """Human-readable name of the browser session, if one was set at creation.""" + + pool: Optional[BrowserPoolRef] = None + """Browser pool this session was acquired from, if any.""" + + profile: Optional[Profile] = None + """Browser profile metadata.""" + + proxy_id: Optional[str] = None + """ID of the proxy associated with this browser session, if any.""" + + start_url: Optional[str] = None + """URL the session was asked to navigate to on creation, if any. + + Recorded for debugging. Navigation is fire-and-forget — the URL is dispatched to + the browser without waiting for it to load, and any errors (DNS failure, bad + status, timeout) are silently dropped. Captures what was requested, not what the + browser actually loaded. + """ + + tags: Optional[Tags] = None + """User-defined key-value tags that were set on this browser session, if any. + + Echoed back when present. + """ + + telemetry: Optional[BrowserTelemetryConfig] = None + """Active telemetry configuration for the session, if any.""" + + usage: Optional[BrowserUsage] = None + """Session usage metrics.""" + + viewport: Optional[BrowserViewport] = None + """ + Initial browser window size in pixels with optional refresh rate. If omitted, + image defaults apply (1920x1080@25). For GPU images, the default is + 1920x1080@60. Arbitrary viewport dimensions and refresh rates are accepted. + Known-good presets include: 2560x1440@10, 1920x1080@25, 1920x1200@25, + 1440x900@25, 1280x800@60, 1024x768@60, 1200x800@60, 768x1024@60, 390x844@60. For + GPU images, recommended presets use one of these resolutions with refresh rates + 60, 30, 25, or 10: 800x600, 960x720, 1024x576, 1024x768, 1152x648, 1200x800, + 1280x720, 1368x768, 1440x900, 1600x900, 1920x1080, 1920x1200, 390x844, 360x250, + 768x1024, 800x1600. Viewports outside this list may exhibit unstable live view + or recording behavior. If refresh_rate is not provided, it will be automatically + determined based on the resolution (higher resolutions use lower refresh rates + to keep bandwidth reasonable). + """ diff --git a/src/kernel/types/browser_update_params.py b/src/kernel/types/browser_update_params.py new file mode 100644 index 00000000..3c3140ee --- /dev/null +++ b/src/kernel/types/browser_update_params.py @@ -0,0 +1,107 @@ +# File generated from our OpenAPI spec by Stainless. See CONTRIBUTING.md for details. + +from __future__ import annotations + +from typing import Optional +from typing_extensions import TypedDict + +from .tags_param import TagsParam +from .shared_params.browser_profile import BrowserProfile +from .shared_params.browser_viewport import BrowserViewport +from .browsers.browser_telemetry_categories_config_param import BrowserTelemetryCategoriesConfigParam + +__all__ = ["BrowserUpdateParams", "Telemetry", "Viewport"] + + +class BrowserUpdateParams(TypedDict, total=False): + disable_default_proxy: bool + """ + If true, stealth browsers connect directly instead of using the default stealth + proxy. + """ + + name: Optional[str] + """Human-readable name for the browser session. + + Omit to leave unchanged, set to an empty string to clear the name. When set, + must be unique among active sessions within the project. + """ + + profile: BrowserProfile + """Profile to load into the browser session. + + Only allowed if the session does not already have a profile loaded. + """ + + proxy_id: Optional[str] + """ID of the proxy to use. + + Omit to leave unchanged, set to empty string to remove proxy. + """ + + tags: Optional[TagsParam] + """User-defined key-value tags for the browser session. + + Omit to leave unchanged. Provide a map to replace the entire tag set (full + replace, not a merge). Set to an empty object ({}) to clear all tags. Up to 50 + pairs. + """ + + telemetry: Optional[Telemetry] + """Telemetry configuration. + + Omit, set to null, or set to an empty object ({}) to leave the existing + configuration unchanged. Set enabled to true to enable capture using VM + defaults. Set enabled to false to stop capture. Provide browser category + settings for per-category updates. Explicitly disabling all four categories also + stops capture. + """ + + viewport: Viewport + """Viewport configuration to apply to the browser session.""" + + +class Telemetry(TypedDict, total=False): + """Telemetry configuration. + + Omit, set to null, or set to an empty object ({}) to leave the existing configuration unchanged. Set enabled to true to enable capture using VM defaults. Set enabled to false to stop capture. Provide browser category settings for per-category updates. Explicitly disabling all four categories also stops capture. + """ + + browser: BrowserTelemetryCategoriesConfigParam + """Per-category capture flags. + + The operational categories (control, connection, system, captcha) are captured + whenever telemetry is enabled; set one to enabled=false to opt out. The CDP + categories (console, network, page, interaction) and screenshot are off by + default; set enabled=true to opt in. On create, provided categories layer onto + the default set. On update, provided categories merge onto the session's current + config; when no telemetry is active this falls back to the default set (matching + create). If browser is omitted or empty, the default set is used. A browser + config that disables every category stops capture on update and starts no + capture on create. + """ + + enabled: bool + """Request shortcut for browser telemetry capture. + + True enables capture; with no browser category settings it captures the default + set (control, connection, system, captcha), and any browser category settings + are layered onto that default set. On update, enabled=true resolves the config + fresh from the default set plus any provided categories, replacing the session's + current selection rather than merging onto it; omit enabled to merge categories + onto the current selection instead. False stops capture on update and starts no + capture on create. enabled=false cannot be combined with browser category + settings. + """ + + +class Viewport(BrowserViewport, total=False): + """Viewport configuration to apply to the browser session.""" + + force: bool + """ + If true, allow the viewport change even when a live view or recording/replay is + active. Active recordings will be gracefully stopped and restarted at the new + resolution as separate segments. If false (default), the resize is refused when + a live view or recording is active. + """ diff --git a/src/kernel/types/browser_update_response.py b/src/kernel/types/browser_update_response.py new file mode 100644 index 00000000..5a7c7383 --- /dev/null +++ b/src/kernel/types/browser_update_response.py @@ -0,0 +1,114 @@ +# File generated from our OpenAPI spec by Stainless. See CONTRIBUTING.md for details. + +from typing import Dict, Optional +from datetime import datetime + +from .tags import Tags +from .profile import Profile +from .._models import BaseModel +from .browser_usage import BrowserUsage +from .browser_pool_ref import BrowserPoolRef +from .shared.browser_viewport import BrowserViewport +from .browsers.browser_telemetry_config import BrowserTelemetryConfig + +__all__ = ["BrowserUpdateResponse"] + + +class BrowserUpdateResponse(BaseModel): + cdp_ws_url: str + """Websocket URL for Chrome DevTools Protocol connections to the browser session""" + + created_at: datetime + """When the browser session was created.""" + + headless: bool + """Whether the browser session is running in headless mode.""" + + session_id: str + """Unique identifier for the browser session""" + + stealth: bool + """Whether the browser session is running in stealth mode.""" + + timeout_seconds: int + """The number of seconds of inactivity before the browser session is terminated.""" + + webdriver_ws_url: str + """Websocket URL for WebDriver BiDi connections to the browser session""" + + base_url: Optional[str] = None + """Metro-API HTTP base URL for this browser session.""" + + browser_live_view_url: Optional[str] = None + """Remote URL for live viewing the browser session. + + Only available for non-headless browsers. + """ + + chrome_policy: Optional[Dict[str, object]] = None + """ + Custom Chrome enterprise policy overrides that were applied to this browser + session, if any. Echoed back for verification. Keys are Chrome enterprise policy + names. + """ + + deleted_at: Optional[datetime] = None + """When the browser session was soft-deleted. Only present for deleted sessions.""" + + gpu: Optional[bool] = None + """ + Whether GPU acceleration is enabled for the browser session (only supported for + headful sessions). + """ + + kiosk_mode: Optional[bool] = None + """Whether the browser session is running in kiosk mode.""" + + name: Optional[str] = None + """Human-readable name of the browser session, if one was set at creation.""" + + pool: Optional[BrowserPoolRef] = None + """Browser pool this session was acquired from, if any.""" + + profile: Optional[Profile] = None + """Browser profile metadata.""" + + proxy_id: Optional[str] = None + """ID of the proxy associated with this browser session, if any.""" + + start_url: Optional[str] = None + """URL the session was asked to navigate to on creation, if any. + + Recorded for debugging. Navigation is fire-and-forget — the URL is dispatched to + the browser without waiting for it to load, and any errors (DNS failure, bad + status, timeout) are silently dropped. Captures what was requested, not what the + browser actually loaded. + """ + + tags: Optional[Tags] = None + """User-defined key-value tags that were set on this browser session, if any. + + Echoed back when present. + """ + + telemetry: Optional[BrowserTelemetryConfig] = None + """Active telemetry configuration for the session, if any.""" + + usage: Optional[BrowserUsage] = None + """Session usage metrics.""" + + viewport: Optional[BrowserViewport] = None + """ + Initial browser window size in pixels with optional refresh rate. If omitted, + image defaults apply (1920x1080@25). For GPU images, the default is + 1920x1080@60. Arbitrary viewport dimensions and refresh rates are accepted. + Known-good presets include: 2560x1440@10, 1920x1080@25, 1920x1200@25, + 1440x900@25, 1280x800@60, 1024x768@60, 1200x800@60, 768x1024@60, 390x844@60. For + GPU images, recommended presets use one of these resolutions with refresh rates + 60, 30, 25, or 10: 800x600, 960x720, 1024x576, 1024x768, 1152x648, 1200x800, + 1280x720, 1368x768, 1440x900, 1600x900, 1920x1080, 1920x1200, 390x844, 360x250, + 768x1024, 800x1600. Viewports outside this list may exhibit unstable live view + or recording behavior. If refresh_rate is not provided, it will be automatically + determined based on the resolution (higher resolutions use lower refresh rates + to keep bandwidth reasonable). + """ diff --git a/src/kernel/types/browser_usage.py b/src/kernel/types/browser_usage.py new file mode 100644 index 00000000..72bde9b6 --- /dev/null +++ b/src/kernel/types/browser_usage.py @@ -0,0 +1,12 @@ +# File generated from our OpenAPI spec by Stainless. See CONTRIBUTING.md for details. + +from .._models import BaseModel + +__all__ = ["BrowserUsage"] + + +class BrowserUsage(BaseModel): + """Session usage metrics.""" + + uptime_ms: int + """Time in milliseconds the session was actively running.""" diff --git a/src/kernel/types/browsers/__init__.py b/src/kernel/types/browsers/__init__.py new file mode 100644 index 00000000..990e8e19 --- /dev/null +++ b/src/kernel/types/browsers/__init__.py @@ -0,0 +1,107 @@ +# File generated from our OpenAPI spec by Stainless. See CONTRIBUTING.md for details. + +from __future__ import annotations + +from .f_move_params import FMoveParams as FMoveParams +from .f_upload_params import FUploadParams as FUploadParams +from .log_stream_params import LogStreamParams as LogStreamParams +from .browser_call_stack import BrowserCallStack as BrowserCallStack +from .f_file_info_params import FFileInfoParams as FFileInfoParams +from .f_read_file_params import FReadFileParams as FReadFileParams +from .f_list_files_params import FListFilesParams as FListFilesParams +from .f_upload_zip_params import FUploadZipParams as FUploadZipParams +from .f_write_file_params import FWriteFileParams as FWriteFileParams +from .process_exec_params import ProcessExecParams as ProcessExecParams +from .process_kill_params import ProcessKillParams as ProcessKillParams +from .replay_start_params import ReplayStartParams as ReplayStartParams +from .browser_event_source import BrowserEventSource as BrowserEventSource +from .browser_http_headers import BrowserHTTPHeaders as BrowserHTTPHeaders +from .f_delete_file_params import FDeleteFileParams as FDeleteFileParams +from .f_file_info_response import FFileInfoResponse as FFileInfoResponse +from .process_spawn_params import ProcessSpawnParams as ProcessSpawnParams +from .process_stdin_params import ProcessStdinParams as ProcessStdinParams +from .replay_list_response import ReplayListResponse as ReplayListResponse +from .browser_event_context import BrowserEventContext as BrowserEventContext +from .computer_batch_params import ComputerBatchParams as ComputerBatchParams +from .f_list_files_response import FListFilesResponse as FListFilesResponse +from .process_exec_response import ProcessExecResponse as ProcessExecResponse +from .process_kill_response import ProcessKillResponse as ProcessKillResponse +from .process_resize_params import ProcessResizeParams as ProcessResizeParams +from .replay_start_response import ReplayStartResponse as ReplayStartResponse +from .browser_api_call_event import BrowserAPICallEvent as BrowserAPICallEvent +from .browser_page_lcp_event import BrowserPageLcpEvent as BrowserPageLcpEvent +from .computer_scroll_params import ComputerScrollParams as ComputerScrollParams +from .process_spawn_response import ProcessSpawnResponse as ProcessSpawnResponse +from .process_stdin_response import ProcessStdinResponse as ProcessStdinResponse +from .browser_page_load_event import BrowserPageLoadEvent as BrowserPageLoadEvent +from .browser_telemetry_event import BrowserTelemetryEvent as BrowserTelemetryEvent +from .process_resize_response import ProcessResizeResponse as ProcessResizeResponse +from .process_status_response import ProcessStatusResponse as ProcessStatusResponse +from .telemetry_events_params import TelemetryEventsParams as TelemetryEventsParams +from .telemetry_stream_params import TelemetryStreamParams as TelemetryStreamParams +from .browser_telemetry_config import BrowserTelemetryConfig as BrowserTelemetryConfig +from .browser_cdp_connect_event import BrowserCdpConnectEvent as BrowserCdpConnectEvent +from .browser_console_log_event import BrowserConsoleLogEvent as BrowserConsoleLogEvent +from .computer_press_key_params import ComputerPressKeyParams as ComputerPressKeyParams +from .computer_type_text_params import ComputerTypeTextParams as ComputerTypeTextParams +from .f_create_directory_params import FCreateDirectoryParams as FCreateDirectoryParams +from .f_delete_directory_params import FDeleteDirectoryParams as FDeleteDirectoryParams +from .f_download_dir_zip_params import FDownloadDirZipParams as FDownloadDirZipParams +from .playwright_execute_params import PlaywrightExecuteParams as PlaywrightExecuteParams +from .telemetry_events_response import TelemetryEventsResponse as TelemetryEventsResponse +from .telemetry_stream_response import TelemetryStreamResponse as TelemetryStreamResponse +from .browser_network_idle_event import BrowserNetworkIdleEvent as BrowserNetworkIdleEvent +from .computer_drag_mouse_params import ComputerDragMouseParams as ComputerDragMouseParams +from .computer_move_mouse_params import ComputerMoveMouseParams as ComputerMoveMouseParams +from .browser_console_error_event import BrowserConsoleErrorEvent as BrowserConsoleErrorEvent +from .computer_click_mouse_params import ComputerClickMouseParams as ComputerClickMouseParams +from .playwright_execute_response import PlaywrightExecuteResponse as PlaywrightExecuteResponse +from .browser_cdp_disconnect_event import BrowserCdpDisconnectEvent as BrowserCdpDisconnectEvent +from .browser_interaction_key_event import BrowserInteractionKeyEvent as BrowserInteractionKeyEvent +from .browser_network_request_event import BrowserNetworkRequestEvent as BrowserNetworkRequestEvent +from .browser_page_navigation_event import BrowserPageNavigationEvent as BrowserPageNavigationEvent +from .browser_page_tab_opened_event import BrowserPageTabOpenedEvent as BrowserPageTabOpenedEvent +from .browser_service_crashed_event import BrowserServiceCrashedEvent as BrowserServiceCrashedEvent +from .browser_system_oom_kill_event import BrowserSystemOomKillEvent as BrowserSystemOomKillEvent +from .f_set_file_permissions_params import FSetFilePermissionsParams as FSetFilePermissionsParams +from .browser_network_response_event import BrowserNetworkResponseEvent as BrowserNetworkResponseEvent +from .process_stdout_stream_response import ProcessStdoutStreamResponse as ProcessStdoutStreamResponse +from .browser_interaction_click_event import BrowserInteractionClickEvent as BrowserInteractionClickEvent +from .browser_live_view_connect_event import BrowserLiveViewConnectEvent as BrowserLiveViewConnectEvent +from .browser_page_layout_shift_event import BrowserPageLayoutShiftEvent as BrowserPageLayoutShiftEvent +from .computer_write_clipboard_params import ComputerWriteClipboardParams as ComputerWriteClipboardParams +from .browser_monitor_screenshot_event import BrowserMonitorScreenshotEvent as BrowserMonitorScreenshotEvent +from .computer_read_clipboard_response import ComputerReadClipboardResponse as ComputerReadClipboardResponse +from .browser_monitor_init_failed_event import BrowserMonitorInitFailedEvent as BrowserMonitorInitFailedEvent +from .browser_monitor_reconnected_event import BrowserMonitorReconnectedEvent as BrowserMonitorReconnectedEvent +from .browser_page_layout_settled_event import BrowserPageLayoutSettledEvent as BrowserPageLayoutSettledEvent +from .browser_telemetry_category_config import BrowserTelemetryCategoryConfig as BrowserTelemetryCategoryConfig +from .browser_captcha_solve_result_event import BrowserCaptchaSolveResultEvent as BrowserCaptchaSolveResultEvent +from .browser_live_view_disconnect_event import BrowserLiveViewDisconnectEvent as BrowserLiveViewDisconnectEvent +from .browser_monitor_disconnected_event import BrowserMonitorDisconnectedEvent as BrowserMonitorDisconnectedEvent +from .computer_capture_screenshot_params import ComputerCaptureScreenshotParams as ComputerCaptureScreenshotParams +from .browser_telemetry_categories_config import BrowserTelemetryCategoriesConfig as BrowserTelemetryCategoriesConfig +from .browser_network_loading_failed_event import BrowserNetworkLoadingFailedEvent as BrowserNetworkLoadingFailedEvent +from .computer_get_mouse_position_response import ComputerGetMousePositionResponse as ComputerGetMousePositionResponse +from .browser_page_dom_content_loaded_event import BrowserPageDomContentLoadedEvent as BrowserPageDomContentLoadedEvent +from .browser_page_navigation_settled_event import ( + BrowserPageNavigationSettledEvent as BrowserPageNavigationSettledEvent, +) +from .computer_set_cursor_visibility_params import ( + ComputerSetCursorVisibilityParams as ComputerSetCursorVisibilityParams, +) +from .browser_monitor_reconnect_failed_event import ( + BrowserMonitorReconnectFailedEvent as BrowserMonitorReconnectFailedEvent, +) +from .browser_telemetry_category_config_param import ( + BrowserTelemetryCategoryConfigParam as BrowserTelemetryCategoryConfigParam, +) +from .computer_set_cursor_visibility_response import ( + ComputerSetCursorVisibilityResponse as ComputerSetCursorVisibilityResponse, +) +from .browser_interaction_scroll_settled_event import ( + BrowserInteractionScrollSettledEvent as BrowserInteractionScrollSettledEvent, +) +from .browser_telemetry_categories_config_param import ( + BrowserTelemetryCategoriesConfigParam as BrowserTelemetryCategoriesConfigParam, +) diff --git a/src/kernel/types/browsers/browser_api_call_event.py b/src/kernel/types/browsers/browser_api_call_event.py new file mode 100644 index 00000000..702596d8 --- /dev/null +++ b/src/kernel/types/browsers/browser_api_call_event.py @@ -0,0 +1,42 @@ +# 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 +from .browser_event_source import BrowserEventSource + +__all__ = ["BrowserAPICallEvent", "Data"] + + +class Data(BaseModel): + duration_ms: float + """Wall-clock duration of the handler in milliseconds.""" + + operation_id: str + """OpenAPI operationId of the matched route (e.g. processExec, takeScreenshot).""" + + request_id: str + """Per-request identifier from the in-VM API request middleware.""" + + status: int + """HTTP response status code.""" + + +class BrowserAPICallEvent(BaseModel): + """An agent-driven HTTP call handled by the in-VM API server.""" + + category: Literal["control"] + + source: BrowserEventSource + """Provenance metadata identifying which producer emitted the event.""" + + ts: int + """Event timestamp in Unix microseconds.""" + + type: Literal["api_call"] + + data: Optional[Data] = None + + truncated: Optional[bool] = None + """True if the data field was truncated due to size limits.""" diff --git a/src/kernel/types/browsers/browser_call_stack.py b/src/kernel/types/browsers/browser_call_stack.py new file mode 100644 index 00000000..d9e97ffd --- /dev/null +++ b/src/kernel/types/browsers/browser_call_stack.py @@ -0,0 +1,43 @@ +# File generated from our OpenAPI spec by Stainless. See CONTRIBUTING.md for details. + +from __future__ import annotations + +from typing import List, Optional + +from pydantic import Field as FieldInfo + +from ..._models import BaseModel + +__all__ = ["BrowserCallStack", "CallFrame"] + + +class CallFrame(BaseModel): + column_number: int = FieldInfo(alias="columnNumber") + """Zero-based column number within the line.""" + + function_name: str = FieldInfo(alias="functionName") + """JavaScript function name, or empty string for anonymous functions.""" + + line_number: int = FieldInfo(alias="lineNumber") + """Zero-based line number within the script.""" + + script_id: str = FieldInfo(alias="scriptId") + """CDP script identifier.""" + + url: str + """URL or name of the script file.""" + + +class BrowserCallStack(BaseModel): + """ + CDP Runtime.StackTrace representing the JavaScript call stack at the time of an event. Fields use CDP naming conventions rather than snake_case to match the Chrome DevTools Protocol wire format. + """ + + call_frames: List[CallFrame] = FieldInfo(alias="callFrames") + """Ordered list of call frames, outermost first.""" + + description: Optional[str] = None + """Optional label for the stack trace (e.g. async cause).""" + + parent: Optional["BrowserCallStack"] = None + """Parent stack trace for async stacks.""" diff --git a/src/kernel/types/browsers/browser_captcha_solve_result_event.py b/src/kernel/types/browsers/browser_captcha_solve_result_event.py new file mode 100644 index 00000000..fb8476d4 --- /dev/null +++ b/src/kernel/types/browsers/browser_captcha_solve_result_event.py @@ -0,0 +1,63 @@ +# 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 +from .browser_event_source import BrowserEventSource + +__all__ = ["BrowserCaptchaSolveResultEvent", "Data"] + + +class Data(BaseModel): + captcha_type: Literal["hcaptcha", "recaptcha_v2", "recaptcha_v3", "turnstile", "geetest", "other"] + """Captcha vendor family. + + Provider-specific task names are normalized into this set; anything not covered + is reported as other. + """ + + duration_ms: float + """Wall-clock duration from solve start to terminal outcome.""" + + status: Literal["success", "failure", "timeout", "abandoned"] + """Terminal outcome. + + success: solver returned a usable solution. failure: solver returned an error + (see error_code). timeout: solver did not return within the caller's wait + budget. abandoned: caller cancelled or the page navigated away mid-solve. + """ + + error_code: Optional[str] = None + """Solver-specific error code on failure (e.g. + + ERROR_CAPTCHA_UNSOLVABLE). Absent on success. + """ + + task_id: Optional[str] = None + """Solver-assigned identifier. Opaque, useful for support cross-references.""" + + website_host: Optional[str] = None + """Host of the page where the captcha was solved.""" + + website_path: Optional[str] = None + """Path of the page where the captcha was solved. Query string excluded.""" + + +class BrowserCaptchaSolveResultEvent(BaseModel): + """A captcha solve attempt reached a terminal outcome.""" + + category: Literal["captcha"] + + source: BrowserEventSource + """Provenance metadata identifying which producer emitted the event.""" + + ts: int + """Event timestamp in Unix microseconds.""" + + type: Literal["captcha_solve_result"] + + data: Optional[Data] = None + + truncated: Optional[bool] = None + """True if the data field was truncated due to size limits.""" diff --git a/src/kernel/types/browsers/browser_cdp_connect_event.py b/src/kernel/types/browsers/browser_cdp_connect_event.py new file mode 100644 index 00000000..ebbc7fa3 --- /dev/null +++ b/src/kernel/types/browsers/browser_cdp_connect_event.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 ..._models import BaseModel +from .browser_event_source import BrowserEventSource + +__all__ = ["BrowserCdpConnectEvent"] + + +class BrowserCdpConnectEvent(BaseModel): + """An external client (e.g. + + customer SDK, Playwright, Puppeteer) connected to the CDP WebSocket proxy on this VM. + """ + + category: Literal["connection"] + + source: BrowserEventSource + """Provenance metadata identifying which producer emitted the event.""" + + ts: int + """Event timestamp in Unix microseconds.""" + + type: Literal["cdp_connect"] + + truncated: Optional[bool] = None + """True if the data field was truncated due to size limits.""" diff --git a/src/kernel/types/browsers/browser_cdp_disconnect_event.py b/src/kernel/types/browsers/browser_cdp_disconnect_event.py new file mode 100644 index 00000000..d9260ebc --- /dev/null +++ b/src/kernel/types/browsers/browser_cdp_disconnect_event.py @@ -0,0 +1,49 @@ +# 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 +from .browser_event_source import BrowserEventSource + +__all__ = ["BrowserCdpDisconnectEvent", "Data"] + + +class Data(BaseModel): + duration_ms: float + """Wall-clock duration of the connection in milliseconds.""" + + message_count: int + """Number of CDP messages relayed across the connection in either direction.""" + + reason: Literal["client_close", "upstream_changed", "upstream_error", "context_cancelled"] + """Why the connection ended. + + client_close: the client initiated the close. upstream_changed: Chromium + restarted mid-session and the proxy tore down so the client could reconnect + against the new upstream. upstream_error: upstream dial or message pump errored. + context_cancelled: the request context was cancelled (typically server + shutdown). + """ + + +class BrowserCdpDisconnectEvent(BaseModel): + """An external client disconnected from the CDP WebSocket proxy on this VM. + + Pair with the immediately preceding cdp_connect on the same stream. + """ + + category: Literal["connection"] + + source: BrowserEventSource + """Provenance metadata identifying which producer emitted the event.""" + + ts: int + """Event timestamp in Unix microseconds.""" + + type: Literal["cdp_disconnect"] + + data: Optional[Data] = None + + truncated: Optional[bool] = None + """True if the data field was truncated due to size limits.""" diff --git a/src/kernel/types/browsers/browser_console_error_event.py b/src/kernel/types/browsers/browser_console_error_event.py new file mode 100644 index 00000000..ef1c8949 --- /dev/null +++ b/src/kernel/types/browsers/browser_console_error_event.py @@ -0,0 +1,94 @@ +# File generated from our OpenAPI spec by Stainless. See CONTRIBUTING.md for details. + +from __future__ import annotations + +from typing import List, Optional +from typing_extensions import Literal + +from ..._models import BaseModel +from .browser_event_source import BrowserEventSource +from .browser_event_context import BrowserEventContext + +__all__ = ["BrowserConsoleErrorEvent", "Data"] + + +class Data(BrowserEventContext): + """Browser event context stamped by the browser monitor onto all CDP-sourced events. + + Identifies the target, frame, and navigation epoch in which the event occurred. + """ + + text: str + """Human-readable error text, as the browser console would display it. + + For console.error() calls, the first argument coerced to a string. For uncaught + exceptions, the prefix and error message, e.g. "Uncaught Error: boom" or + "Uncaught (in promise) TypeError: x is not a function". + """ + + args: Optional[List[str]] = None + """All console arguments coerced to strings. + + Present only when sourced from Runtime.consoleAPICalled. + """ + + column: Optional[int] = None + """Column number in the script where the exception was thrown. + + Present only when sourced from Runtime.exceptionThrown. + """ + + level: Optional[str] = None + """CDP console type value, always "error". + + Present only when sourced from Runtime.consoleAPICalled. + """ + + line: Optional[int] = None + """Line number in the script where the exception was thrown. + + Present only when sourced from Runtime.exceptionThrown. + """ + + source_url: Optional[str] = None + """URL of the script file that threw the exception. + + Present only when sourced from Runtime.exceptionThrown. + """ + + stack_trace: Optional["BrowserCallStack"] = None + """ + CDP Runtime.StackTrace representing the JavaScript call stack at the time of an + event. Fields use CDP naming conventions rather than snake_case to match the + Chrome DevTools Protocol wire format. + """ + + +class BrowserConsoleErrorEvent(BaseModel): + """A browser console error or uncaught JavaScript exception event. + + Emitted from two distinct CDP sources with different data shapes. Runtime.consoleAPICalled (console.error calls) produces level, text, args, and stack_trace. Runtime.exceptionThrown (uncaught exceptions) produces text, line, column, source_url, and stack_trace. Fields not applicable to the source are absent. + """ + + category: Literal["console"] + + source: BrowserEventSource + """Provenance metadata identifying which producer emitted the event.""" + + ts: int + """Event timestamp in Unix microseconds.""" + + type: Literal["console_error"] + + data: Optional[Data] = None + """Browser event context stamped by the browser monitor onto all CDP-sourced + events. + + Identifies the target, frame, and navigation epoch in which the event occurred. + """ + + truncated: Optional[bool] = None + """True if the data field was truncated due to size limits.""" + + +from .browser_call_stack import BrowserCallStack diff --git a/src/kernel/types/browsers/browser_console_log_event.py b/src/kernel/types/browsers/browser_console_log_event.py new file mode 100644 index 00000000..338f6c85 --- /dev/null +++ b/src/kernel/types/browsers/browser_console_log_event.py @@ -0,0 +1,66 @@ +# File generated from our OpenAPI spec by Stainless. See CONTRIBUTING.md for details. + +from __future__ import annotations + +from typing import List, Optional +from typing_extensions import Literal + +from ..._models import BaseModel +from .browser_event_source import BrowserEventSource +from .browser_event_context import BrowserEventContext + +__all__ = ["BrowserConsoleLogEvent", "Data"] + + +class Data(BrowserEventContext): + """Browser event context stamped by the browser monitor onto all CDP-sourced events. + + Identifies the target, frame, and navigation epoch in which the event occurred. + """ + + args: Optional[List[str]] = None + """All console arguments coerced to strings.""" + + level: Optional[str] = None + """CDP Runtime.consoleAPICalled type, passed through unfiltered from Chrome. + + error is routed to console_error events instead; all other CDP console types + appear here. See CDP spec for the full enum. + """ + + stack_trace: Optional["BrowserCallStack"] = None + """ + CDP Runtime.StackTrace representing the JavaScript call stack at the time of an + event. Fields use CDP naming conventions rather than snake_case to match the + Chrome DevTools Protocol wire format. + """ + + text: Optional[str] = None + """First console argument coerced to string.""" + + +class BrowserConsoleLogEvent(BaseModel): + """A browser console log event (console.log, console.info, console.warn, etc.).""" + + category: Literal["console"] + + source: BrowserEventSource + """Provenance metadata identifying which producer emitted the event.""" + + ts: int + """Event timestamp in Unix microseconds.""" + + type: Literal["console_log"] + + data: Optional[Data] = None + """Browser event context stamped by the browser monitor onto all CDP-sourced + events. + + Identifies the target, frame, and navigation epoch in which the event occurred. + """ + + truncated: Optional[bool] = None + """True if the data field was truncated due to size limits.""" + + +from .browser_call_stack import BrowserCallStack diff --git a/src/kernel/types/browsers/browser_event_context.py b/src/kernel/types/browsers/browser_event_context.py new file mode 100644 index 00000000..6f2e9549 --- /dev/null +++ b/src/kernel/types/browsers/browser_event_context.py @@ -0,0 +1,42 @@ +# 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__ = ["BrowserEventContext"] + + +class BrowserEventContext(BaseModel): + """Browser event context stamped by the browser monitor onto all CDP-sourced events. + + Identifies the target, frame, and navigation epoch in which the event occurred. + """ + + frame_id: Optional[str] = None + """CDP frame identifier within the target.""" + + loader_id: Optional[str] = None + """CDP document loader identifier, reset on each navigation.""" + + nav_seq: Optional[int] = None + """ + Monotonically increasing navigation sequence number, incremented on each + top-level navigation within the target. + """ + + session_id: Optional[str] = None + """CDP session identifier for the target connection.""" + + target_id: Optional[str] = None + """Browser target identifier (stable across navigations within a tab).""" + + target_type: Optional[Literal["page", "background_page", "service_worker", "shared_worker", "other"]] = None + """CDP target type of the page that produced the event.""" + + url: Optional[str] = None + """ + URL relevant to this event — page URL for navigation and page events, request + URL for network events. + """ diff --git a/src/kernel/types/browsers/browser_event_source.py b/src/kernel/types/browsers/browser_event_source.py new file mode 100644 index 00000000..40125b6b --- /dev/null +++ b/src/kernel/types/browsers/browser_event_source.py @@ -0,0 +1,30 @@ +# 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__ = ["BrowserEventSource"] + + +class BrowserEventSource(BaseModel): + """Provenance metadata identifying which producer emitted the event.""" + + kind: Literal["cdp", "kernel_api", "extension", "local_process"] + """Event producer. + + cdp: Chrome DevTools Protocol events from the browser. kernel_api: Kernel API + server. extension: injected Chrome extension. local_process: system process + running alongside the browser. + """ + + event: Optional[str] = None + """Producer-specific event name (e.g. + + Runtime.consoleAPICalled for CDP-sourced console events, Runtime.exceptionThrown + for uncaught exceptions). + """ + + metadata: Optional[Dict[str, str]] = None + """Producer-specific context (e.g. CDP target/session/frame IDs).""" diff --git a/src/kernel/types/browsers/browser_http_headers.py b/src/kernel/types/browsers/browser_http_headers.py new file mode 100644 index 00000000..b55ae855 --- /dev/null +++ b/src/kernel/types/browsers/browser_http_headers.py @@ -0,0 +1,8 @@ +# File generated from our OpenAPI spec by Stainless. See CONTRIBUTING.md for details. + +from typing import Dict +from typing_extensions import TypeAlias + +__all__ = ["BrowserHTTPHeaders"] + +BrowserHTTPHeaders: TypeAlias = Dict[str, object] diff --git a/src/kernel/types/browsers/browser_interaction_click_event.py b/src/kernel/types/browsers/browser_interaction_click_event.py new file mode 100644 index 00000000..7c4f6119 --- /dev/null +++ b/src/kernel/types/browsers/browser_interaction_click_event.py @@ -0,0 +1,56 @@ +# 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 +from .browser_event_source import BrowserEventSource +from .browser_event_context import BrowserEventContext + +__all__ = ["BrowserInteractionClickEvent", "Data"] + + +class Data(BrowserEventContext): + """Browser event context stamped by the browser monitor onto all CDP-sourced events. + + Identifies the target, frame, and navigation epoch in which the event occurred. + """ + + selector: Optional[str] = None + """CSS selector path to the clicked element.""" + + tag: Optional[str] = None + """HTML tag name of the clicked element in uppercase (e.g. BUTTON, A, DIV).""" + + text: Optional[str] = None + """Visible text content of the clicked element, trimmed.""" + + x: Optional[int] = None + """Viewport x-coordinate of the click in CSS pixels.""" + + y: Optional[int] = None + """Viewport y-coordinate of the click in CSS pixels.""" + + +class BrowserInteractionClickEvent(BaseModel): + """A browser user click event captured via injected page script.""" + + category: Literal["interaction"] + + source: BrowserEventSource + """Provenance metadata identifying which producer emitted the event.""" + + ts: int + """Event timestamp in Unix microseconds.""" + + type: Literal["interaction_click"] + + data: Optional[Data] = None + """Browser event context stamped by the browser monitor onto all CDP-sourced + events. + + Identifies the target, frame, and navigation epoch in which the event occurred. + """ + + truncated: Optional[bool] = None + """True if the data field was truncated due to size limits.""" diff --git a/src/kernel/types/browsers/browser_interaction_key_event.py b/src/kernel/types/browsers/browser_interaction_key_event.py new file mode 100644 index 00000000..93f2c43e --- /dev/null +++ b/src/kernel/types/browsers/browser_interaction_key_event.py @@ -0,0 +1,50 @@ +# 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 +from .browser_event_source import BrowserEventSource +from .browser_event_context import BrowserEventContext + +__all__ = ["BrowserInteractionKeyEvent", "Data"] + + +class Data(BrowserEventContext): + """Browser event context stamped by the browser monitor onto all CDP-sourced events. + + Identifies the target, frame, and navigation epoch in which the event occurred. + """ + + key: Optional[str] = None + """Key value from the KeyboardEvent (e.g. Enter, Backspace, a).""" + + selector: Optional[str] = None + """CSS selector path to the element that had focus when the key was pressed.""" + + tag: Optional[str] = None + """HTML tag name of the focused element in uppercase (e.g. INPUT, TEXTAREA, DIV).""" + + +class BrowserInteractionKeyEvent(BaseModel): + """A browser keyboard event captured via injected page script.""" + + category: Literal["interaction"] + + source: BrowserEventSource + """Provenance metadata identifying which producer emitted the event.""" + + ts: int + """Event timestamp in Unix microseconds.""" + + type: Literal["interaction_key"] + + data: Optional[Data] = None + """Browser event context stamped by the browser monitor onto all CDP-sourced + events. + + Identifies the target, frame, and navigation epoch in which the event occurred. + """ + + truncated: Optional[bool] = None + """True if the data field was truncated due to size limits.""" diff --git a/src/kernel/types/browsers/browser_interaction_scroll_settled_event.py b/src/kernel/types/browsers/browser_interaction_scroll_settled_event.py new file mode 100644 index 00000000..2c7ee24c --- /dev/null +++ b/src/kernel/types/browsers/browser_interaction_scroll_settled_event.py @@ -0,0 +1,58 @@ +# 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 +from .browser_event_source import BrowserEventSource +from .browser_event_context import BrowserEventContext + +__all__ = ["BrowserInteractionScrollSettledEvent", "Data"] + + +class Data(BrowserEventContext): + """Browser event context stamped by the browser monitor onto all CDP-sourced events. + + Identifies the target, frame, and navigation epoch in which the event occurred. + """ + + from_x: Optional[int] = None + """Scroll x-position at the start of the scroll gesture in CSS pixels.""" + + from_y: Optional[int] = None + """Scroll y-position at the start of the scroll gesture in CSS pixels.""" + + target_selector: Optional[str] = None + """CSS selector path to the scrolled element.""" + + to_x: Optional[int] = None + """Final scroll x-position after the gesture settled in CSS pixels.""" + + to_y: Optional[int] = None + """Final scroll y-position after the gesture settled in CSS pixels.""" + + +class BrowserInteractionScrollSettledEvent(BaseModel): + """ + A browser scroll settled event emitted after scroll position stops changing, captured via injected page script. + """ + + category: Literal["interaction"] + + source: BrowserEventSource + """Provenance metadata identifying which producer emitted the event.""" + + ts: int + """Event timestamp in Unix microseconds.""" + + type: Literal["interaction_scroll_settled"] + + data: Optional[Data] = None + """Browser event context stamped by the browser monitor onto all CDP-sourced + events. + + Identifies the target, frame, and navigation epoch in which the event occurred. + """ + + truncated: Optional[bool] = None + """True if the data field was truncated due to size limits.""" diff --git a/src/kernel/types/browsers/browser_live_view_connect_event.py b/src/kernel/types/browsers/browser_live_view_connect_event.py new file mode 100644 index 00000000..620e3d36 --- /dev/null +++ b/src/kernel/types/browsers/browser_live_view_connect_event.py @@ -0,0 +1,40 @@ +# 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 +from .browser_event_source import BrowserEventSource + +__all__ = ["BrowserLiveViewConnectEvent", "Data"] + + +class Data(BaseModel): + session_id: str + """Live view session identifier. + + Stable across reconnects, so a transient network blip can emit two events with + the same session_id. + """ + + +class BrowserLiveViewConnectEvent(BaseModel): + """A live view client connected to the headful browser's WebRTC server. + + Headful only; not emitted for headless images. + """ + + category: Literal["connection"] + + source: BrowserEventSource + """Provenance metadata identifying which producer emitted the event.""" + + ts: int + """Event timestamp in Unix microseconds.""" + + type: Literal["live_view_connect"] + + data: Optional[Data] = None + + truncated: Optional[bool] = None + """True if the data field was truncated due to size limits.""" diff --git a/src/kernel/types/browsers/browser_live_view_disconnect_event.py b/src/kernel/types/browsers/browser_live_view_disconnect_event.py new file mode 100644 index 00000000..a847d34c --- /dev/null +++ b/src/kernel/types/browsers/browser_live_view_disconnect_event.py @@ -0,0 +1,41 @@ +# 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 +from .browser_event_source import BrowserEventSource + +__all__ = ["BrowserLiveViewDisconnectEvent", "Data"] + + +class Data(BaseModel): + duration_ms: float + """Wall-clock duration of the connection in milliseconds.""" + + session_id: str + """ + Live view session identifier; matches the corresponding live_view_connect event. + """ + + +class BrowserLiveViewDisconnectEvent(BaseModel): + """A live view client disconnected from the headful browser's WebRTC server. + + Pair with live_view_connect by session_id. + """ + + category: Literal["connection"] + + source: BrowserEventSource + """Provenance metadata identifying which producer emitted the event.""" + + ts: int + """Event timestamp in Unix microseconds.""" + + type: Literal["live_view_disconnect"] + + data: Optional[Data] = None + + truncated: Optional[bool] = None + """True if the data field was truncated due to size limits.""" diff --git a/src/kernel/types/browsers/browser_monitor_disconnected_event.py b/src/kernel/types/browsers/browser_monitor_disconnected_event.py new file mode 100644 index 00000000..2ea3afeb --- /dev/null +++ b/src/kernel/types/browsers/browser_monitor_disconnected_event.py @@ -0,0 +1,36 @@ +# 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 +from .browser_event_source import BrowserEventSource + +__all__ = ["BrowserMonitorDisconnectedEvent", "Data"] + + +class Data(BaseModel): + reason: Optional[Literal["chrome_restarted"]] = None + """Reason for the disconnection. chrome_restarted: Chrome process restarted.""" + + +class BrowserMonitorDisconnectedEvent(BaseModel): + """The CDP connection to Chrome was lost. + + Telemetry events may be dropped until monitor_reconnected arrives. Treat any in-progress computed state (network_idle, page_layout_settled) as unreliable until then. + """ + + category: Literal["monitor"] + + source: BrowserEventSource + """Provenance metadata identifying which producer emitted the event.""" + + ts: int + """Event timestamp in Unix microseconds.""" + + type: Literal["monitor_disconnected"] + + data: Optional[Data] = None + + truncated: Optional[bool] = None + """True if the data field was truncated due to size limits.""" diff --git a/src/kernel/types/browsers/browser_monitor_init_failed_event.py b/src/kernel/types/browsers/browser_monitor_init_failed_event.py new file mode 100644 index 00000000..c3a7cfdb --- /dev/null +++ b/src/kernel/types/browsers/browser_monitor_init_failed_event.py @@ -0,0 +1,33 @@ +# 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 +from .browser_event_source import BrowserEventSource + +__all__ = ["BrowserMonitorInitFailedEvent", "Data"] + + +class Data(BaseModel): + step: Optional[str] = None + """The CDP method or initialization step that failed (e.g. Target.setAutoAttach).""" + + +class BrowserMonitorInitFailedEvent(BaseModel): + """The CDP session could not be initialized.""" + + category: Literal["monitor"] + + source: BrowserEventSource + """Provenance metadata identifying which producer emitted the event.""" + + ts: int + """Event timestamp in Unix microseconds.""" + + type: Literal["monitor_init_failed"] + + data: Optional[Data] = None + + truncated: Optional[bool] = None + """True if the data field was truncated due to size limits.""" diff --git a/src/kernel/types/browsers/browser_monitor_reconnect_failed_event.py b/src/kernel/types/browsers/browser_monitor_reconnect_failed_event.py new file mode 100644 index 00000000..c71bf530 --- /dev/null +++ b/src/kernel/types/browsers/browser_monitor_reconnect_failed_event.py @@ -0,0 +1,39 @@ +# 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 +from .browser_event_source import BrowserEventSource + +__all__ = ["BrowserMonitorReconnectFailedEvent", "Data"] + + +class Data(BaseModel): + reason: Optional[Literal["reconnect_exhausted"]] = None + """Reason for the reconnection failure. + + reconnect_exhausted: all retry attempts were used up without successfully + restoring the CDP connection. + """ + + +class BrowserMonitorReconnectFailedEvent(BaseModel): + """ + The CDP connection to Chrome could not be re-established after exhausting all reconnection attempts. No further telemetry events will arrive on this session. + """ + + category: Literal["monitor"] + + source: BrowserEventSource + """Provenance metadata identifying which producer emitted the event.""" + + ts: int + """Event timestamp in Unix microseconds.""" + + type: Literal["monitor_reconnect_failed"] + + data: Optional[Data] = None + + truncated: Optional[bool] = None + """True if the data field was truncated due to size limits.""" diff --git a/src/kernel/types/browsers/browser_monitor_reconnected_event.py b/src/kernel/types/browsers/browser_monitor_reconnected_event.py new file mode 100644 index 00000000..c27340c6 --- /dev/null +++ b/src/kernel/types/browsers/browser_monitor_reconnected_event.py @@ -0,0 +1,35 @@ +# 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 +from .browser_event_source import BrowserEventSource + +__all__ = ["BrowserMonitorReconnectedEvent", "Data"] + + +class Data(BaseModel): + reconnect_duration_ms: Optional[int] = None + """Wall-clock time in milliseconds taken to reconnect after the disconnection.""" + + +class BrowserMonitorReconnectedEvent(BaseModel): + """ + The CDP connection to Chrome was successfully re-established after a disconnection. Events emitted during the gap are lost. Computed state is reset, so navigation and network tracking restart fresh from this point. + """ + + category: Literal["monitor"] + + source: BrowserEventSource + """Provenance metadata identifying which producer emitted the event.""" + + ts: int + """Event timestamp in Unix microseconds.""" + + type: Literal["monitor_reconnected"] + + data: Optional[Data] = None + + truncated: Optional[bool] = None + """True if the data field was truncated due to size limits.""" diff --git a/src/kernel/types/browsers/browser_monitor_screenshot_event.py b/src/kernel/types/browsers/browser_monitor_screenshot_event.py new file mode 100644 index 00000000..02135b47 --- /dev/null +++ b/src/kernel/types/browsers/browser_monitor_screenshot_event.py @@ -0,0 +1,33 @@ +# 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 +from .browser_event_source import BrowserEventSource + +__all__ = ["BrowserMonitorScreenshotEvent", "Data"] + + +class Data(BaseModel): + png: Optional[str] = None + """Base64-encoded PNG screenshot of the browser viewport.""" + + +class BrowserMonitorScreenshotEvent(BaseModel): + """A periodic screenshot of the browser viewport.""" + + category: Literal["screenshot"] + + source: BrowserEventSource + """Provenance metadata identifying which producer emitted the event.""" + + ts: int + """Event timestamp in Unix microseconds.""" + + type: Literal["monitor_screenshot"] + + data: Optional[Data] = None + + truncated: Optional[bool] = None + """True if the data field was truncated due to size limits.""" diff --git a/src/kernel/types/browsers/browser_network_idle_event.py b/src/kernel/types/browsers/browser_network_idle_event.py new file mode 100644 index 00000000..b1877115 --- /dev/null +++ b/src/kernel/types/browsers/browser_network_idle_event.py @@ -0,0 +1,36 @@ +# 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 +from .browser_event_source import BrowserEventSource +from .browser_event_context import BrowserEventContext + +__all__ = ["BrowserNetworkIdleEvent"] + + +class BrowserNetworkIdleEvent(BaseModel): + """ + A browser network idle event emitted after a 500ms quiet period with no in-flight HTTP requests. + """ + + category: Literal["network"] + + source: BrowserEventSource + """Provenance metadata identifying which producer emitted the event.""" + + ts: int + """Event timestamp in Unix microseconds.""" + + type: Literal["network_idle"] + + data: Optional[BrowserEventContext] = None + """Browser event context stamped by the browser monitor onto all CDP-sourced + events. + + Identifies the target, frame, and navigation epoch in which the event occurred. + """ + + truncated: Optional[bool] = None + """True if the data field was truncated due to size limits.""" diff --git a/src/kernel/types/browsers/browser_network_loading_failed_event.py b/src/kernel/types/browsers/browser_network_loading_failed_event.py new file mode 100644 index 00000000..ac6b7961 --- /dev/null +++ b/src/kernel/types/browsers/browser_network_loading_failed_event.py @@ -0,0 +1,60 @@ +# 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 +from .browser_event_source import BrowserEventSource +from .browser_event_context import BrowserEventContext + +__all__ = ["BrowserNetworkLoadingFailedEvent", "Data"] + + +class Data(BrowserEventContext): + """Browser event context stamped by the browser monitor onto all CDP-sourced events. + + Identifies the target, frame, and navigation epoch in which the event occurred. + """ + + canceled: Optional[bool] = None + """True if the request was canceled by the browser or page script.""" + + error_text: Optional[str] = None + """Network error description (e.g. net::ERR_CONNECTION_REFUSED).""" + + request_id: Optional[str] = None + """CDP request identifier matching the originating network_request event.""" + + resource_type: Optional[str] = None + """CDP Network.ResourceType for the request, passed through as-is from Chrome. + + Known values include Document, Fetch, XHR, Script, Stylesheet, Image, Media, + Font, TextTrack, EventSource, WebSocket, Manifest, Prefetch, Other, and more. + """ + + +class BrowserNetworkLoadingFailedEvent(BaseModel): + """A browser network loading failed event. + + If the request was already in flight when CDP attached (no prior network_request was emitted for it), url, frame_id, loader_id, and resource_type are absent; BrowserEventContext is partially populated in that case. + """ + + category: Literal["network"] + + source: BrowserEventSource + """Provenance metadata identifying which producer emitted the event.""" + + ts: int + """Event timestamp in Unix microseconds.""" + + type: Literal["network_loading_failed"] + + data: Optional[Data] = None + """Browser event context stamped by the browser monitor onto all CDP-sourced + events. + + Identifies the target, frame, and navigation epoch in which the event occurred. + """ + + truncated: Optional[bool] = None + """True if the data field was truncated due to size limits.""" diff --git a/src/kernel/types/browsers/browser_network_request_event.py b/src/kernel/types/browsers/browser_network_request_event.py new file mode 100644 index 00000000..efea950b --- /dev/null +++ b/src/kernel/types/browsers/browser_network_request_event.py @@ -0,0 +1,76 @@ +# 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 +from .browser_event_source import BrowserEventSource +from .browser_http_headers import BrowserHTTPHeaders +from .browser_event_context import BrowserEventContext + +__all__ = ["BrowserNetworkRequestEvent", "Data"] + + +class Data(BrowserEventContext): + """Browser event context stamped by the browser monitor onto all CDP-sourced events. + + Identifies the target, frame, and navigation epoch in which the event occurred. + """ + + document_url: Optional[str] = None + """URL of the document that initiated the request.""" + + headers: Optional[BrowserHTTPHeaders] = None + """Request headers.""" + + initiator_type: Optional[str] = None + """ + CDP Initiator.type indicating what caused the request, passed through as-is from + Chrome. Known values include script, parser, preload, and other. + """ + + is_redirect: Optional[bool] = None + """True if this request is the result of a redirect.""" + + method: Optional[str] = None + """HTTP method as sent on the wire (e.g. GET, POST).""" + + post_data: Optional[str] = None + """Request body for POST/PUT requests, if available.""" + + redirect_url: Optional[str] = None + """Original URL before the redirect, present when is_redirect is true.""" + + request_id: Optional[str] = None + """CDP request identifier, unique within the session.""" + + resource_type: Optional[str] = None + """CDP Network.ResourceType for the request, passed through as-is from Chrome. + + Known values include Document, Fetch, XHR, Script, Stylesheet, Image, Media, + Font, TextTrack, EventSource, WebSocket, Manifest, Prefetch, Other, and more. + """ + + +class BrowserNetworkRequestEvent(BaseModel): + """A browser network request sent event.""" + + category: Literal["network"] + + source: BrowserEventSource + """Provenance metadata identifying which producer emitted the event.""" + + ts: int + """Event timestamp in Unix microseconds.""" + + type: Literal["network_request"] + + data: Optional[Data] = None + """Browser event context stamped by the browser monitor onto all CDP-sourced + events. + + Identifies the target, frame, and navigation epoch in which the event occurred. + """ + + truncated: Optional[bool] = None + """True if the data field was truncated due to size limits.""" diff --git a/src/kernel/types/browsers/browser_network_response_event.py b/src/kernel/types/browsers/browser_network_response_event.py new file mode 100644 index 00000000..b39fefb8 --- /dev/null +++ b/src/kernel/types/browsers/browser_network_response_event.py @@ -0,0 +1,73 @@ +# 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 +from .browser_event_source import BrowserEventSource +from .browser_http_headers import BrowserHTTPHeaders +from .browser_event_context import BrowserEventContext + +__all__ = ["BrowserNetworkResponseEvent", "Data"] + + +class Data(BrowserEventContext): + """Browser event context stamped by the browser monitor onto all CDP-sourced events. + + Identifies the target, frame, and navigation epoch in which the event occurred. + """ + + body: Optional[str] = None + """Truncated response body, present only for text MIME types.""" + + headers: Optional[BrowserHTTPHeaders] = None + """Response headers.""" + + method: Optional[str] = None + """HTTP method of the original request.""" + + mime_type: Optional[str] = None + """MIME type of the response (e.g. text/html, application/json).""" + + request_id: Optional[str] = None + """CDP request identifier matching the originating network_request event.""" + + resource_type: Optional[str] = None + """CDP Network.ResourceType for the request, passed through as-is from Chrome. + + Known values include Document, Fetch, XHR, Script, Stylesheet, Image, Media, + Font, TextTrack, EventSource, WebSocket, Manifest, Prefetch, Other, and more. + """ + + status: Optional[int] = None + """HTTP response status code.""" + + status_text: Optional[str] = None + """HTTP response status text (e.g. OK, Not Found).""" + + +class BrowserNetworkResponseEvent(BaseModel): + """A browser network response received event. + + Fired after the response body is fully received, not when headers arrive. + """ + + category: Literal["network"] + + source: BrowserEventSource + """Provenance metadata identifying which producer emitted the event.""" + + ts: int + """Event timestamp in Unix microseconds.""" + + type: Literal["network_response"] + + data: Optional[Data] = None + """Browser event context stamped by the browser monitor onto all CDP-sourced + events. + + Identifies the target, frame, and navigation epoch in which the event occurred. + """ + + truncated: Optional[bool] = None + """True if the data field was truncated due to size limits.""" diff --git a/src/kernel/types/browsers/browser_page_dom_content_loaded_event.py b/src/kernel/types/browsers/browser_page_dom_content_loaded_event.py new file mode 100644 index 00000000..99393cce --- /dev/null +++ b/src/kernel/types/browsers/browser_page_dom_content_loaded_event.py @@ -0,0 +1,47 @@ +# 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 +from .browser_event_source import BrowserEventSource +from .browser_event_context import BrowserEventContext + +__all__ = ["BrowserPageDomContentLoadedEvent", "Data"] + + +class Data(BrowserEventContext): + """Browser event context stamped by the browser monitor onto all CDP-sourced events. + + Identifies the target, frame, and navigation epoch in which the event occurred. + """ + + cdp_timestamp: Optional[float] = None + """ + Chrome monotonic clock value in seconds at which DOMContentLoaded fired, + relative to browser process start (not Unix epoch). Use ts for wall-clock time. + """ + + +class BrowserPageDomContentLoadedEvent(BaseModel): + """A browser DOMContentLoaded event (CDP Page.domContentEventFired).""" + + category: Literal["page"] + + source: BrowserEventSource + """Provenance metadata identifying which producer emitted the event.""" + + ts: int + """Event timestamp in Unix microseconds.""" + + type: Literal["page_dom_content_loaded"] + + data: Optional[Data] = None + """Browser event context stamped by the browser monitor onto all CDP-sourced + events. + + Identifies the target, frame, and navigation epoch in which the event occurred. + """ + + truncated: Optional[bool] = None + """True if the data field was truncated due to size limits.""" diff --git a/src/kernel/types/browsers/browser_page_layout_settled_event.py b/src/kernel/types/browsers/browser_page_layout_settled_event.py new file mode 100644 index 00000000..f0558f12 --- /dev/null +++ b/src/kernel/types/browsers/browser_page_layout_settled_event.py @@ -0,0 +1,36 @@ +# 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 +from .browser_event_source import BrowserEventSource +from .browser_event_context import BrowserEventContext + +__all__ = ["BrowserPageLayoutSettledEvent"] + + +class BrowserPageLayoutSettledEvent(BaseModel): + """ + A browser layout settled event emitted 1 second after page load with no intervening layout shifts, indicating visual stability. Each layout shift resets the 1-second timer. + """ + + category: Literal["page"] + + source: BrowserEventSource + """Provenance metadata identifying which producer emitted the event.""" + + ts: int + """Event timestamp in Unix microseconds.""" + + type: Literal["page_layout_settled"] + + data: Optional[BrowserEventContext] = None + """Browser event context stamped by the browser monitor onto all CDP-sourced + events. + + Identifies the target, frame, and navigation epoch in which the event occurred. + """ + + truncated: Optional[bool] = None + """True if the data field was truncated due to size limits.""" diff --git a/src/kernel/types/browsers/browser_page_layout_shift_event.py b/src/kernel/types/browsers/browser_page_layout_shift_event.py new file mode 100644 index 00000000..fb578ac8 --- /dev/null +++ b/src/kernel/types/browsers/browser_page_layout_shift_event.py @@ -0,0 +1,71 @@ +# 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 +from .browser_event_source import BrowserEventSource +from .browser_event_context import BrowserEventContext + +__all__ = ["BrowserPageLayoutShiftEvent", "Data", "DataLayoutShiftDetails"] + + +class DataLayoutShiftDetails(BaseModel): + """PerformanceLayoutShift attributes from the Performance Timeline entry.""" + + had_recent_input: Optional[bool] = None + """ + True if the layout shift was preceded by user input within 500ms, excluding it + from CLS. + """ + + value: Optional[float] = None + """Layout shift score for this entry (contribution to CLS).""" + + +class Data(BrowserEventContext): + """Browser event context stamped by the browser monitor onto all CDP-sourced events. + + Identifies the target, frame, and navigation epoch in which the event occurred. + """ + + duration: Optional[float] = None + """ + Duration of the layout shift entry in milliseconds (always 0 for layout shifts + per spec). + """ + + layout_shift_details: Optional[DataLayoutShiftDetails] = None + """PerformanceLayoutShift attributes from the Performance Timeline entry.""" + + source_frame_id: Optional[str] = None + """CDP frame identifier of the frame where the layout shift occurred.""" + + time: Optional[float] = None + """Performance Timeline timestamp of the layout shift in milliseconds.""" + + +class BrowserPageLayoutShiftEvent(BaseModel): + """ + A browser cumulative layout shift (CLS) event from the Performance Timeline API. + """ + + category: Literal["page"] + + source: BrowserEventSource + """Provenance metadata identifying which producer emitted the event.""" + + ts: int + """Event timestamp in Unix microseconds.""" + + type: Literal["page_layout_shift"] + + data: Optional[Data] = None + """Browser event context stamped by the browser monitor onto all CDP-sourced + events. + + Identifies the target, frame, and navigation epoch in which the event occurred. + """ + + truncated: Optional[bool] = None + """True if the data field was truncated due to size limits.""" diff --git a/src/kernel/types/browsers/browser_page_lcp_event.py b/src/kernel/types/browsers/browser_page_lcp_event.py new file mode 100644 index 00000000..0bcbddaa --- /dev/null +++ b/src/kernel/types/browsers/browser_page_lcp_event.py @@ -0,0 +1,77 @@ +# 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 +from .browser_event_source import BrowserEventSource +from .browser_event_context import BrowserEventContext + +__all__ = ["BrowserPageLcpEvent", "Data", "DataLcpDetails"] + + +class DataLcpDetails(BaseModel): + """LargestContentfulPaint attributes from the Performance Timeline entry.""" + + element_id: Optional[str] = None + """id attribute of the LCP element, if present.""" + + load_time: Optional[float] = None + """Load time of the LCP element in milliseconds.""" + + node_id: Optional[int] = None + """CDP DOM node identifier of the LCP element.""" + + render_time: Optional[float] = None + """ + Render time of the LCP element in milliseconds; 0 for cross-origin images + without Timing-Allow-Origin. + """ + + size: Optional[float] = None + """Visible area of the LCP element in pixels squared.""" + + url: Optional[str] = None + """URL of the LCP element for image or video elements.""" + + +class Data(BrowserEventContext): + """Browser event context stamped by the browser monitor onto all CDP-sourced events. + + Identifies the target, frame, and navigation epoch in which the event occurred. + """ + + lcp_details: Optional[DataLcpDetails] = None + """LargestContentfulPaint attributes from the Performance Timeline entry.""" + + source_frame_id: Optional[str] = None + """CDP frame identifier of the frame where the LCP element was rendered.""" + + time: Optional[float] = None + """Performance Timeline timestamp of the LCP entry in milliseconds.""" + + +class BrowserPageLcpEvent(BaseModel): + """ + A browser Largest Contentful Paint (LCP) event from the Performance Timeline API. + """ + + category: Literal["page"] + + source: BrowserEventSource + """Provenance metadata identifying which producer emitted the event.""" + + ts: int + """Event timestamp in Unix microseconds.""" + + type: Literal["page_lcp"] + + data: Optional[Data] = None + """Browser event context stamped by the browser monitor onto all CDP-sourced + events. + + Identifies the target, frame, and navigation epoch in which the event occurred. + """ + + truncated: Optional[bool] = None + """True if the data field was truncated due to size limits.""" diff --git a/src/kernel/types/browsers/browser_page_load_event.py b/src/kernel/types/browsers/browser_page_load_event.py new file mode 100644 index 00000000..f27658f1 --- /dev/null +++ b/src/kernel/types/browsers/browser_page_load_event.py @@ -0,0 +1,47 @@ +# 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 +from .browser_event_source import BrowserEventSource +from .browser_event_context import BrowserEventContext + +__all__ = ["BrowserPageLoadEvent", "Data"] + + +class Data(BrowserEventContext): + """Browser event context stamped by the browser monitor onto all CDP-sourced events. + + Identifies the target, frame, and navigation epoch in which the event occurred. + """ + + cdp_timestamp: Optional[float] = None + """ + Chrome monotonic clock value in seconds at which the load event fired, relative + to browser process start (not Unix epoch). Use ts for wall-clock time. + """ + + +class BrowserPageLoadEvent(BaseModel): + """A browser page load event (CDP Page.loadEventFired).""" + + category: Literal["page"] + + source: BrowserEventSource + """Provenance metadata identifying which producer emitted the event.""" + + ts: int + """Event timestamp in Unix microseconds.""" + + type: Literal["page_load"] + + data: Optional[Data] = None + """Browser event context stamped by the browser monitor onto all CDP-sourced + events. + + Identifies the target, frame, and navigation epoch in which the event occurred. + """ + + truncated: Optional[bool] = None + """True if the data field was truncated due to size limits.""" diff --git a/src/kernel/types/browsers/browser_page_navigation_event.py b/src/kernel/types/browsers/browser_page_navigation_event.py new file mode 100644 index 00000000..5835d6d4 --- /dev/null +++ b/src/kernel/types/browsers/browser_page_navigation_event.py @@ -0,0 +1,57 @@ +# 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 +from .browser_event_source import BrowserEventSource + +__all__ = ["BrowserPageNavigationEvent", "Data"] + + +class Data(BaseModel): + frame_id: Optional[str] = None + """CDP frame identifier of the navigated frame.""" + + loader_id: Optional[str] = None + """New CDP document loader identifier assigned for this navigation.""" + + parent_frame_id: Optional[str] = None + """ + Parent frame identifier for subframe navigations; absent for top-level + navigations. + """ + + session_id: Optional[str] = None + """CDP session identifier.""" + + target_id: Optional[str] = None + """Browser target identifier.""" + + target_type: Optional[Literal["page", "background_page", "service_worker", "shared_worker", "other"]] = None + """CDP target type of the page that produced the event.""" + + url: Optional[str] = None + """URL navigated to.""" + + +class BrowserPageNavigationEvent(BaseModel): + """A browser page navigation started event (CDP Page.frameNavigated). + + Carries nav context fields inline but not nav_seq, as this event resets the navigation epoch. + """ + + category: Literal["page"] + + source: BrowserEventSource + """Provenance metadata identifying which producer emitted the event.""" + + ts: int + """Event timestamp in Unix microseconds.""" + + type: Literal["page_navigation"] + + data: Optional[Data] = None + + truncated: Optional[bool] = None + """True if the data field was truncated due to size limits.""" diff --git a/src/kernel/types/browsers/browser_page_navigation_settled_event.py b/src/kernel/types/browsers/browser_page_navigation_settled_event.py new file mode 100644 index 00000000..6e5d31c2 --- /dev/null +++ b/src/kernel/types/browsers/browser_page_navigation_settled_event.py @@ -0,0 +1,36 @@ +# 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 +from .browser_event_source import BrowserEventSource +from .browser_event_context import BrowserEventContext + +__all__ = ["BrowserPageNavigationSettledEvent"] + + +class BrowserPageNavigationSettledEvent(BaseModel): + """ + Emitted when page_dom_content_loaded and page_layout_settled have both fired for the same navigation, indicating the page is loaded and visually stable. Independent of network_idle; a single pending request does not block it. + """ + + category: Literal["page"] + + source: BrowserEventSource + """Provenance metadata identifying which producer emitted the event.""" + + ts: int + """Event timestamp in Unix microseconds.""" + + type: Literal["page_navigation_settled"] + + data: Optional[BrowserEventContext] = None + """Browser event context stamped by the browser monitor onto all CDP-sourced + events. + + Identifies the target, frame, and navigation epoch in which the event occurred. + """ + + truncated: Optional[bool] = None + """True if the data field was truncated due to size limits.""" diff --git a/src/kernel/types/browsers/browser_page_tab_opened_event.py b/src/kernel/types/browsers/browser_page_tab_opened_event.py new file mode 100644 index 00000000..144be476 --- /dev/null +++ b/src/kernel/types/browsers/browser_page_tab_opened_event.py @@ -0,0 +1,47 @@ +# 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 +from .browser_event_source import BrowserEventSource + +__all__ = ["BrowserPageTabOpenedEvent", "Data"] + + +class Data(BaseModel): + opener_id: Optional[str] = None + """Target identifier of the tab that opened this one, if any.""" + + target_id: Optional[str] = None + """CDP target identifier for the newly opened tab.""" + + target_type: Optional[Literal["page", "background_page", "service_worker", "shared_worker", "other"]] = None + """CDP target type of the page that produced the event.""" + + title: Optional[str] = None + """Initial page title of the new tab.""" + + url: Optional[str] = None + """Initial URL of the new tab.""" + + +class BrowserPageTabOpenedEvent(BaseModel): + """ + A new browser tab or target was opened (CDP Target.attachedToTarget for page targets). Fires before a CDP session is attached to the new target, so session_id, frame_id, loader_id, and nav_seq are absent; this event does not compose BrowserEventContext. Consumers reading context fields generically should treat it as a special case. + """ + + category: Literal["page"] + + source: BrowserEventSource + """Provenance metadata identifying which producer emitted the event.""" + + ts: int + """Event timestamp in Unix microseconds.""" + + type: Literal["page_tab_opened"] + + data: Optional[Data] = None + + truncated: Optional[bool] = None + """True if the data field was truncated due to size limits.""" diff --git a/src/kernel/types/browsers/browser_service_crashed_event.py b/src/kernel/types/browsers/browser_service_crashed_event.py new file mode 100644 index 00000000..7bc6f1cd --- /dev/null +++ b/src/kernel/types/browsers/browser_service_crashed_event.py @@ -0,0 +1,50 @@ +# 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 +from .browser_event_source import BrowserEventSource + +__all__ = ["BrowserServiceCrashedEvent", "Data"] + + +class Data(BaseModel): + phase: Literal["startup", "running", "gave_up"] + """Lifecycle phase the crash occurred in. + + startup: the process died before reaching a healthy running state. running: a + previously healthy process died unexpectedly. gave_up: the process manager + exhausted its restart attempts and stopped trying. + """ + + service_name: str + """Program name of the crashed service (e.g. chromium, mutter, kernel-images-api).""" + + pid: Optional[int] = None + """PID of the crashed process. + + Absent when the process manager gave up after exhausting restart attempts. + """ + + +class BrowserServiceCrashedEvent(BaseModel): + """A managed service exited unexpectedly. + + Intentional stops do not produce this event; only unexpected exits and terminal restart-give-up transitions do. + """ + + category: Literal["system"] + + source: BrowserEventSource + """Provenance metadata identifying which producer emitted the event.""" + + ts: int + """Event timestamp in Unix microseconds.""" + + type: Literal["service_crashed"] + + data: Optional[Data] = None + + truncated: Optional[bool] = None + """True if the data field was truncated due to size limits.""" diff --git a/src/kernel/types/browsers/browser_system_oom_kill_event.py b/src/kernel/types/browsers/browser_system_oom_kill_event.py new file mode 100644 index 00000000..2cafb817 --- /dev/null +++ b/src/kernel/types/browsers/browser_system_oom_kill_event.py @@ -0,0 +1,99 @@ +# File generated from our OpenAPI spec by Stainless. See CONTRIBUTING.md for details. + +from typing import List, Optional +from typing_extensions import Literal + +from ..._models import BaseModel +from .browser_event_source import BrowserEventSource + +__all__ = ["BrowserSystemOomKillEvent", "Data", "DataTopTask"] + + +class DataTopTask(BaseModel): + name: str + """Comm of the process (max 15 chars, truncated by the kernel).""" + + pid: int + """PID of the process.""" + + rss_kb: int + """Resident set size in KiB at the moment of the kill.""" + + +class Data(BaseModel): + pid: int + """PID of the killed process.""" + + process_name: str + """ + Comm of the killed process as reported by the kernel (max 15 chars, truncated by + the kernel). + """ + + rss_kb: int + """ + Resident set size of the killed process in KiB (sum of anon-rss, file-rss, and + shmem-rss). + """ + + constraint: Optional[Literal["none", "memcg", "cpuset", "memory_policy"]] = None + """Why the kernel decided to OOM-kill. + + none means global memory exhaustion; memcg means a cgroup memory limit was hit; + cpuset / memory_policy are NUMA/policy-driven kills. Absent on kernels older + than 5.0. + """ + + mem_free_kb: Optional[int] = None + """Free system memory in KiB at the time of the kill. + + Assumes a 4 KiB page size. Does not include reclaimable caches. Absent if the + kernel did not emit a parseable Mem-Info section. + """ + + mem_total_kb: Optional[int] = None + """Total system memory in KiB at the time of the kill. + + Assumes a 4 KiB page size. Absent if the kernel did not emit a parseable + Mem-Info section. + """ + + top_tasks: Optional[List[DataTopTask]] = None + """Top processes by resident-set-size at the moment of the kill, sorted descending. + + Empty if the kernel did not emit the Tasks state table. Capped at 5 entries. + """ + + trigger_pid: Optional[int] = None + """PID of the triggering process. + + Absent if the kernel did not emit the standard header line. + """ + + trigger_process_name: Optional[str] = None + """ + Comm of the process whose allocation request caused the kernel to invoke the + OOM-killer. Often the same as process_name but can differ. Max 15 chars. + """ + + +class BrowserSystemOomKillEvent(BaseModel): + """The Linux kernel OOM-killer terminated a process inside the VM. + + Fires for any process killed by the kernel due to memory exhaustion, including Chrome renderer subprocesses that are not supervised. + """ + + category: Literal["system"] + + source: BrowserEventSource + """Provenance metadata identifying which producer emitted the event.""" + + ts: int + """Event timestamp in Unix microseconds.""" + + type: Literal["system_oom_kill"] + + data: Optional[Data] = None + + truncated: Optional[bool] = None + """True if the data field was truncated due to size limits.""" diff --git a/src/kernel/types/browsers/browser_telemetry_categories_config.py b/src/kernel/types/browsers/browser_telemetry_categories_config.py new file mode 100644 index 00000000..b89ff17b --- /dev/null +++ b/src/kernel/types/browsers/browser_telemetry_categories_config.py @@ -0,0 +1,67 @@ +# File generated from our OpenAPI spec by Stainless. See CONTRIBUTING.md for details. + +from typing import Optional + +from ..._models import BaseModel +from .browser_telemetry_category_config import BrowserTelemetryCategoryConfig + +__all__ = ["BrowserTelemetryCategoriesConfig"] + + +class BrowserTelemetryCategoriesConfig(BaseModel): + """Per-category telemetry capture settings layered onto the default set. + + The operational signals (control, connection, system, captcha) are on by default and are opt-out: set one to enabled=false to stop capturing it. The CDP categories (console, network, page, interaction) and screenshot are off by default and are opt-in: set enabled=true to capture them. + """ + + captcha: Optional[BrowserTelemetryCategoryConfig] = None + """Captcha solve attempt outcomes. On by default.""" + + connection: Optional[BrowserTelemetryCategoryConfig] = None + """Client attach/detach lifecycle for the CDP proxy and live view. On by default.""" + + console: Optional[BrowserTelemetryCategoryConfig] = None + """Console output (log, warn, error) and uncaught exceptions. + + CDP category; off by default. + """ + + control: Optional[BrowserTelemetryCategoryConfig] = None + """Agent-driven actions against the browser, such as inbound calls to the in-VM + API. + + On by default. + """ + + interaction: Optional[BrowserTelemetryCategoryConfig] = None + """User interaction events including clicks, keydowns, and scroll-settled events. + + CDP category; off by default. + """ + + network: Optional[BrowserTelemetryCategoryConfig] = None + """ + HTTP request and response metadata including URL, method, status code, and + timing. Request post data is forwarded as-is from CDP. Text response bodies are + truncated at 8 KB for structured types (JSON, XML, form data) and 4 KB for other + text types. Binary responses (images, fonts, media) are excluded. CDP category; + off by default. + """ + + page: Optional[BrowserTelemetryCategoryConfig] = None + """ + Page lifecycle events including navigation, DOMContentLoaded, load, layout + shifts, and LCP. CDP category; off by default. + """ + + screenshot: Optional[BrowserTelemetryCategoryConfig] = None + """Periodic base64-encoded viewport screenshots. + + High volume; off by default and must be opted into. + """ + + system: Optional[BrowserTelemetryCategoryConfig] = None + """Browser VM health, such as out-of-memory kills and managed-service crashes. + + On by default. + """ diff --git a/src/kernel/types/browsers/browser_telemetry_categories_config_param.py b/src/kernel/types/browsers/browser_telemetry_categories_config_param.py new file mode 100644 index 00000000..add32385 --- /dev/null +++ b/src/kernel/types/browsers/browser_telemetry_categories_config_param.py @@ -0,0 +1,68 @@ +# File generated from our OpenAPI spec by Stainless. See CONTRIBUTING.md for details. + +from __future__ import annotations + +from typing_extensions import TypedDict + +from .browser_telemetry_category_config_param import BrowserTelemetryCategoryConfigParam + +__all__ = ["BrowserTelemetryCategoriesConfigParam"] + + +class BrowserTelemetryCategoriesConfigParam(TypedDict, total=False): + """Per-category telemetry capture settings layered onto the default set. + + The operational signals (control, connection, system, captcha) are on by default and are opt-out: set one to enabled=false to stop capturing it. The CDP categories (console, network, page, interaction) and screenshot are off by default and are opt-in: set enabled=true to capture them. + """ + + captcha: BrowserTelemetryCategoryConfigParam + """Captcha solve attempt outcomes. On by default.""" + + connection: BrowserTelemetryCategoryConfigParam + """Client attach/detach lifecycle for the CDP proxy and live view. On by default.""" + + console: BrowserTelemetryCategoryConfigParam + """Console output (log, warn, error) and uncaught exceptions. + + CDP category; off by default. + """ + + control: BrowserTelemetryCategoryConfigParam + """Agent-driven actions against the browser, such as inbound calls to the in-VM + API. + + On by default. + """ + + interaction: BrowserTelemetryCategoryConfigParam + """User interaction events including clicks, keydowns, and scroll-settled events. + + CDP category; off by default. + """ + + network: BrowserTelemetryCategoryConfigParam + """ + HTTP request and response metadata including URL, method, status code, and + timing. Request post data is forwarded as-is from CDP. Text response bodies are + truncated at 8 KB for structured types (JSON, XML, form data) and 4 KB for other + text types. Binary responses (images, fonts, media) are excluded. CDP category; + off by default. + """ + + page: BrowserTelemetryCategoryConfigParam + """ + Page lifecycle events including navigation, DOMContentLoaded, load, layout + shifts, and LCP. CDP category; off by default. + """ + + screenshot: BrowserTelemetryCategoryConfigParam + """Periodic base64-encoded viewport screenshots. + + High volume; off by default and must be opted into. + """ + + system: BrowserTelemetryCategoryConfigParam + """Browser VM health, such as out-of-memory kills and managed-service crashes. + + On by default. + """ diff --git a/src/kernel/types/browsers/browser_telemetry_category_config.py b/src/kernel/types/browsers/browser_telemetry_category_config.py new file mode 100644 index 00000000..f63c02f4 --- /dev/null +++ b/src/kernel/types/browsers/browser_telemetry_category_config.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__ = ["BrowserTelemetryCategoryConfig"] + + +class BrowserTelemetryCategoryConfig(BaseModel): + """Per-category telemetry configuration.""" + + enabled: Optional[bool] = None + """Whether this category is captured. + + Operational categories (control, connection, system, captcha) default to true; + set false to opt out. CDP categories (console, network, page, interaction) and + screenshot default to false; set true to opt in. + """ diff --git a/src/kernel/types/browsers/browser_telemetry_category_config_param.py b/src/kernel/types/browsers/browser_telemetry_category_config_param.py new file mode 100644 index 00000000..09dbe418 --- /dev/null +++ b/src/kernel/types/browsers/browser_telemetry_category_config_param.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 TypedDict + +__all__ = ["BrowserTelemetryCategoryConfigParam"] + + +class BrowserTelemetryCategoryConfigParam(TypedDict, total=False): + """Per-category telemetry configuration.""" + + enabled: bool + """Whether this category is captured. + + Operational categories (control, connection, system, captcha) default to true; + set false to opt out. CDP categories (console, network, page, interaction) and + screenshot default to false; set true to opt in. + """ diff --git a/src/kernel/types/browsers/browser_telemetry_config.py b/src/kernel/types/browsers/browser_telemetry_config.py new file mode 100644 index 00000000..2fae7a3d --- /dev/null +++ b/src/kernel/types/browsers/browser_telemetry_config.py @@ -0,0 +1,15 @@ +# File generated from our OpenAPI spec by Stainless. See CONTRIBUTING.md for details. + +from typing import Optional + +from ..._models import BaseModel +from .browser_telemetry_categories_config import BrowserTelemetryCategoriesConfig + +__all__ = ["BrowserTelemetryConfig"] + + +class BrowserTelemetryConfig(BaseModel): + """Active telemetry configuration for a browser session.""" + + browser: Optional[BrowserTelemetryCategoriesConfig] = None + """Per-category enable/disable flags.""" diff --git a/src/kernel/types/browsers/browser_telemetry_event.py b/src/kernel/types/browsers/browser_telemetry_event.py new file mode 100644 index 00000000..864bb681 --- /dev/null +++ b/src/kernel/types/browsers/browser_telemetry_event.py @@ -0,0 +1,77 @@ +# 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 Annotated, TypeAlias + +from ..._utils import PropertyInfo +from .browser_api_call_event import BrowserAPICallEvent +from .browser_page_lcp_event import BrowserPageLcpEvent +from .browser_page_load_event import BrowserPageLoadEvent +from .browser_cdp_connect_event import BrowserCdpConnectEvent +from .browser_network_idle_event import BrowserNetworkIdleEvent +from .browser_cdp_disconnect_event import BrowserCdpDisconnectEvent +from .browser_interaction_key_event import BrowserInteractionKeyEvent +from .browser_network_request_event import BrowserNetworkRequestEvent +from .browser_page_navigation_event import BrowserPageNavigationEvent +from .browser_page_tab_opened_event import BrowserPageTabOpenedEvent +from .browser_service_crashed_event import BrowserServiceCrashedEvent +from .browser_system_oom_kill_event import BrowserSystemOomKillEvent +from .browser_network_response_event import BrowserNetworkResponseEvent +from .browser_interaction_click_event import BrowserInteractionClickEvent +from .browser_live_view_connect_event import BrowserLiveViewConnectEvent +from .browser_page_layout_shift_event import BrowserPageLayoutShiftEvent +from .browser_monitor_screenshot_event import BrowserMonitorScreenshotEvent +from .browser_monitor_init_failed_event import BrowserMonitorInitFailedEvent +from .browser_monitor_reconnected_event import BrowserMonitorReconnectedEvent +from .browser_page_layout_settled_event import BrowserPageLayoutSettledEvent +from .browser_captcha_solve_result_event import BrowserCaptchaSolveResultEvent +from .browser_live_view_disconnect_event import BrowserLiveViewDisconnectEvent +from .browser_monitor_disconnected_event import BrowserMonitorDisconnectedEvent +from .browser_network_loading_failed_event import BrowserNetworkLoadingFailedEvent +from .browser_page_dom_content_loaded_event import BrowserPageDomContentLoadedEvent +from .browser_page_navigation_settled_event import BrowserPageNavigationSettledEvent +from .browser_monitor_reconnect_failed_event import BrowserMonitorReconnectFailedEvent +from .browser_interaction_scroll_settled_event import BrowserInteractionScrollSettledEvent + +__all__ = ["BrowserTelemetryEvent"] + +BrowserTelemetryEvent: TypeAlias = Annotated[ + Union[ + "BrowserConsoleLogEvent", + "BrowserConsoleErrorEvent", + BrowserNetworkRequestEvent, + BrowserNetworkResponseEvent, + BrowserNetworkLoadingFailedEvent, + BrowserNetworkIdleEvent, + BrowserPageNavigationEvent, + BrowserPageDomContentLoadedEvent, + BrowserPageLoadEvent, + BrowserPageTabOpenedEvent, + BrowserPageLayoutShiftEvent, + BrowserPageLcpEvent, + BrowserPageLayoutSettledEvent, + BrowserPageNavigationSettledEvent, + BrowserInteractionClickEvent, + BrowserInteractionKeyEvent, + BrowserInteractionScrollSettledEvent, + BrowserMonitorScreenshotEvent, + BrowserMonitorDisconnectedEvent, + BrowserMonitorReconnectedEvent, + BrowserMonitorReconnectFailedEvent, + BrowserMonitorInitFailedEvent, + BrowserAPICallEvent, + BrowserCdpConnectEvent, + BrowserCdpDisconnectEvent, + BrowserLiveViewConnectEvent, + BrowserLiveViewDisconnectEvent, + BrowserCaptchaSolveResultEvent, + BrowserSystemOomKillEvent, + BrowserServiceCrashedEvent, + ], + PropertyInfo(discriminator="type"), +] + +from .browser_console_log_event import BrowserConsoleLogEvent +from .browser_console_error_event import BrowserConsoleErrorEvent diff --git a/src/kernel/types/browsers/computer_batch_params.py b/src/kernel/types/browsers/computer_batch_params.py new file mode 100644 index 00000000..7fc6abb5 --- /dev/null +++ b/src/kernel/types/browsers/computer_batch_params.py @@ -0,0 +1,197 @@ +# File generated from our OpenAPI spec by Stainless. See CONTRIBUTING.md for details. + +from __future__ import annotations + +from typing import Iterable +from typing_extensions import Literal, Required, TypedDict + +from ..._types import SequenceNotStr + +__all__ = [ + "ComputerBatchParams", + "Action", + "ActionClickMouse", + "ActionDragMouse", + "ActionMoveMouse", + "ActionPressKey", + "ActionScroll", + "ActionSetCursor", + "ActionSleep", + "ActionTypeText", +] + + +class ComputerBatchParams(TypedDict, total=False): + actions: Required[Iterable[Action]] + """Ordered list of actions to execute. Execution stops on the first error.""" + + +class ActionClickMouse(TypedDict, total=False): + x: Required[int] + """X coordinate of the click position""" + + y: Required[int] + """Y coordinate of the click position""" + + button: Literal["left", "right", "middle", "back", "forward"] + """Mouse button to interact with""" + + click_type: Literal["down", "up", "click"] + """Type of click action""" + + hold_keys: SequenceNotStr[str] + """Modifier keys to hold during the click""" + + num_clicks: int + """Number of times to repeat the click""" + + +class ActionDragMouse(TypedDict, total=False): + path: Required[Iterable[Iterable[int]]] + """Ordered list of [x, y] coordinate pairs to move through while dragging. + + Must contain at least 2 points. + """ + + button: Literal["left", "middle", "right"] + """Mouse button to drag with""" + + delay: int + """Delay in milliseconds between button down and starting to move along the path.""" + + duration_ms: int + """ + Target total duration in milliseconds for the entire drag movement when + smooth=true. Omit for automatic timing based on total path length. + """ + + hold_keys: SequenceNotStr[str] + """Modifier keys to hold during the drag""" + + smooth: bool + """ + Use human-like Bezier curves between path waypoints instead of linear + interpolation. When true, steps_per_segment and step_delay_ms are ignored. + """ + + step_delay_ms: int + """ + Delay in milliseconds between relative steps while dragging (not the initial + delay). + """ + + steps_per_segment: int + """Number of relative move steps per segment in the path. Minimum 1.""" + + +class ActionMoveMouse(TypedDict, total=False): + x: Required[int] + """X coordinate to move the cursor to""" + + y: Required[int] + """Y coordinate to move the cursor to""" + + duration_ms: int + """Target total duration in milliseconds for the mouse movement when smooth=true. + + Omit for automatic timing based on distance. + """ + + hold_keys: SequenceNotStr[str] + """Modifier keys to hold during the move""" + + smooth: bool + """Use human-like Bezier curve path instead of instant mouse movement.""" + + +class ActionPressKey(TypedDict, total=False): + keys: Required[SequenceNotStr[str]] + """List of key symbols to press. + + Each item should be a key symbol supported by xdotool (see X11 keysym + definitions). Examples include "Return", "Shift", "Ctrl", "Alt", "F5". Items in + this list could also be combinations, e.g. "Ctrl+t" or "Ctrl+Shift+Tab". + """ + + duration: int + """Duration to hold the keys down in milliseconds. + + If omitted or 0, keys are tapped. + """ + + hold_keys: SequenceNotStr[str] + """Optional modifier keys to hold during the key press sequence.""" + + +class ActionScroll(TypedDict, total=False): + x: Required[int] + """X coordinate at which to perform the scroll""" + + y: Required[int] + """Y coordinate at which to perform the scroll""" + + delta_x: int + """ + Horizontal scroll amount in xdotool "wheel units." Positive scrolls right, + negative scrolls left. + """ + + delta_y: int + """ + Vertical scroll amount in xdotool "wheel units." Positive scrolls down, negative + scrolls up. + """ + + hold_keys: SequenceNotStr[str] + """Modifier keys to hold during the scroll""" + + +class ActionSetCursor(TypedDict, total=False): + hidden: Required[bool] + """Whether the cursor should be hidden or visible""" + + +class ActionSleep(TypedDict, total=False): + """Pause execution for a specified duration.""" + + duration_ms: Required[int] + """Duration to sleep in milliseconds.""" + + +class ActionTypeText(TypedDict, total=False): + text: Required[str] + """Text to type on the browser instance""" + + delay: int + """Delay in milliseconds between keystrokes""" + + +class Action(TypedDict, total=False): + """A single computer action to execute as part of a batch. + + The `type` field selects which + action to perform, and the corresponding field contains the action parameters. + Exactly one action field matching the type must be provided. + """ + + type: Required[ + Literal["click_mouse", "move_mouse", "type_text", "press_key", "scroll", "drag_mouse", "set_cursor", "sleep"] + ] + """The type of action to perform.""" + + click_mouse: ActionClickMouse + + drag_mouse: ActionDragMouse + + move_mouse: ActionMoveMouse + + press_key: ActionPressKey + + scroll: ActionScroll + + set_cursor: ActionSetCursor + + sleep: ActionSleep + """Pause execution for a specified duration.""" + + type_text: ActionTypeText diff --git a/src/kernel/types/browsers/computer_capture_screenshot_params.py b/src/kernel/types/browsers/computer_capture_screenshot_params.py new file mode 100644 index 00000000..942cef30 --- /dev/null +++ b/src/kernel/types/browsers/computer_capture_screenshot_params.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 Required, TypedDict + +__all__ = ["ComputerCaptureScreenshotParams", "Region"] + + +class ComputerCaptureScreenshotParams(TypedDict, total=False): + region: Region + + +class Region(TypedDict, total=False): + height: Required[int] + """Height of the region in pixels""" + + width: Required[int] + """Width of the region in pixels""" + + x: Required[int] + """X coordinate of the region's top-left corner""" + + y: Required[int] + """Y coordinate of the region's top-left corner""" diff --git a/src/kernel/types/browsers/computer_click_mouse_params.py b/src/kernel/types/browsers/computer_click_mouse_params.py new file mode 100644 index 00000000..9bde2e6a --- /dev/null +++ b/src/kernel/types/browsers/computer_click_mouse_params.py @@ -0,0 +1,29 @@ +# 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 ..._types import SequenceNotStr + +__all__ = ["ComputerClickMouseParams"] + + +class ComputerClickMouseParams(TypedDict, total=False): + x: Required[int] + """X coordinate of the click position""" + + y: Required[int] + """Y coordinate of the click position""" + + button: Literal["left", "right", "middle", "back", "forward"] + """Mouse button to interact with""" + + click_type: Literal["down", "up", "click"] + """Type of click action""" + + hold_keys: SequenceNotStr[str] + """Modifier keys to hold during the click""" + + num_clicks: int + """Number of times to repeat the click""" diff --git a/src/kernel/types/browsers/computer_drag_mouse_params.py b/src/kernel/types/browsers/computer_drag_mouse_params.py new file mode 100644 index 00000000..c0dd4c8e --- /dev/null +++ b/src/kernel/types/browsers/computer_drag_mouse_params.py @@ -0,0 +1,48 @@ +# File generated from our OpenAPI spec by Stainless. See CONTRIBUTING.md for details. + +from __future__ import annotations + +from typing import Iterable +from typing_extensions import Literal, Required, TypedDict + +from ..._types import SequenceNotStr + +__all__ = ["ComputerDragMouseParams"] + + +class ComputerDragMouseParams(TypedDict, total=False): + path: Required[Iterable[Iterable[int]]] + """Ordered list of [x, y] coordinate pairs to move through while dragging. + + Must contain at least 2 points. + """ + + button: Literal["left", "middle", "right"] + """Mouse button to drag with""" + + delay: int + """Delay in milliseconds between button down and starting to move along the path.""" + + duration_ms: int + """ + Target total duration in milliseconds for the entire drag movement when + smooth=true. Omit for automatic timing based on total path length. + """ + + hold_keys: SequenceNotStr[str] + """Modifier keys to hold during the drag""" + + smooth: bool + """ + Use human-like Bezier curves between path waypoints instead of linear + interpolation. When true, steps_per_segment and step_delay_ms are ignored. + """ + + step_delay_ms: int + """ + Delay in milliseconds between relative steps while dragging (not the initial + delay). + """ + + steps_per_segment: int + """Number of relative move steps per segment in the path. Minimum 1.""" diff --git a/src/kernel/types/browsers/computer_get_mouse_position_response.py b/src/kernel/types/browsers/computer_get_mouse_position_response.py new file mode 100644 index 00000000..53cdc4ad --- /dev/null +++ b/src/kernel/types/browsers/computer_get_mouse_position_response.py @@ -0,0 +1,13 @@ +# File generated from our OpenAPI spec by Stainless. See CONTRIBUTING.md for details. + +from ..._models import BaseModel + +__all__ = ["ComputerGetMousePositionResponse"] + + +class ComputerGetMousePositionResponse(BaseModel): + x: int + """X coordinate of the cursor""" + + y: int + """Y coordinate of the cursor""" diff --git a/src/kernel/types/browsers/computer_move_mouse_params.py b/src/kernel/types/browsers/computer_move_mouse_params.py new file mode 100644 index 00000000..3a4f99e5 --- /dev/null +++ b/src/kernel/types/browsers/computer_move_mouse_params.py @@ -0,0 +1,29 @@ +# File generated from our OpenAPI spec by Stainless. See CONTRIBUTING.md for details. + +from __future__ import annotations + +from typing_extensions import Required, TypedDict + +from ..._types import SequenceNotStr + +__all__ = ["ComputerMoveMouseParams"] + + +class ComputerMoveMouseParams(TypedDict, total=False): + x: Required[int] + """X coordinate to move the cursor to""" + + y: Required[int] + """Y coordinate to move the cursor to""" + + duration_ms: int + """Target total duration in milliseconds for the mouse movement when smooth=true. + + Omit for automatic timing based on distance. + """ + + hold_keys: SequenceNotStr[str] + """Modifier keys to hold during the move""" + + smooth: bool + """Use human-like Bezier curve path instead of instant mouse movement.""" diff --git a/src/kernel/types/browsers/computer_press_key_params.py b/src/kernel/types/browsers/computer_press_key_params.py new file mode 100644 index 00000000..ea2c9b45 --- /dev/null +++ b/src/kernel/types/browsers/computer_press_key_params.py @@ -0,0 +1,28 @@ +# File generated from our OpenAPI spec by Stainless. See CONTRIBUTING.md for details. + +from __future__ import annotations + +from typing_extensions import Required, TypedDict + +from ..._types import SequenceNotStr + +__all__ = ["ComputerPressKeyParams"] + + +class ComputerPressKeyParams(TypedDict, total=False): + keys: Required[SequenceNotStr[str]] + """List of key symbols to press. + + Each item should be a key symbol supported by xdotool (see X11 keysym + definitions). Examples include "Return", "Shift", "Ctrl", "Alt", "F5". Items in + this list could also be combinations, e.g. "Ctrl+t" or "Ctrl+Shift+Tab". + """ + + duration: int + """Duration to hold the keys down in milliseconds. + + If omitted or 0, keys are tapped. + """ + + hold_keys: SequenceNotStr[str] + """Optional modifier keys to hold during the key press sequence.""" diff --git a/src/kernel/types/browsers/computer_read_clipboard_response.py b/src/kernel/types/browsers/computer_read_clipboard_response.py new file mode 100644 index 00000000..e5210090 --- /dev/null +++ b/src/kernel/types/browsers/computer_read_clipboard_response.py @@ -0,0 +1,10 @@ +# File generated from our OpenAPI spec by Stainless. See CONTRIBUTING.md for details. + +from ..._models import BaseModel + +__all__ = ["ComputerReadClipboardResponse"] + + +class ComputerReadClipboardResponse(BaseModel): + text: str + """Current clipboard text content""" diff --git a/src/kernel/types/browsers/computer_scroll_params.py b/src/kernel/types/browsers/computer_scroll_params.py new file mode 100644 index 00000000..3af38af3 --- /dev/null +++ b/src/kernel/types/browsers/computer_scroll_params.py @@ -0,0 +1,32 @@ +# File generated from our OpenAPI spec by Stainless. See CONTRIBUTING.md for details. + +from __future__ import annotations + +from typing_extensions import Required, TypedDict + +from ..._types import SequenceNotStr + +__all__ = ["ComputerScrollParams"] + + +class ComputerScrollParams(TypedDict, total=False): + x: Required[int] + """X coordinate at which to perform the scroll""" + + y: Required[int] + """Y coordinate at which to perform the scroll""" + + delta_x: int + """ + Horizontal scroll amount in xdotool "wheel units." Positive scrolls right, + negative scrolls left. + """ + + delta_y: int + """ + Vertical scroll amount in xdotool "wheel units." Positive scrolls down, negative + scrolls up. + """ + + hold_keys: SequenceNotStr[str] + """Modifier keys to hold during the scroll""" diff --git a/src/kernel/types/browsers/computer_set_cursor_visibility_params.py b/src/kernel/types/browsers/computer_set_cursor_visibility_params.py new file mode 100644 index 00000000..f003ee91 --- /dev/null +++ b/src/kernel/types/browsers/computer_set_cursor_visibility_params.py @@ -0,0 +1,12 @@ +# File generated from our OpenAPI spec by Stainless. See CONTRIBUTING.md for details. + +from __future__ import annotations + +from typing_extensions import Required, TypedDict + +__all__ = ["ComputerSetCursorVisibilityParams"] + + +class ComputerSetCursorVisibilityParams(TypedDict, total=False): + hidden: Required[bool] + """Whether the cursor should be hidden or visible""" diff --git a/src/kernel/types/browsers/computer_set_cursor_visibility_response.py b/src/kernel/types/browsers/computer_set_cursor_visibility_response.py new file mode 100644 index 00000000..0e070239 --- /dev/null +++ b/src/kernel/types/browsers/computer_set_cursor_visibility_response.py @@ -0,0 +1,12 @@ +# File generated from our OpenAPI spec by Stainless. See CONTRIBUTING.md for details. + +from ..._models import BaseModel + +__all__ = ["ComputerSetCursorVisibilityResponse"] + + +class ComputerSetCursorVisibilityResponse(BaseModel): + """Generic OK response.""" + + ok: bool + """Indicates success.""" diff --git a/src/kernel/types/browsers/computer_type_text_params.py b/src/kernel/types/browsers/computer_type_text_params.py new file mode 100644 index 00000000..3a2c5133 --- /dev/null +++ b/src/kernel/types/browsers/computer_type_text_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__ = ["ComputerTypeTextParams"] + + +class ComputerTypeTextParams(TypedDict, total=False): + text: Required[str] + """Text to type on the browser instance""" + + delay: int + """Delay in milliseconds between keystrokes""" diff --git a/src/kernel/types/browsers/computer_write_clipboard_params.py b/src/kernel/types/browsers/computer_write_clipboard_params.py new file mode 100644 index 00000000..81f7b095 --- /dev/null +++ b/src/kernel/types/browsers/computer_write_clipboard_params.py @@ -0,0 +1,12 @@ +# File generated from our OpenAPI spec by Stainless. See CONTRIBUTING.md for details. + +from __future__ import annotations + +from typing_extensions import Required, TypedDict + +__all__ = ["ComputerWriteClipboardParams"] + + +class ComputerWriteClipboardParams(TypedDict, total=False): + text: Required[str] + """Text to write to the system clipboard""" diff --git a/src/kernel/types/browsers/f_create_directory_params.py b/src/kernel/types/browsers/f_create_directory_params.py new file mode 100644 index 00000000..20924f38 --- /dev/null +++ b/src/kernel/types/browsers/f_create_directory_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__ = ["FCreateDirectoryParams"] + + +class FCreateDirectoryParams(TypedDict, total=False): + path: Required[str] + """Absolute directory path to create.""" + + mode: str + """Optional directory mode (octal string, e.g. 755). Defaults to 755.""" diff --git a/src/kernel/types/browsers/f_delete_directory_params.py b/src/kernel/types/browsers/f_delete_directory_params.py new file mode 100644 index 00000000..8f5a0863 --- /dev/null +++ b/src/kernel/types/browsers/f_delete_directory_params.py @@ -0,0 +1,12 @@ +# File generated from our OpenAPI spec by Stainless. See CONTRIBUTING.md for details. + +from __future__ import annotations + +from typing_extensions import Required, TypedDict + +__all__ = ["FDeleteDirectoryParams"] + + +class FDeleteDirectoryParams(TypedDict, total=False): + path: Required[str] + """Absolute path to delete.""" diff --git a/src/kernel/types/browsers/f_delete_file_params.py b/src/kernel/types/browsers/f_delete_file_params.py new file mode 100644 index 00000000..d79bb8a7 --- /dev/null +++ b/src/kernel/types/browsers/f_delete_file_params.py @@ -0,0 +1,12 @@ +# File generated from our OpenAPI spec by Stainless. See CONTRIBUTING.md for details. + +from __future__ import annotations + +from typing_extensions import Required, TypedDict + +__all__ = ["FDeleteFileParams"] + + +class FDeleteFileParams(TypedDict, total=False): + path: Required[str] + """Absolute path to delete.""" diff --git a/src/kernel/types/browsers/f_download_dir_zip_params.py b/src/kernel/types/browsers/f_download_dir_zip_params.py new file mode 100644 index 00000000..88212c64 --- /dev/null +++ b/src/kernel/types/browsers/f_download_dir_zip_params.py @@ -0,0 +1,12 @@ +# File generated from our OpenAPI spec by Stainless. See CONTRIBUTING.md for details. + +from __future__ import annotations + +from typing_extensions import Required, TypedDict + +__all__ = ["FDownloadDirZipParams"] + + +class FDownloadDirZipParams(TypedDict, total=False): + path: Required[str] + """Absolute directory path to archive and download.""" diff --git a/src/kernel/types/browsers/f_file_info_params.py b/src/kernel/types/browsers/f_file_info_params.py new file mode 100644 index 00000000..9ddf41e5 --- /dev/null +++ b/src/kernel/types/browsers/f_file_info_params.py @@ -0,0 +1,12 @@ +# File generated from our OpenAPI spec by Stainless. See CONTRIBUTING.md for details. + +from __future__ import annotations + +from typing_extensions import Required, TypedDict + +__all__ = ["FFileInfoParams"] + + +class FFileInfoParams(TypedDict, total=False): + path: Required[str] + """Absolute path of the file or directory.""" diff --git a/src/kernel/types/browsers/f_file_info_response.py b/src/kernel/types/browsers/f_file_info_response.py new file mode 100644 index 00000000..7da15740 --- /dev/null +++ b/src/kernel/types/browsers/f_file_info_response.py @@ -0,0 +1,27 @@ +# File generated from our OpenAPI spec by Stainless. See CONTRIBUTING.md for details. + +from datetime import datetime + +from ..._models import BaseModel + +__all__ = ["FFileInfoResponse"] + + +class FFileInfoResponse(BaseModel): + is_dir: bool + """Whether the path is a directory.""" + + mod_time: datetime + """Last modification time.""" + + mode: str + """File mode bits (e.g., "drwxr-xr-x" or "-rw-r--r--").""" + + name: str + """Base name of the file or directory.""" + + path: str + """Absolute path.""" + + size_bytes: int + """Size in bytes. 0 for directories.""" diff --git a/src/kernel/types/browsers/f_list_files_params.py b/src/kernel/types/browsers/f_list_files_params.py new file mode 100644 index 00000000..87026f50 --- /dev/null +++ b/src/kernel/types/browsers/f_list_files_params.py @@ -0,0 +1,12 @@ +# File generated from our OpenAPI spec by Stainless. See CONTRIBUTING.md for details. + +from __future__ import annotations + +from typing_extensions import Required, TypedDict + +__all__ = ["FListFilesParams"] + + +class FListFilesParams(TypedDict, total=False): + path: Required[str] + """Absolute directory path.""" diff --git a/src/kernel/types/browsers/f_list_files_response.py b/src/kernel/types/browsers/f_list_files_response.py new file mode 100644 index 00000000..9fca14bd --- /dev/null +++ b/src/kernel/types/browsers/f_list_files_response.py @@ -0,0 +1,32 @@ +# File generated from our OpenAPI spec by Stainless. See CONTRIBUTING.md for details. + +from typing import List +from datetime import datetime +from typing_extensions import TypeAlias + +from ..._models import BaseModel + +__all__ = ["FListFilesResponse", "FListFilesResponseItem"] + + +class FListFilesResponseItem(BaseModel): + is_dir: bool + """Whether the path is a directory.""" + + mod_time: datetime + """Last modification time.""" + + mode: str + """File mode bits (e.g., "drwxr-xr-x" or "-rw-r--r--").""" + + name: str + """Base name of the file or directory.""" + + path: str + """Absolute path.""" + + size_bytes: int + """Size in bytes. 0 for directories.""" + + +FListFilesResponse: TypeAlias = List[FListFilesResponseItem] diff --git a/src/kernel/types/browsers/f_move_params.py b/src/kernel/types/browsers/f_move_params.py new file mode 100644 index 00000000..d324cc90 --- /dev/null +++ b/src/kernel/types/browsers/f_move_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__ = ["FMoveParams"] + + +class FMoveParams(TypedDict, total=False): + dest_path: Required[str] + """Absolute destination path.""" + + src_path: Required[str] + """Absolute source path.""" diff --git a/src/kernel/types/browsers/f_read_file_params.py b/src/kernel/types/browsers/f_read_file_params.py new file mode 100644 index 00000000..ee5d2e9e --- /dev/null +++ b/src/kernel/types/browsers/f_read_file_params.py @@ -0,0 +1,12 @@ +# File generated from our OpenAPI spec by Stainless. See CONTRIBUTING.md for details. + +from __future__ import annotations + +from typing_extensions import Required, TypedDict + +__all__ = ["FReadFileParams"] + + +class FReadFileParams(TypedDict, total=False): + path: Required[str] + """Absolute file path to read.""" diff --git a/src/kernel/types/browsers/f_set_file_permissions_params.py b/src/kernel/types/browsers/f_set_file_permissions_params.py new file mode 100644 index 00000000..5a02c1e5 --- /dev/null +++ b/src/kernel/types/browsers/f_set_file_permissions_params.py @@ -0,0 +1,21 @@ +# File generated from our OpenAPI spec by Stainless. See CONTRIBUTING.md for details. + +from __future__ import annotations + +from typing_extensions import Required, TypedDict + +__all__ = ["FSetFilePermissionsParams"] + + +class FSetFilePermissionsParams(TypedDict, total=False): + mode: Required[str] + """File mode bits (octal string, e.g. 644).""" + + path: Required[str] + """Absolute path whose permissions are to be changed.""" + + group: str + """New group name or GID.""" + + owner: str + """New owner username or UID.""" diff --git a/src/kernel/types/browsers/f_upload_params.py b/src/kernel/types/browsers/f_upload_params.py new file mode 100644 index 00000000..8f6534bd --- /dev/null +++ b/src/kernel/types/browsers/f_upload_params.py @@ -0,0 +1,21 @@ +# File generated from our OpenAPI spec by Stainless. See CONTRIBUTING.md for details. + +from __future__ import annotations + +from typing import Iterable +from typing_extensions import Required, TypedDict + +from ..._types import FileTypes + +__all__ = ["FUploadParams", "File"] + + +class FUploadParams(TypedDict, total=False): + files: Required[Iterable[File]] + + +class File(TypedDict, total=False): + dest_path: Required[str] + """Absolute destination path to write the file.""" + + file: Required[FileTypes] diff --git a/src/kernel/types/browsers/f_upload_zip_params.py b/src/kernel/types/browsers/f_upload_zip_params.py new file mode 100644 index 00000000..4646e05f --- /dev/null +++ b/src/kernel/types/browsers/f_upload_zip_params.py @@ -0,0 +1,16 @@ +# File generated from our OpenAPI spec by Stainless. See CONTRIBUTING.md for details. + +from __future__ import annotations + +from typing_extensions import Required, TypedDict + +from ..._types import FileTypes + +__all__ = ["FUploadZipParams"] + + +class FUploadZipParams(TypedDict, total=False): + dest_path: Required[str] + """Absolute destination directory to extract the archive to.""" + + zip_file: Required[FileTypes] diff --git a/src/kernel/types/browsers/f_write_file_params.py b/src/kernel/types/browsers/f_write_file_params.py new file mode 100644 index 00000000..557eac10 --- /dev/null +++ b/src/kernel/types/browsers/f_write_file_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__ = ["FWriteFileParams"] + + +class FWriteFileParams(TypedDict, total=False): + path: Required[str] + """Destination absolute file path.""" + + mode: str + """Optional file mode (octal string, e.g. 644). Defaults to 644.""" diff --git a/src/kernel/types/browsers/fs/__init__.py b/src/kernel/types/browsers/fs/__init__.py new file mode 100644 index 00000000..ebd13d99 --- /dev/null +++ b/src/kernel/types/browsers/fs/__init__.py @@ -0,0 +1,7 @@ +# File generated from our OpenAPI spec by Stainless. See CONTRIBUTING.md for details. + +from __future__ import annotations + +from .watch_start_params import WatchStartParams as WatchStartParams +from .watch_start_response import WatchStartResponse as WatchStartResponse +from .watch_events_response import WatchEventsResponse as WatchEventsResponse diff --git a/src/kernel/types/browsers/fs/watch_events_response.py b/src/kernel/types/browsers/fs/watch_events_response.py new file mode 100644 index 00000000..5778a309 --- /dev/null +++ b/src/kernel/types/browsers/fs/watch_events_response.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__ = ["WatchEventsResponse"] + + +class WatchEventsResponse(BaseModel): + """Filesystem change event.""" + + path: str + """Absolute path of the file or directory.""" + + type: Literal["CREATE", "WRITE", "DELETE", "RENAME"] + """Event type.""" + + is_dir: Optional[bool] = None + """Whether the affected path is a directory.""" + + name: Optional[str] = None + """Base name of the file or directory affected.""" diff --git a/src/kernel/types/browsers/fs/watch_start_params.py b/src/kernel/types/browsers/fs/watch_start_params.py new file mode 100644 index 00000000..5afddb1d --- /dev/null +++ b/src/kernel/types/browsers/fs/watch_start_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__ = ["WatchStartParams"] + + +class WatchStartParams(TypedDict, total=False): + path: Required[str] + """Directory to watch.""" + + recursive: bool + """Whether to watch recursively.""" diff --git a/src/kernel/types/browsers/fs/watch_start_response.py b/src/kernel/types/browsers/fs/watch_start_response.py new file mode 100644 index 00000000..b9f78e49 --- /dev/null +++ b/src/kernel/types/browsers/fs/watch_start_response.py @@ -0,0 +1,12 @@ +# File generated from our OpenAPI spec by Stainless. See CONTRIBUTING.md for details. + +from typing import Optional + +from ...._models import BaseModel + +__all__ = ["WatchStartResponse"] + + +class WatchStartResponse(BaseModel): + watch_id: Optional[str] = None + """Unique identifier for the directory watch""" diff --git a/src/kernel/types/browsers/log_stream_params.py b/src/kernel/types/browsers/log_stream_params.py new file mode 100644 index 00000000..2eeb9b32 --- /dev/null +++ b/src/kernel/types/browsers/log_stream_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 Literal, Required, TypedDict + +__all__ = ["LogStreamParams"] + + +class LogStreamParams(TypedDict, total=False): + source: Required[Literal["path", "supervisor"]] + + follow: bool + + path: str + """only required if source is path""" + + supervisor_process: str + """only required if source is supervisor""" diff --git a/src/kernel/types/browsers/playwright_execute_params.py b/src/kernel/types/browsers/playwright_execute_params.py new file mode 100644 index 00000000..948a74c1 --- /dev/null +++ b/src/kernel/types/browsers/playwright_execute_params.py @@ -0,0 +1,21 @@ +# File generated from our OpenAPI spec by Stainless. See CONTRIBUTING.md for details. + +from __future__ import annotations + +from typing_extensions import Required, TypedDict + +__all__ = ["PlaywrightExecuteParams"] + + +class PlaywrightExecuteParams(TypedDict, total=False): + code: Required[str] + """TypeScript/JavaScript code to execute. + + The code has access to 'page', 'context', and 'browser' variables. It runs + within a function, so you can use a return statement at the end to return a + value. This value is returned as the `result` property in the response. Example: + "await page.goto('https://example.com'); return await page.title();" + """ + + timeout_sec: int + """Maximum execution time in seconds. Default is 60.""" diff --git a/src/kernel/types/browsers/playwright_execute_response.py b/src/kernel/types/browsers/playwright_execute_response.py new file mode 100644 index 00000000..d53080de --- /dev/null +++ b/src/kernel/types/browsers/playwright_execute_response.py @@ -0,0 +1,26 @@ +# File generated from our OpenAPI spec by Stainless. See CONTRIBUTING.md for details. + +from typing import Optional + +from ..._models import BaseModel + +__all__ = ["PlaywrightExecuteResponse"] + + +class PlaywrightExecuteResponse(BaseModel): + """Result of Playwright code execution""" + + success: bool + """Whether the code executed successfully""" + + error: Optional[str] = None + """Error message if execution failed""" + + result: Optional[object] = None + """The value returned by the code (if any)""" + + stderr: Optional[str] = None + """Standard error from the execution""" + + stdout: Optional[str] = None + """Standard output from the execution""" diff --git a/src/kernel/types/browsers/process_exec_params.py b/src/kernel/types/browsers/process_exec_params.py new file mode 100644 index 00000000..a6481c19 --- /dev/null +++ b/src/kernel/types/browsers/process_exec_params.py @@ -0,0 +1,33 @@ +# File generated from our OpenAPI spec by Stainless. See CONTRIBUTING.md for details. + +from __future__ import annotations + +from typing import Dict, Optional +from typing_extensions import Required, TypedDict + +from ..._types import SequenceNotStr + +__all__ = ["ProcessExecParams"] + + +class ProcessExecParams(TypedDict, total=False): + command: Required[str] + """Executable or shell command to run.""" + + args: SequenceNotStr[str] + """Command arguments.""" + + as_root: bool + """Run the process with root privileges.""" + + as_user: Optional[str] + """Run the process as this user.""" + + cwd: Optional[str] + """Working directory (absolute path) to run the command in.""" + + env: Dict[str, str] + """Environment variables to set for the process.""" + + timeout_sec: Optional[int] + """Maximum execution time in seconds.""" diff --git a/src/kernel/types/browsers/process_exec_response.py b/src/kernel/types/browsers/process_exec_response.py new file mode 100644 index 00000000..a5e4b772 --- /dev/null +++ b/src/kernel/types/browsers/process_exec_response.py @@ -0,0 +1,23 @@ +# File generated from our OpenAPI spec by Stainless. See CONTRIBUTING.md for details. + +from typing import Optional + +from ..._models import BaseModel + +__all__ = ["ProcessExecResponse"] + + +class ProcessExecResponse(BaseModel): + """Result of a synchronous command execution.""" + + duration_ms: Optional[int] = None + """Execution duration in milliseconds.""" + + exit_code: Optional[int] = None + """Process exit code.""" + + stderr_b64: Optional[str] = None + """Base64-encoded stderr buffer.""" + + stdout_b64: Optional[str] = None + """Base64-encoded stdout buffer.""" diff --git a/src/kernel/types/browsers/process_kill_params.py b/src/kernel/types/browsers/process_kill_params.py new file mode 100644 index 00000000..84be2770 --- /dev/null +++ b/src/kernel/types/browsers/process_kill_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 Literal, Required, TypedDict + +__all__ = ["ProcessKillParams"] + + +class ProcessKillParams(TypedDict, total=False): + id: Required[str] + + signal: Required[Literal["TERM", "KILL", "INT", "HUP"]] + """Signal to send.""" diff --git a/src/kernel/types/browsers/process_kill_response.py b/src/kernel/types/browsers/process_kill_response.py new file mode 100644 index 00000000..6706e88b --- /dev/null +++ b/src/kernel/types/browsers/process_kill_response.py @@ -0,0 +1,12 @@ +# File generated from our OpenAPI spec by Stainless. See CONTRIBUTING.md for details. + +from ..._models import BaseModel + +__all__ = ["ProcessKillResponse"] + + +class ProcessKillResponse(BaseModel): + """Generic OK response.""" + + ok: bool + """Indicates success.""" diff --git a/src/kernel/types/browsers/process_resize_params.py b/src/kernel/types/browsers/process_resize_params.py new file mode 100644 index 00000000..4fdb8ad5 --- /dev/null +++ b/src/kernel/types/browsers/process_resize_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, TypedDict + +__all__ = ["ProcessResizeParams"] + + +class ProcessResizeParams(TypedDict, total=False): + id: Required[str] + + cols: Required[int] + """New terminal columns.""" + + rows: Required[int] + """New terminal rows.""" diff --git a/src/kernel/types/browsers/process_resize_response.py b/src/kernel/types/browsers/process_resize_response.py new file mode 100644 index 00000000..6997517d --- /dev/null +++ b/src/kernel/types/browsers/process_resize_response.py @@ -0,0 +1,12 @@ +# File generated from our OpenAPI spec by Stainless. See CONTRIBUTING.md for details. + +from ..._models import BaseModel + +__all__ = ["ProcessResizeResponse"] + + +class ProcessResizeResponse(BaseModel): + """Generic OK response.""" + + ok: bool + """Indicates success.""" diff --git a/src/kernel/types/browsers/process_spawn_params.py b/src/kernel/types/browsers/process_spawn_params.py new file mode 100644 index 00000000..78d57a2d --- /dev/null +++ b/src/kernel/types/browsers/process_spawn_params.py @@ -0,0 +1,42 @@ +# File generated from our OpenAPI spec by Stainless. See CONTRIBUTING.md for details. + +from __future__ import annotations + +from typing import Dict, Optional +from typing_extensions import Required, TypedDict + +from ..._types import SequenceNotStr + +__all__ = ["ProcessSpawnParams"] + + +class ProcessSpawnParams(TypedDict, total=False): + command: Required[str] + """Executable or shell command to run.""" + + allocate_tty: bool + """Allocate a pseudo-terminal (PTY) for interactive shells.""" + + args: SequenceNotStr[str] + """Command arguments.""" + + as_root: bool + """Run the process with root privileges.""" + + as_user: Optional[str] + """Run the process as this user.""" + + cols: int + """Initial terminal columns. Only used when allocate_tty is true.""" + + cwd: Optional[str] + """Working directory (absolute path) to run the command in.""" + + env: Dict[str, str] + """Environment variables to set for the process.""" + + rows: int + """Initial terminal rows. Only used when allocate_tty is true.""" + + timeout_sec: Optional[int] + """Maximum execution time in seconds.""" diff --git a/src/kernel/types/browsers/process_spawn_response.py b/src/kernel/types/browsers/process_spawn_response.py new file mode 100644 index 00000000..0cda64d5 --- /dev/null +++ b/src/kernel/types/browsers/process_spawn_response.py @@ -0,0 +1,21 @@ +# File generated from our OpenAPI spec by Stainless. See CONTRIBUTING.md for details. + +from typing import Optional +from datetime import datetime + +from ..._models import BaseModel + +__all__ = ["ProcessSpawnResponse"] + + +class ProcessSpawnResponse(BaseModel): + """Information about a spawned process.""" + + pid: Optional[int] = None + """OS process ID.""" + + process_id: Optional[str] = None + """Server-assigned identifier for the process.""" + + started_at: Optional[datetime] = None + """Timestamp when the process started.""" diff --git a/src/kernel/types/browsers/process_status_response.py b/src/kernel/types/browsers/process_status_response.py new file mode 100644 index 00000000..91c77240 --- /dev/null +++ b/src/kernel/types/browsers/process_status_response.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__ = ["ProcessStatusResponse"] + + +class ProcessStatusResponse(BaseModel): + """Current status of a process.""" + + cpu_pct: Optional[float] = None + """Estimated CPU usage percentage.""" + + exit_code: Optional[int] = None + """Exit code if the process has exited.""" + + mem_bytes: Optional[int] = None + """Estimated resident memory usage in bytes.""" + + state: Optional[Literal["running", "exited"]] = None + """Process state.""" diff --git a/src/kernel/types/browsers/process_stdin_params.py b/src/kernel/types/browsers/process_stdin_params.py new file mode 100644 index 00000000..9ece9a57 --- /dev/null +++ b/src/kernel/types/browsers/process_stdin_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__ = ["ProcessStdinParams"] + + +class ProcessStdinParams(TypedDict, total=False): + id: Required[str] + + data_b64: Required[str] + """Base64-encoded data to write.""" diff --git a/src/kernel/types/browsers/process_stdin_response.py b/src/kernel/types/browsers/process_stdin_response.py new file mode 100644 index 00000000..be3c7987 --- /dev/null +++ b/src/kernel/types/browsers/process_stdin_response.py @@ -0,0 +1,14 @@ +# File generated from our OpenAPI spec by Stainless. See CONTRIBUTING.md for details. + +from typing import Optional + +from ..._models import BaseModel + +__all__ = ["ProcessStdinResponse"] + + +class ProcessStdinResponse(BaseModel): + """Result of writing to stdin.""" + + written_bytes: Optional[int] = None + """Number of bytes written.""" diff --git a/src/kernel/types/browsers/process_stdout_stream_response.py b/src/kernel/types/browsers/process_stdout_stream_response.py new file mode 100644 index 00000000..6e911f5d --- /dev/null +++ b/src/kernel/types/browsers/process_stdout_stream_response.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__ = ["ProcessStdoutStreamResponse"] + + +class ProcessStdoutStreamResponse(BaseModel): + """SSE payload representing process output or lifecycle events.""" + + data_b64: Optional[str] = None + """Base64-encoded data from the process stream.""" + + event: Optional[Literal["exit"]] = None + """Lifecycle event type.""" + + exit_code: Optional[int] = None + """Exit code when the event is "exit".""" + + stream: Optional[Literal["stdout", "stderr"]] = None + """Source stream of the data chunk.""" diff --git a/src/kernel/types/browsers/replay_list_response.py b/src/kernel/types/browsers/replay_list_response.py new file mode 100644 index 00000000..8cf9d543 --- /dev/null +++ b/src/kernel/types/browsers/replay_list_response.py @@ -0,0 +1,28 @@ +# File generated from our OpenAPI spec by Stainless. See CONTRIBUTING.md for details. + +from typing import List, Optional +from datetime import datetime +from typing_extensions import TypeAlias + +from ..._models import BaseModel + +__all__ = ["ReplayListResponse", "ReplayListResponseItem"] + + +class ReplayListResponseItem(BaseModel): + """Information about a browser replay recording.""" + + replay_id: str + """Unique identifier for the replay recording.""" + + finished_at: Optional[datetime] = None + """Timestamp when replay finished""" + + replay_view_url: Optional[str] = None + """URL for viewing the replay recording.""" + + started_at: Optional[datetime] = None + """Timestamp when replay started""" + + +ReplayListResponse: TypeAlias = List[ReplayListResponseItem] diff --git a/src/kernel/types/browsers/replay_start_params.py b/src/kernel/types/browsers/replay_start_params.py new file mode 100644 index 00000000..c37c554d --- /dev/null +++ b/src/kernel/types/browsers/replay_start_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_extensions import TypedDict + +__all__ = ["ReplayStartParams"] + + +class ReplayStartParams(TypedDict, total=False): + framerate: int + """Recording framerate in fps. + + Values above 20 require GPU to be enabled on the browser session. + """ + + max_duration_in_seconds: int + """Maximum recording duration in seconds.""" + + record_audio: bool + """Record audio in addition to video. + + When false (the default), the recording is video-only. + """ diff --git a/src/kernel/types/browsers/replay_start_response.py b/src/kernel/types/browsers/replay_start_response.py new file mode 100644 index 00000000..ac4130b5 --- /dev/null +++ b/src/kernel/types/browsers/replay_start_response.py @@ -0,0 +1,24 @@ +# File generated from our OpenAPI spec by Stainless. See CONTRIBUTING.md for details. + +from typing import Optional +from datetime import datetime + +from ..._models import BaseModel + +__all__ = ["ReplayStartResponse"] + + +class ReplayStartResponse(BaseModel): + """Information about a browser replay recording.""" + + replay_id: str + """Unique identifier for the replay recording.""" + + finished_at: Optional[datetime] = None + """Timestamp when replay finished""" + + replay_view_url: Optional[str] = None + """URL for viewing the replay recording.""" + + started_at: Optional[datetime] = None + """Timestamp when replay started""" diff --git a/src/kernel/types/browsers/telemetry_events_params.py b/src/kernel/types/browsers/telemetry_events_params.py new file mode 100644 index 00000000..4aa0f13c --- /dev/null +++ b/src/kernel/types/browsers/telemetry_events_params.py @@ -0,0 +1,63 @@ +# File generated from our OpenAPI spec by Stainless. See CONTRIBUTING.md for details. + +from __future__ import annotations + +from typing import List +from typing_extensions import Literal, TypedDict + +__all__ = ["TelemetryEventsParams"] + + +class TelemetryEventsParams(TypedDict, total=False): + category: List[ + Literal[ + "console", + "network", + "page", + "interaction", + "control", + "connection", + "system", + "screenshot", + "captcha", + "monitor", + ] + ] + """Restrict results to these event categories. + + Repeat the parameter for multiple values. + """ + + limit: int + """Maximum number of events per page. Defaults to 20.""" + + offset: int + """ + Opaque pagination cursor: pass the X-Next-Offset value from the previous + response to fetch the next page. When set, paging continues from this cursor and + since is ignored, while until still bounds the page. It is not an event's seq + field, so do not derive it from the response body. + """ + + order: str + """Read direction. + + asc (default) reads oldest first, starting from since or the offset cursor. desc + reads newest first: each request returns one page of up to limit records ending + at the offset cursor (or until, or the newest archived event); combining desc + with since is rejected with a 400. In either direction the category filter + applies within the page, so a filtered page may be empty while X-Has-More is + true. + """ + + since: str + """ + Start of the window: an RFC-3339 timestamp, or a duration like 5m meaning that + long ago. Defaults to 5m. Ignored when offset is set. + """ + + until: str + """ + End of the window (exclusive): an RFC-3339 timestamp, or a duration like 5m + meaning that long ago. + """ diff --git a/src/kernel/types/browsers/telemetry_events_response.py b/src/kernel/types/browsers/telemetry_events_response.py new file mode 100644 index 00000000..3002f470 --- /dev/null +++ b/src/kernel/types/browsers/telemetry_events_response.py @@ -0,0 +1,36 @@ +# File generated from our OpenAPI spec by Stainless. See CONTRIBUTING.md for details. + +from __future__ import annotations + +from ..._models import BaseModel + +__all__ = ["TelemetryEventsResponse"] + + +class TelemetryEventsResponse(BaseModel): + """Envelope wrapping a browser telemetry event with its monotonic sequence number. + + Each SSE data: frame carries one envelope as JSON. The seq value is also emitted as the SSE id: field so clients can pass it as Last-Event-ID on reconnect. + """ + + event: "BrowserTelemetryEvent" + """Union type representing any browser telemetry event. + + Discriminated on `type`. Each event's `category` determines when it is captured. + The CDP collector-health events (monitor_disconnected, monitor_reconnected, + monitor_reconnect_failed, monitor_init_failed) use the `monitor` category, which + is not user-configurable: it flows automatically whenever any CDP category + (console, network, page, interaction) is captured, and is silent otherwise. + monitor_screenshot uses the opt-in `screenshot` category. All other event types + are controlled by their per-category enable/disable flags. + """ + + seq: int + """Process-monotonic sequence number assigned by the browser VM. + + Pass as Last-Event-ID on reconnect to resume without gaps. Gaps in received seq + values indicate dropped events. + """ + + +from .browser_telemetry_event import BrowserTelemetryEvent diff --git a/src/kernel/types/browsers/telemetry_stream_params.py b/src/kernel/types/browsers/telemetry_stream_params.py new file mode 100644 index 00000000..d9703bc7 --- /dev/null +++ b/src/kernel/types/browsers/telemetry_stream_params.py @@ -0,0 +1,20 @@ +# 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__ = ["TelemetryStreamParams"] + + +class TelemetryStreamParams(TypedDict, total=False): + replay: str + """ + Pass `all` to start from the oldest retained event instead of only new events; + any other value is treated as from-now. The buffer is bounded, so the first + event id may be greater than 1 if older events were evicted. + """ + + last_event_id: Annotated[str, PropertyInfo(alias="Last-Event-ID")] diff --git a/src/kernel/types/browsers/telemetry_stream_response.py b/src/kernel/types/browsers/telemetry_stream_response.py new file mode 100644 index 00000000..25dd6650 --- /dev/null +++ b/src/kernel/types/browsers/telemetry_stream_response.py @@ -0,0 +1,36 @@ +# File generated from our OpenAPI spec by Stainless. See CONTRIBUTING.md for details. + +from __future__ import annotations + +from ..._models import BaseModel + +__all__ = ["TelemetryStreamResponse"] + + +class TelemetryStreamResponse(BaseModel): + """Envelope wrapping a browser telemetry event with its monotonic sequence number. + + Each SSE data: frame carries one envelope as JSON. The seq value is also emitted as the SSE id: field so clients can pass it as Last-Event-ID on reconnect. + """ + + event: "BrowserTelemetryEvent" + """Union type representing any browser telemetry event. + + Discriminated on `type`. Each event's `category` determines when it is captured. + The CDP collector-health events (monitor_disconnected, monitor_reconnected, + monitor_reconnect_failed, monitor_init_failed) use the `monitor` category, which + is not user-configurable: it flows automatically whenever any CDP category + (console, network, page, interaction) is captured, and is silent otherwise. + monitor_screenshot uses the opt-in `screenshot` category. All other event types + are controlled by their per-category enable/disable flags. + """ + + seq: int + """Process-monotonic sequence number assigned by the browser VM. + + Pass as Last-Event-ID on reconnect to resume without gaps. Gaps in received seq + values indicate dropped events. + """ + + +from .browser_telemetry_event import BrowserTelemetryEvent diff --git a/src/kernel/types/created_api_key.py b/src/kernel/types/created_api_key.py new file mode 100644 index 00000000..674943d5 --- /dev/null +++ b/src/kernel/types/created_api_key.py @@ -0,0 +1,12 @@ +# File generated from our OpenAPI spec by Stainless. See CONTRIBUTING.md for details. + +from .api_key import APIKey + +__all__ = ["CreatedAPIKey"] + + +class CreatedAPIKey(APIKey): + """API key returned immediately after creation. Includes the plaintext key once.""" + + key: str + """Plaintext API key. Only returned once when the key is created.""" diff --git a/src/kernel/types/credential.py b/src/kernel/types/credential.py new file mode 100644 index 00000000..fb2ff6e1 --- /dev/null +++ b/src/kernel/types/credential.py @@ -0,0 +1,57 @@ +# File generated from our OpenAPI spec by Stainless. See CONTRIBUTING.md for details. + +from typing import List, Optional +from datetime import datetime + +from .._models import BaseModel + +__all__ = ["Credential"] + + +class Credential(BaseModel): + """A stored credential for automatic re-authentication""" + + id: str + """Unique identifier for the credential""" + + created_at: datetime + """When the credential was created""" + + domain: str + """Target domain this credential is for""" + + name: str + """Unique name for the credential within the project""" + + updated_at: datetime + """When the credential was last updated""" + + has_totp_secret: Optional[bool] = None + """Whether this credential has a TOTP secret configured for automatic 2FA""" + + has_values: Optional[bool] = None + """Whether this credential has stored values (email, password, etc.)""" + + sso_provider: Optional[str] = None + """ + If set, indicates this credential should be used with the specified SSO provider + (e.g., google, github, microsoft). When the target site has a matching SSO + button, it will be clicked first before filling credential values on the + identity provider's login page. + """ + + totp_code: Optional[str] = None + """Current 6-digit TOTP code. + + Only included in create/update responses when totp_secret was just set. + """ + + totp_code_expires_at: Optional[datetime] = None + """When the totp_code expires. Only included when totp_code is present.""" + + value_keys: Optional[List[str]] = None + """The field names stored in this credential's values (e.g., username, password). + + Values themselves are never returned. Included on single-credential responses + (create, get by id or name, update); omitted from list responses. + """ diff --git a/src/kernel/types/credential_create_params.py b/src/kernel/types/credential_create_params.py new file mode 100644 index 00000000..9d306844 --- /dev/null +++ b/src/kernel/types/credential_create_params.py @@ -0,0 +1,33 @@ +# 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 Required, TypedDict + +__all__ = ["CredentialCreateParams"] + + +class CredentialCreateParams(TypedDict, total=False): + domain: Required[str] + """Target domain this credential is for""" + + name: Required[str] + """Unique name for the credential within the project""" + + values: Required[Dict[str, str]] + """Field name to value mapping (e.g., username, password)""" + + sso_provider: str + """ + If set, indicates this credential should be used with the specified SSO provider + (e.g., google, github, microsoft). When the target site has a matching SSO + button, it will be clicked first before filling credential values on the + identity provider's login page. + """ + + totp_secret: str + """Base32-encoded TOTP secret for generating one-time passwords. + + Used for automatic 2FA during login. + """ diff --git a/src/kernel/types/credential_list_params.py b/src/kernel/types/credential_list_params.py new file mode 100644 index 00000000..ecaf2ef1 --- /dev/null +++ b/src/kernel/types/credential_list_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_extensions import TypedDict + +__all__ = ["CredentialListParams"] + + +class CredentialListParams(TypedDict, total=False): + domain: str + """Filter by domain""" + + limit: int + """Maximum number of results to return""" + + offset: int + """Number of results to skip""" + + query: str + """Case-insensitive substring match against credential name or domain. + + IDs match by exact value. + """ diff --git a/src/kernel/types/credential_provider.py b/src/kernel/types/credential_provider.py new file mode 100644 index 00000000..866f0631 --- /dev/null +++ b/src/kernel/types/credential_provider.py @@ -0,0 +1,35 @@ +# File generated from our OpenAPI spec by Stainless. See CONTRIBUTING.md for details. + +from datetime import datetime +from typing_extensions import Literal + +from .._models import BaseModel + +__all__ = ["CredentialProvider"] + + +class CredentialProvider(BaseModel): + """ + An external credential provider (e.g., 1Password) for automatic credential lookup + """ + + id: str + """Unique identifier for the credential provider""" + + created_at: datetime + """When the credential provider was created""" + + enabled: bool + """Whether the provider is enabled for credential lookups""" + + name: str + """Human-readable name for this provider instance""" + + priority: int + """Priority order for credential lookups (lower numbers are checked first)""" + + provider_type: Literal["onepassword"] + """Type of credential provider""" + + updated_at: datetime + """When the credential provider was last updated""" diff --git a/src/kernel/types/credential_provider_create_params.py b/src/kernel/types/credential_provider_create_params.py new file mode 100644 index 00000000..ea531c7d --- /dev/null +++ b/src/kernel/types/credential_provider_create_params.py @@ -0,0 +1,21 @@ +# 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__ = ["CredentialProviderCreateParams"] + + +class CredentialProviderCreateParams(TypedDict, total=False): + token: Required[str] + """Service account token for the provider (e.g., 1Password service account token)""" + + name: Required[str] + """Human-readable name for this provider instance (unique per org)""" + + provider_type: Required[Literal["onepassword"]] + """Type of credential provider""" + + cache_ttl_seconds: int + """How long to cache credential lists (default 300 seconds)""" diff --git a/src/kernel/types/credential_provider_item.py b/src/kernel/types/credential_provider_item.py new file mode 100644 index 00000000..8bc618b5 --- /dev/null +++ b/src/kernel/types/credential_provider_item.py @@ -0,0 +1,29 @@ +# File generated from our OpenAPI spec by Stainless. See CONTRIBUTING.md for details. + +from typing import List, Optional + +from .._models import BaseModel + +__all__ = ["CredentialProviderItem"] + + +class CredentialProviderItem(BaseModel): + """A credential item from an external provider (e.g., a 1Password login item)""" + + id: str + """Unique identifier for the item within the provider""" + + path: str + """Path to reference this item (VaultName/ItemTitle format)""" + + title: str + """Display name of the credential item""" + + vault_id: str + """ID of the vault containing this item""" + + vault_name: str + """Name of the vault containing this item""" + + urls: Optional[List[str]] = None + """URLs associated with this credential""" diff --git a/src/kernel/types/credential_provider_list_items_response.py b/src/kernel/types/credential_provider_list_items_response.py new file mode 100644 index 00000000..265f2ef2 --- /dev/null +++ b/src/kernel/types/credential_provider_list_items_response.py @@ -0,0 +1,12 @@ +# File generated from our OpenAPI spec by Stainless. See CONTRIBUTING.md for details. + +from typing import List, Optional + +from .._models import BaseModel +from .credential_provider_item import CredentialProviderItem + +__all__ = ["CredentialProviderListItemsResponse"] + + +class CredentialProviderListItemsResponse(BaseModel): + items: Optional[List[CredentialProviderItem]] = None diff --git a/src/kernel/types/credential_provider_list_params.py b/src/kernel/types/credential_provider_list_params.py new file mode 100644 index 00000000..97571aa8 --- /dev/null +++ b/src/kernel/types/credential_provider_list_params.py @@ -0,0 +1,21 @@ +# File generated from our OpenAPI spec by Stainless. See CONTRIBUTING.md for details. + +from __future__ import annotations + +from typing_extensions import TypedDict + +__all__ = ["CredentialProviderListParams"] + + +class CredentialProviderListParams(TypedDict, total=False): + limit: int + """Limit the number of credential providers to return.""" + + offset: int + """Offset the number of credential providers to return.""" + + query: str + """Case-insensitive substring match against credential provider name. + + IDs match by exact value. + """ diff --git a/src/kernel/types/credential_provider_test_result.py b/src/kernel/types/credential_provider_test_result.py new file mode 100644 index 00000000..8cc4b018 --- /dev/null +++ b/src/kernel/types/credential_provider_test_result.py @@ -0,0 +1,28 @@ +# File generated from our OpenAPI spec by Stainless. See CONTRIBUTING.md for details. + +from typing import List, Optional + +from .._models import BaseModel + +__all__ = ["CredentialProviderTestResult", "Vault"] + + +class Vault(BaseModel): + id: str + """Vault ID""" + + name: str + """Vault name""" + + +class CredentialProviderTestResult(BaseModel): + """Result of testing a credential provider connection""" + + success: bool + """Whether the connection test was successful""" + + vaults: List[Vault] + """List of vaults accessible by the service account""" + + error: Optional[str] = None + """Error message if the test failed""" diff --git a/src/kernel/types/credential_provider_update_params.py b/src/kernel/types/credential_provider_update_params.py new file mode 100644 index 00000000..b2dda02b --- /dev/null +++ b/src/kernel/types/credential_provider_update_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_extensions import TypedDict + +__all__ = ["CredentialProviderUpdateParams"] + + +class CredentialProviderUpdateParams(TypedDict, total=False): + token: str + """New service account token (to rotate credentials)""" + + cache_ttl_seconds: int + """How long to cache credential lists""" + + enabled: bool + """Whether the provider is enabled for credential lookups""" + + name: str + """Human-readable name for this provider instance""" + + priority: int + """Priority order for credential lookups (lower numbers are checked first)""" diff --git a/src/kernel/types/credential_totp_code_response.py b/src/kernel/types/credential_totp_code_response.py new file mode 100644 index 00000000..670f4e7c --- /dev/null +++ b/src/kernel/types/credential_totp_code_response.py @@ -0,0 +1,15 @@ +# File generated from our OpenAPI spec by Stainless. See CONTRIBUTING.md for details. + +from datetime import datetime + +from .._models import BaseModel + +__all__ = ["CredentialTotpCodeResponse"] + + +class CredentialTotpCodeResponse(BaseModel): + code: str + """Current 6-digit TOTP code""" + + expires_at: datetime + """When this code expires (ISO 8601 timestamp)""" diff --git a/src/kernel/types/credential_update_params.py b/src/kernel/types/credential_update_params.py new file mode 100644 index 00000000..9da3f09c --- /dev/null +++ b/src/kernel/types/credential_update_params.py @@ -0,0 +1,43 @@ +# File generated from our OpenAPI spec by Stainless. See CONTRIBUTING.md for details. + +from __future__ import annotations + +from typing import Dict, Optional +from typing_extensions import TypedDict + +from .._types import SequenceNotStr + +__all__ = ["CredentialUpdateParams"] + + +class CredentialUpdateParams(TypedDict, total=False): + name: str + """New name for the credential""" + + remove_value_keys: SequenceNotStr[str] + """Field names to remove from the credential's stored values. + + Removals are applied before `values` are merged, so a key present in both is + kept with its new value. + """ + + sso_provider: Optional[str] + """If set, indicates this credential should be used with the specified SSO + provider. + + Set to empty string or null to remove. + """ + + totp_secret: str + """Base32-encoded TOTP secret for generating one-time passwords. + + Spaces and formatting are automatically normalized. Set to empty string to + remove. + """ + + values: Dict[str, str] + """Field name to value mapping. + + Values are merged with existing values (new keys added, existing keys + overwritten). + """ diff --git a/src/kernel/types/deployment_create_params.py b/src/kernel/types/deployment_create_params.py new file mode 100644 index 00000000..84d3d876 --- /dev/null +++ b/src/kernel/types/deployment_create_params.py @@ -0,0 +1,68 @@ +# 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 + +from .._types import FileTypes + +__all__ = ["DeploymentCreateParams", "Source", "SourceAuth"] + + +class DeploymentCreateParams(TypedDict, total=False): + entrypoint_rel_path: str + """Relative path to the entrypoint of the application""" + + env_vars: Dict[str, str] + """Map of environment variables to set for the deployed application. + + Each key-value pair represents an environment variable. + """ + + file: FileTypes + """ZIP file containing the application source directory""" + + force: bool + """Allow overwriting an existing app version""" + + region: Literal["aws.us-east-1a"] + """Region for deployment. Currently we only support "aws.us-east-1a" """ + + source: Source + """Source from which to fetch application code.""" + + version: str + """Version of the application. Can be any string.""" + + +class SourceAuth(TypedDict, total=False): + """Authentication for private repositories.""" + + token: Required[str] + """GitHub PAT or installation access token""" + + method: Required[Literal["github_token"]] + """Auth method""" + + +class Source(TypedDict, total=False): + """Source from which to fetch application code.""" + + entrypoint: Required[str] + """Relative path to the application entrypoint within the selected path.""" + + ref: Required[str] + """Git ref (branch, tag, or commit SHA) to fetch.""" + + type: Required[Literal["github"]] + """Source type identifier.""" + + url: Required[str] + """Base repository URL (without blob/tree suffixes).""" + + auth: SourceAuth + """Authentication for private repositories.""" + + path: str + """Path within the repo to deploy (omit to use repo root).""" diff --git a/src/kernel/types/deployment_create_response.py b/src/kernel/types/deployment_create_response.py new file mode 100644 index 00000000..439ed52a --- /dev/null +++ b/src/kernel/types/deployment_create_response.py @@ -0,0 +1,75 @@ +# File generated from our OpenAPI spec by Stainless. See CONTRIBUTING.md for details. + +from typing import Dict, Optional +from datetime import datetime +from typing_extensions import Literal + +from .._models import BaseModel + +__all__ = ["DeploymentCreateResponse"] + + +class DeploymentCreateResponse(BaseModel): + """Deployment record information.""" + + id: str + """Unique identifier for the deployment""" + + created_at: datetime + """Timestamp when the deployment was created""" + + region: Literal["aws.us-east-1a"] + """Deployment region code""" + + status: Literal["queued", "in_progress", "running", "failed", "stopped"] + """Current status of the deployment""" + + entrypoint_rel_path: Optional[str] = None + """Relative path to the application entrypoint""" + + env_vars: Optional[Dict[str, str]] = None + """Environment variables configured for this deployment. + + Values are redacted for API key, OAuth, and managed-auth callers, which receive + every key with an empty string value. Only dashboard sessions receive the actual + values. + """ + + source_checksum: Optional[str] = None + """Hex-encoded SHA-256 checksum of the source archive. + + For file uploads, this hashes the uploaded archive; for GitHub-sourced + deployments, this hashes the GitHub archive downloaded by the API. Omitted for + deployments created before this field was recorded. + """ + + source_path: Optional[str] = None + """ + For GitHub-sourced deployments, the subpath within the repository that was used + as the deploy root. Omitted when the repo root was used or for file uploads. + """ + + source_ref: Optional[str] = None + """ + For GitHub-sourced deployments, the git ref as requested at deploy time (branch, + tag, or commit SHA — not resolved to a commit). Omitted for file uploads. + """ + + source_type: Optional[Literal["file", "github"]] = None + """Origin of the deployed source code. + + This is read-only response provenance; `file` indicates an uploaded archive and + `github` indicates a repository fetched by the API. + """ + + source_url: Optional[str] = None + """For GitHub-sourced deployments, the repository URL that was fetched. + + Omitted for file uploads. + """ + + status_reason: Optional[str] = None + """Status reason""" + + updated_at: Optional[datetime] = None + """Timestamp when the deployment was last updated""" diff --git a/src/kernel/types/deployment_follow_params.py b/src/kernel/types/deployment_follow_params.py new file mode 100644 index 00000000..861f161e --- /dev/null +++ b/src/kernel/types/deployment_follow_params.py @@ -0,0 +1,12 @@ +# File generated from our OpenAPI spec by Stainless. See CONTRIBUTING.md for details. + +from __future__ import annotations + +from typing_extensions import TypedDict + +__all__ = ["DeploymentFollowParams"] + + +class DeploymentFollowParams(TypedDict, total=False): + since: str + """Show logs since the given time (RFC timestamps or durations like 5m).""" diff --git a/src/kernel/types/deployment_follow_response.py b/src/kernel/types/deployment_follow_response.py new file mode 100644 index 00000000..36087a67 --- /dev/null +++ b/src/kernel/types/deployment_follow_response.py @@ -0,0 +1,52 @@ +# File generated from our OpenAPI spec by Stainless. See CONTRIBUTING.md for details. + +from typing import Dict, List, Union, Optional +from datetime import datetime +from typing_extensions import Literal, Annotated, TypeAlias + +from .._utils import PropertyInfo +from .._models import BaseModel +from .shared.log_event import LogEvent +from .shared.app_action import AppAction +from .shared.error_event import ErrorEvent +from .deployment_state_event import DeploymentStateEvent +from .shared.heartbeat_event import HeartbeatEvent + +__all__ = ["DeploymentFollowResponse", "AppVersionSummaryEvent"] + + +class AppVersionSummaryEvent(BaseModel): + """Summary of an application version.""" + + id: str + """Unique identifier for the app version""" + + actions: List[AppAction] + """List of actions available on the app""" + + app_name: str + """Name of the application""" + + event: Literal["app_version_summary"] + """Event type identifier (always "app_version_summary").""" + + region: Literal["aws.us-east-1a"] + """Deployment region code""" + + timestamp: datetime + """Time the state was reported.""" + + version: str + """Version label for the application""" + + env_vars: Optional[Dict[str, str]] = None + """Environment variables configured for this app version. + + Not currently populated on streamed app_version_summary events. + """ + + +DeploymentFollowResponse: TypeAlias = Annotated[ + Union[LogEvent, DeploymentStateEvent, AppVersionSummaryEvent, ErrorEvent, HeartbeatEvent], + PropertyInfo(discriminator="event"), +] diff --git a/src/kernel/types/deployment_list_params.py b/src/kernel/types/deployment_list_params.py new file mode 100644 index 00000000..7ee96dac --- /dev/null +++ b/src/kernel/types/deployment_list_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_extensions import TypedDict + +__all__ = ["DeploymentListParams"] + + +class DeploymentListParams(TypedDict, total=False): + app_name: str + """Filter results by application name.""" + + app_version: str + """Filter results by application version. Requires app_name to be set.""" + + limit: int + """Limit the number of deployments to return.""" + + offset: int + """Offset the number of deployments to return.""" + + query: str + """Search deployments by ID or app name.""" diff --git a/src/kernel/types/deployment_list_response.py b/src/kernel/types/deployment_list_response.py new file mode 100644 index 00000000..accd0d05 --- /dev/null +++ b/src/kernel/types/deployment_list_response.py @@ -0,0 +1,75 @@ +# File generated from our OpenAPI spec by Stainless. See CONTRIBUTING.md for details. + +from typing import Dict, Optional +from datetime import datetime +from typing_extensions import Literal + +from .._models import BaseModel + +__all__ = ["DeploymentListResponse"] + + +class DeploymentListResponse(BaseModel): + """Deployment record information.""" + + id: str + """Unique identifier for the deployment""" + + created_at: datetime + """Timestamp when the deployment was created""" + + region: Literal["aws.us-east-1a"] + """Deployment region code""" + + status: Literal["queued", "in_progress", "running", "failed", "stopped"] + """Current status of the deployment""" + + entrypoint_rel_path: Optional[str] = None + """Relative path to the application entrypoint""" + + env_vars: Optional[Dict[str, str]] = None + """Environment variables configured for this deployment. + + Values are redacted for API key, OAuth, and managed-auth callers, which receive + every key with an empty string value. Only dashboard sessions receive the actual + values. + """ + + source_checksum: Optional[str] = None + """Hex-encoded SHA-256 checksum of the source archive. + + For file uploads, this hashes the uploaded archive; for GitHub-sourced + deployments, this hashes the GitHub archive downloaded by the API. Omitted for + deployments created before this field was recorded. + """ + + source_path: Optional[str] = None + """ + For GitHub-sourced deployments, the subpath within the repository that was used + as the deploy root. Omitted when the repo root was used or for file uploads. + """ + + source_ref: Optional[str] = None + """ + For GitHub-sourced deployments, the git ref as requested at deploy time (branch, + tag, or commit SHA — not resolved to a commit). Omitted for file uploads. + """ + + source_type: Optional[Literal["file", "github"]] = None + """Origin of the deployed source code. + + This is read-only response provenance; `file` indicates an uploaded archive and + `github` indicates a repository fetched by the API. + """ + + source_url: Optional[str] = None + """For GitHub-sourced deployments, the repository URL that was fetched. + + Omitted for file uploads. + """ + + status_reason: Optional[str] = None + """Status reason""" + + updated_at: Optional[datetime] = None + """Timestamp when the deployment was last updated""" diff --git a/src/kernel/types/deployment_retrieve_response.py b/src/kernel/types/deployment_retrieve_response.py new file mode 100644 index 00000000..2f4cf49e --- /dev/null +++ b/src/kernel/types/deployment_retrieve_response.py @@ -0,0 +1,75 @@ +# File generated from our OpenAPI spec by Stainless. See CONTRIBUTING.md for details. + +from typing import Dict, Optional +from datetime import datetime +from typing_extensions import Literal + +from .._models import BaseModel + +__all__ = ["DeploymentRetrieveResponse"] + + +class DeploymentRetrieveResponse(BaseModel): + """Deployment record information.""" + + id: str + """Unique identifier for the deployment""" + + created_at: datetime + """Timestamp when the deployment was created""" + + region: Literal["aws.us-east-1a"] + """Deployment region code""" + + status: Literal["queued", "in_progress", "running", "failed", "stopped"] + """Current status of the deployment""" + + entrypoint_rel_path: Optional[str] = None + """Relative path to the application entrypoint""" + + env_vars: Optional[Dict[str, str]] = None + """Environment variables configured for this deployment. + + Values are redacted for API key, OAuth, and managed-auth callers, which receive + every key with an empty string value. Only dashboard sessions receive the actual + values. + """ + + source_checksum: Optional[str] = None + """Hex-encoded SHA-256 checksum of the source archive. + + For file uploads, this hashes the uploaded archive; for GitHub-sourced + deployments, this hashes the GitHub archive downloaded by the API. Omitted for + deployments created before this field was recorded. + """ + + source_path: Optional[str] = None + """ + For GitHub-sourced deployments, the subpath within the repository that was used + as the deploy root. Omitted when the repo root was used or for file uploads. + """ + + source_ref: Optional[str] = None + """ + For GitHub-sourced deployments, the git ref as requested at deploy time (branch, + tag, or commit SHA — not resolved to a commit). Omitted for file uploads. + """ + + source_type: Optional[Literal["file", "github"]] = None + """Origin of the deployed source code. + + This is read-only response provenance; `file` indicates an uploaded archive and + `github` indicates a repository fetched by the API. + """ + + source_url: Optional[str] = None + """For GitHub-sourced deployments, the repository URL that was fetched. + + Omitted for file uploads. + """ + + status_reason: Optional[str] = None + """Status reason""" + + updated_at: Optional[datetime] = None + """Timestamp when the deployment was last updated""" diff --git a/src/kernel/types/deployment_state_event.py b/src/kernel/types/deployment_state_event.py new file mode 100644 index 00000000..64835725 --- /dev/null +++ b/src/kernel/types/deployment_state_event.py @@ -0,0 +1,88 @@ +# File generated from our OpenAPI spec by Stainless. See CONTRIBUTING.md for details. + +from typing import Dict, Optional +from datetime import datetime +from typing_extensions import Literal + +from .._models import BaseModel + +__all__ = ["DeploymentStateEvent", "Deployment"] + + +class Deployment(BaseModel): + """Deployment record information.""" + + id: str + """Unique identifier for the deployment""" + + created_at: datetime + """Timestamp when the deployment was created""" + + region: Literal["aws.us-east-1a"] + """Deployment region code""" + + status: Literal["queued", "in_progress", "running", "failed", "stopped"] + """Current status of the deployment""" + + entrypoint_rel_path: Optional[str] = None + """Relative path to the application entrypoint""" + + env_vars: Optional[Dict[str, str]] = None + """Environment variables configured for this deployment. + + Values are redacted for API key, OAuth, and managed-auth callers, which receive + every key with an empty string value. Only dashboard sessions receive the actual + values. + """ + + source_checksum: Optional[str] = None + """Hex-encoded SHA-256 checksum of the source archive. + + For file uploads, this hashes the uploaded archive; for GitHub-sourced + deployments, this hashes the GitHub archive downloaded by the API. Omitted for + deployments created before this field was recorded. + """ + + source_path: Optional[str] = None + """ + For GitHub-sourced deployments, the subpath within the repository that was used + as the deploy root. Omitted when the repo root was used or for file uploads. + """ + + source_ref: Optional[str] = None + """ + For GitHub-sourced deployments, the git ref as requested at deploy time (branch, + tag, or commit SHA — not resolved to a commit). Omitted for file uploads. + """ + + source_type: Optional[Literal["file", "github"]] = None + """Origin of the deployed source code. + + This is read-only response provenance; `file` indicates an uploaded archive and + `github` indicates a repository fetched by the API. + """ + + source_url: Optional[str] = None + """For GitHub-sourced deployments, the repository URL that was fetched. + + Omitted for file uploads. + """ + + status_reason: Optional[str] = None + """Status reason""" + + updated_at: Optional[datetime] = None + """Timestamp when the deployment was last updated""" + + +class DeploymentStateEvent(BaseModel): + """An event representing the current state of a deployment.""" + + deployment: Deployment + """Deployment record information.""" + + event: Literal["deployment_state"] + """Event type identifier (always "deployment_state").""" + + timestamp: datetime + """Time the state was reported.""" diff --git a/src/kernel/types/extension_download_from_chrome_store_params.py b/src/kernel/types/extension_download_from_chrome_store_params.py new file mode 100644 index 00000000..e9ca538c --- /dev/null +++ b/src/kernel/types/extension_download_from_chrome_store_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__ = ["ExtensionDownloadFromChromeStoreParams"] + + +class ExtensionDownloadFromChromeStoreParams(TypedDict, total=False): + url: Required[str] + """Chrome Web Store URL for the extension.""" + + os: Literal["win", "mac", "linux"] + """Target operating system for the extension package. Defaults to linux.""" diff --git a/src/kernel/types/extension_get_response.py b/src/kernel/types/extension_get_response.py new file mode 100644 index 00000000..a4d10b1f --- /dev/null +++ b/src/kernel/types/extension_get_response.py @@ -0,0 +1,39 @@ +# File generated from our OpenAPI spec by Stainless. See CONTRIBUTING.md for details. + +from typing import Optional +from datetime import datetime + +from .._models import BaseModel + +__all__ = ["ExtensionGetResponse"] + + +class ExtensionGetResponse(BaseModel): + """A browser extension uploaded to Kernel.""" + + id: str + """Unique identifier for the extension""" + + created_at: datetime + """Timestamp when the extension was created""" + + size_bytes: int + """Size of the extension archive in bytes""" + + checksum: Optional[str] = None + """ + SHA-256 checksum, encoded as lowercase hexadecimal, of the exact uploaded + extension archive bytes. This is not a normalized checksum of the extension + contents; archive metadata, file ordering, and compression can change the + checksum for otherwise identical contents. Omitted for legacy rows and + server-repackaged Chrome Web Store extensions. + """ + + last_used_at: Optional[datetime] = None + """Timestamp when the extension was last used""" + + name: Optional[str] = None + """Optional, easier-to-reference name for the extension. + + Must be unique within the project. + """ diff --git a/src/kernel/types/extension_list_params.py b/src/kernel/types/extension_list_params.py new file mode 100644 index 00000000..4c6669a9 --- /dev/null +++ b/src/kernel/types/extension_list_params.py @@ -0,0 +1,29 @@ +# File generated from our OpenAPI spec by Stainless. See CONTRIBUTING.md for details. + +from __future__ import annotations + +from typing_extensions import TypedDict + +__all__ = ["ExtensionListParams"] + + +class ExtensionListParams(TypedDict, total=False): + limit: int + """Limit the number of extensions to return.""" + + name: str + """Exact-match filter on extension name using the database collation. + + In production, matching is case- and accent-insensitive. During the + default-project migration, unscoped requests prefer a concrete default-project + extension over a legacy unscoped extension with the same name. + """ + + offset: int + """Offset the number of extensions to return.""" + + query: str + """Case-insensitive substring match against extension name. + + IDs match by exact value. + """ diff --git a/src/kernel/types/extension_list_response.py b/src/kernel/types/extension_list_response.py new file mode 100644 index 00000000..d7d0ea0d --- /dev/null +++ b/src/kernel/types/extension_list_response.py @@ -0,0 +1,39 @@ +# File generated from our OpenAPI spec by Stainless. See CONTRIBUTING.md for details. + +from typing import Optional +from datetime import datetime + +from .._models import BaseModel + +__all__ = ["ExtensionListResponse"] + + +class ExtensionListResponse(BaseModel): + """A browser extension uploaded to Kernel.""" + + id: str + """Unique identifier for the extension""" + + created_at: datetime + """Timestamp when the extension was created""" + + size_bytes: int + """Size of the extension archive in bytes""" + + checksum: Optional[str] = None + """ + SHA-256 checksum, encoded as lowercase hexadecimal, of the exact uploaded + extension archive bytes. This is not a normalized checksum of the extension + contents; archive metadata, file ordering, and compression can change the + checksum for otherwise identical contents. Omitted for legacy rows and + server-repackaged Chrome Web Store extensions. + """ + + last_used_at: Optional[datetime] = None + """Timestamp when the extension was last used""" + + name: Optional[str] = None + """Optional, easier-to-reference name for the extension. + + Must be unique within the project. + """ diff --git a/src/kernel/types/extension_upload_params.py b/src/kernel/types/extension_upload_params.py new file mode 100644 index 00000000..ab44d3eb --- /dev/null +++ b/src/kernel/types/extension_upload_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, TypedDict + +from .._types import FileTypes + +__all__ = ["ExtensionUploadParams"] + + +class ExtensionUploadParams(TypedDict, total=False): + file: Required[FileTypes] + """ZIP file containing the browser extension.""" + + name: str + """Optional unique name within the project to reference this extension.""" diff --git a/src/kernel/types/extension_upload_response.py b/src/kernel/types/extension_upload_response.py new file mode 100644 index 00000000..b44390d4 --- /dev/null +++ b/src/kernel/types/extension_upload_response.py @@ -0,0 +1,39 @@ +# File generated from our OpenAPI spec by Stainless. See CONTRIBUTING.md for details. + +from typing import Optional +from datetime import datetime + +from .._models import BaseModel + +__all__ = ["ExtensionUploadResponse"] + + +class ExtensionUploadResponse(BaseModel): + """A browser extension uploaded to Kernel.""" + + id: str + """Unique identifier for the extension""" + + created_at: datetime + """Timestamp when the extension was created""" + + size_bytes: int + """Size of the extension archive in bytes""" + + checksum: Optional[str] = None + """ + SHA-256 checksum, encoded as lowercase hexadecimal, of the exact uploaded + extension archive bytes. This is not a normalized checksum of the extension + contents; archive metadata, file ordering, and compression can change the + checksum for otherwise identical contents. Omitted for legacy rows and + server-repackaged Chrome Web Store extensions. + """ + + last_used_at: Optional[datetime] = None + """Timestamp when the extension was last used""" + + name: Optional[str] = None + """Optional, easier-to-reference name for the extension. + + Must be unique within the project. + """ diff --git a/src/kernel/types/invocation_create_params.py b/src/kernel/types/invocation_create_params.py new file mode 100644 index 00000000..288656a6 --- /dev/null +++ b/src/kernel/types/invocation_create_params.py @@ -0,0 +1,35 @@ +# 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__ = ["InvocationCreateParams"] + + +class InvocationCreateParams(TypedDict, total=False): + action_name: Required[str] + """Name of the action to invoke""" + + app_name: Required[str] + """Name of the application""" + + version: Required[str] + """Version of the application""" + + async_: Annotated[bool, PropertyInfo(alias="async")] + """If true, invoke asynchronously. + + When set, the API responds 202 Accepted with status "queued". + """ + + async_timeout_seconds: int + """Timeout in seconds for async invocations (min 10, max 3600). + + Only applies when async is true. + """ + + payload: str + """Input data for the action, sent as a JSON string.""" diff --git a/src/kernel/types/invocation_create_response.py b/src/kernel/types/invocation_create_response.py new file mode 100644 index 00000000..21fbcf33 --- /dev/null +++ b/src/kernel/types/invocation_create_response.py @@ -0,0 +1,28 @@ +# 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__ = ["InvocationCreateResponse"] + + +class InvocationCreateResponse(BaseModel): + id: str + """ID of the invocation""" + + action_name: str + """Name of the action invoked""" + + status: Literal["queued", "running", "succeeded", "failed"] + """Status of the invocation""" + + output: Optional[str] = None + """The return value of the action that was invoked, rendered as a JSON string. + + This could be: string, number, boolean, array, object, or null. + """ + + status_reason: Optional[str] = None + """Status reason""" diff --git a/src/kernel/types/invocation_follow_params.py b/src/kernel/types/invocation_follow_params.py new file mode 100644 index 00000000..67847810 --- /dev/null +++ b/src/kernel/types/invocation_follow_params.py @@ -0,0 +1,12 @@ +# File generated from our OpenAPI spec by Stainless. See CONTRIBUTING.md for details. + +from __future__ import annotations + +from typing_extensions import TypedDict + +__all__ = ["InvocationFollowParams"] + + +class InvocationFollowParams(TypedDict, total=False): + since: str + """Show logs since the given time (RFC timestamps or durations like 5m).""" diff --git a/src/kernel/types/invocation_follow_response.py b/src/kernel/types/invocation_follow_response.py new file mode 100644 index 00000000..2effbde7 --- /dev/null +++ b/src/kernel/types/invocation_follow_response.py @@ -0,0 +1,16 @@ +# 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 .shared.log_event import LogEvent +from .shared.error_event import ErrorEvent +from .invocation_state_event import InvocationStateEvent +from .shared.heartbeat_event import HeartbeatEvent + +__all__ = ["InvocationFollowResponse"] + +InvocationFollowResponse: TypeAlias = Annotated[ + Union[LogEvent, InvocationStateEvent, ErrorEvent, HeartbeatEvent], PropertyInfo(discriminator="event") +] diff --git a/src/kernel/types/invocation_list_browsers_response.py b/src/kernel/types/invocation_list_browsers_response.py new file mode 100644 index 00000000..a37d180a --- /dev/null +++ b/src/kernel/types/invocation_list_browsers_response.py @@ -0,0 +1,118 @@ +# File generated from our OpenAPI spec by Stainless. See CONTRIBUTING.md for details. + +from typing import Dict, List, Optional +from datetime import datetime + +from .tags import Tags +from .profile import Profile +from .._models import BaseModel +from .browser_usage import BrowserUsage +from .browser_pool_ref import BrowserPoolRef +from .shared.browser_viewport import BrowserViewport +from .browsers.browser_telemetry_config import BrowserTelemetryConfig + +__all__ = ["InvocationListBrowsersResponse", "Browser"] + + +class Browser(BaseModel): + cdp_ws_url: str + """Websocket URL for Chrome DevTools Protocol connections to the browser session""" + + created_at: datetime + """When the browser session was created.""" + + headless: bool + """Whether the browser session is running in headless mode.""" + + session_id: str + """Unique identifier for the browser session""" + + stealth: bool + """Whether the browser session is running in stealth mode.""" + + timeout_seconds: int + """The number of seconds of inactivity before the browser session is terminated.""" + + webdriver_ws_url: str + """Websocket URL for WebDriver BiDi connections to the browser session""" + + base_url: Optional[str] = None + """Metro-API HTTP base URL for this browser session.""" + + browser_live_view_url: Optional[str] = None + """Remote URL for live viewing the browser session. + + Only available for non-headless browsers. + """ + + chrome_policy: Optional[Dict[str, object]] = None + """ + Custom Chrome enterprise policy overrides that were applied to this browser + session, if any. Echoed back for verification. Keys are Chrome enterprise policy + names. + """ + + deleted_at: Optional[datetime] = None + """When the browser session was soft-deleted. Only present for deleted sessions.""" + + gpu: Optional[bool] = None + """ + Whether GPU acceleration is enabled for the browser session (only supported for + headful sessions). + """ + + kiosk_mode: Optional[bool] = None + """Whether the browser session is running in kiosk mode.""" + + name: Optional[str] = None + """Human-readable name of the browser session, if one was set at creation.""" + + pool: Optional[BrowserPoolRef] = None + """Browser pool this session was acquired from, if any.""" + + profile: Optional[Profile] = None + """Browser profile metadata.""" + + proxy_id: Optional[str] = None + """ID of the proxy associated with this browser session, if any.""" + + start_url: Optional[str] = None + """URL the session was asked to navigate to on creation, if any. + + Recorded for debugging. Navigation is fire-and-forget — the URL is dispatched to + the browser without waiting for it to load, and any errors (DNS failure, bad + status, timeout) are silently dropped. Captures what was requested, not what the + browser actually loaded. + """ + + tags: Optional[Tags] = None + """User-defined key-value tags that were set on this browser session, if any. + + Echoed back when present. + """ + + telemetry: Optional[BrowserTelemetryConfig] = None + """Active telemetry configuration for the session, if any.""" + + usage: Optional[BrowserUsage] = None + """Session usage metrics.""" + + viewport: Optional[BrowserViewport] = None + """ + Initial browser window size in pixels with optional refresh rate. If omitted, + image defaults apply (1920x1080@25). For GPU images, the default is + 1920x1080@60. Arbitrary viewport dimensions and refresh rates are accepted. + Known-good presets include: 2560x1440@10, 1920x1080@25, 1920x1200@25, + 1440x900@25, 1280x800@60, 1024x768@60, 1200x800@60, 768x1024@60, 390x844@60. For + GPU images, recommended presets use one of these resolutions with refresh rates + 60, 30, 25, or 10: 800x600, 960x720, 1024x576, 1024x768, 1152x648, 1200x800, + 1280x720, 1368x768, 1440x900, 1600x900, 1920x1080, 1920x1200, 390x844, 360x250, + 768x1024, 800x1600. Viewports outside this list may exhibit unstable live view + or recording behavior. If refresh_rate is not provided, it will be automatically + determined based on the resolution (higher resolutions use lower refresh rates + to keep bandwidth reasonable). + """ + + +class InvocationListBrowsersResponse(BaseModel): + browsers: List[Browser] diff --git a/src/kernel/types/invocation_list_params.py b/src/kernel/types/invocation_list_params.py new file mode 100644 index 00000000..2bcfe9c7 --- /dev/null +++ b/src/kernel/types/invocation_list_params.py @@ -0,0 +1,39 @@ +# File generated from our OpenAPI spec by Stainless. See CONTRIBUTING.md for details. + +from __future__ import annotations + +from typing_extensions import Literal, TypedDict + +__all__ = ["InvocationListParams"] + + +class InvocationListParams(TypedDict, total=False): + action_name: str + """Filter results by action name.""" + + app_name: str + """Filter results by application name.""" + + deployment_id: str + """Filter results by deployment ID.""" + + limit: int + """Limit the number of invocations to return.""" + + offset: int + """Offset the number of invocations to return.""" + + query: str + """Search invocations by ID, app name, or action name.""" + + since: str + """ + Show invocations that have started since the given time (RFC timestamps or + durations like 5m). + """ + + status: Literal["queued", "running", "succeeded", "failed"] + """Filter results by invocation status.""" + + version: str + """Filter results by application version.""" diff --git a/src/kernel/types/invocation_list_response.py b/src/kernel/types/invocation_list_response.py new file mode 100644 index 00000000..e635b4d8 --- /dev/null +++ b/src/kernel/types/invocation_list_response.py @@ -0,0 +1,50 @@ +# File generated from our OpenAPI spec by Stainless. See CONTRIBUTING.md for details. + +from typing import Optional +from datetime import datetime +from typing_extensions import Literal + +from .._models import BaseModel + +__all__ = ["InvocationListResponse"] + + +class InvocationListResponse(BaseModel): + id: str + """ID of the invocation""" + + action_name: str + """Name of the action invoked""" + + app_name: str + """Name of the application""" + + started_at: datetime + """RFC 3339 Nanoseconds timestamp when the invocation started""" + + status: Literal["queued", "running", "succeeded", "failed"] + """Status of the invocation""" + + version: str + """Version label for the application""" + + finished_at: Optional[datetime] = None + """ + RFC 3339 Nanoseconds timestamp when the invocation finished (null if still + running) + """ + + output: Optional[str] = None + """Output produced by the action, rendered as a JSON string. + + This could be: string, number, boolean, array, object, or null. + """ + + payload: Optional[str] = None + """Payload provided to the invocation. + + This is a string that can be parsed as JSON. + """ + + status_reason: Optional[str] = None + """Status reason""" diff --git a/src/kernel/types/invocation_retrieve_response.py b/src/kernel/types/invocation_retrieve_response.py new file mode 100644 index 00000000..580424eb --- /dev/null +++ b/src/kernel/types/invocation_retrieve_response.py @@ -0,0 +1,50 @@ +# File generated from our OpenAPI spec by Stainless. See CONTRIBUTING.md for details. + +from typing import Optional +from datetime import datetime +from typing_extensions import Literal + +from .._models import BaseModel + +__all__ = ["InvocationRetrieveResponse"] + + +class InvocationRetrieveResponse(BaseModel): + id: str + """ID of the invocation""" + + action_name: str + """Name of the action invoked""" + + app_name: str + """Name of the application""" + + started_at: datetime + """RFC 3339 Nanoseconds timestamp when the invocation started""" + + status: Literal["queued", "running", "succeeded", "failed"] + """Status of the invocation""" + + version: str + """Version label for the application""" + + finished_at: Optional[datetime] = None + """ + RFC 3339 Nanoseconds timestamp when the invocation finished (null if still + running) + """ + + output: Optional[str] = None + """Output produced by the action, rendered as a JSON string. + + This could be: string, number, boolean, array, object, or null. + """ + + payload: Optional[str] = None + """Payload provided to the invocation. + + This is a string that can be parsed as JSON. + """ + + status_reason: Optional[str] = None + """Status reason""" diff --git a/src/kernel/types/invocation_state_event.py b/src/kernel/types/invocation_state_event.py new file mode 100644 index 00000000..f32bf8e9 --- /dev/null +++ b/src/kernel/types/invocation_state_event.py @@ -0,0 +1,62 @@ +# File generated from our OpenAPI spec by Stainless. See CONTRIBUTING.md for details. + +from typing import Optional +from datetime import datetime +from typing_extensions import Literal + +from .._models import BaseModel + +__all__ = ["InvocationStateEvent", "Invocation"] + + +class Invocation(BaseModel): + id: str + """ID of the invocation""" + + action_name: str + """Name of the action invoked""" + + app_name: str + """Name of the application""" + + started_at: datetime + """RFC 3339 Nanoseconds timestamp when the invocation started""" + + status: Literal["queued", "running", "succeeded", "failed"] + """Status of the invocation""" + + version: str + """Version label for the application""" + + finished_at: Optional[datetime] = None + """ + RFC 3339 Nanoseconds timestamp when the invocation finished (null if still + running) + """ + + output: Optional[str] = None + """Output produced by the action, rendered as a JSON string. + + This could be: string, number, boolean, array, object, or null. + """ + + payload: Optional[str] = None + """Payload provided to the invocation. + + This is a string that can be parsed as JSON. + """ + + status_reason: Optional[str] = None + """Status reason""" + + +class InvocationStateEvent(BaseModel): + """An event representing the current state of an invocation.""" + + event: Literal["invocation_state"] + """Event type identifier (always "invocation_state").""" + + invocation: Invocation + + timestamp: datetime + """Time the state was reported.""" diff --git a/src/kernel/types/invocation_update_params.py b/src/kernel/types/invocation_update_params.py new file mode 100644 index 00000000..72ccf5d9 --- /dev/null +++ b/src/kernel/types/invocation_update_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__ = ["InvocationUpdateParams"] + + +class InvocationUpdateParams(TypedDict, total=False): + status: Required[Literal["succeeded", "failed"]] + """New status for the invocation.""" + + output: str + """Updated output of the invocation rendered as JSON string.""" diff --git a/src/kernel/types/invocation_update_response.py b/src/kernel/types/invocation_update_response.py new file mode 100644 index 00000000..3bcc8bc0 --- /dev/null +++ b/src/kernel/types/invocation_update_response.py @@ -0,0 +1,50 @@ +# File generated from our OpenAPI spec by Stainless. See CONTRIBUTING.md for details. + +from typing import Optional +from datetime import datetime +from typing_extensions import Literal + +from .._models import BaseModel + +__all__ = ["InvocationUpdateResponse"] + + +class InvocationUpdateResponse(BaseModel): + id: str + """ID of the invocation""" + + action_name: str + """Name of the action invoked""" + + app_name: str + """Name of the application""" + + started_at: datetime + """RFC 3339 Nanoseconds timestamp when the invocation started""" + + status: Literal["queued", "running", "succeeded", "failed"] + """Status of the invocation""" + + version: str + """Version label for the application""" + + finished_at: Optional[datetime] = None + """ + RFC 3339 Nanoseconds timestamp when the invocation finished (null if still + running) + """ + + output: Optional[str] = None + """Output produced by the action, rendered as a JSON string. + + This could be: string, number, boolean, array, object, or null. + """ + + payload: Optional[str] = None + """Payload provided to the invocation. + + This is a string that can be parsed as JSON. + """ + + status_reason: Optional[str] = None + """Status reason""" diff --git a/src/kernel/types/organization/__init__.py b/src/kernel/types/organization/__init__.py new file mode 100644 index 00000000..d2411f9a --- /dev/null +++ b/src/kernel/types/organization/__init__.py @@ -0,0 +1,6 @@ +# File generated from our OpenAPI spec by Stainless. See CONTRIBUTING.md for details. + +from __future__ import annotations + +from .org_limits import OrgLimits as OrgLimits +from .limit_update_params import LimitUpdateParams as LimitUpdateParams diff --git a/src/kernel/types/organization/limit_update_params.py b/src/kernel/types/organization/limit_update_params.py new file mode 100644 index 00000000..4c85f9c1 --- /dev/null +++ b/src/kernel/types/organization/limit_update_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 import Optional +from typing_extensions import TypedDict + +__all__ = ["LimitUpdateParams"] + + +class LimitUpdateParams(TypedDict, total=False): + default_project_max_concurrent_sessions: Optional[int] + """Default maximum concurrent browsers for projects without an explicit override. + + Set to 0 to remove the default; omit to leave unchanged. Cannot exceed the + organization's concurrency limit. + """ diff --git a/src/kernel/types/organization/org_limits.py b/src/kernel/types/organization/org_limits.py new file mode 100644 index 00000000..3d614996 --- /dev/null +++ b/src/kernel/types/organization/org_limits.py @@ -0,0 +1,25 @@ +# File generated from our OpenAPI spec by Stainless. See CONTRIBUTING.md for details. + +from typing import Optional + +from ..._models import BaseModel + +__all__ = ["OrgLimits"] + + +class OrgLimits(BaseModel): + default_project_max_concurrent_sessions: Optional[int] = None + """ + Default maximum concurrent browsers applied to every project that has no + explicit per-project override. Null means no org-level default, so such projects + are uncapped (only the org-wide limit applies). Applies to existing and newly + created projects. + """ + + max_concurrent_sessions: Optional[int] = None + """ + The organization's effective concurrency limit — the maximum browsers running at + once, covering both on-demand sessions and browser pool reservations — from its + plan or an override. Read-only and shared across all projects in the org; a + per-project default cannot exceed it. + """ diff --git a/src/kernel/types/profile.py b/src/kernel/types/profile.py new file mode 100644 index 00000000..e141aa06 --- /dev/null +++ b/src/kernel/types/profile.py @@ -0,0 +1,27 @@ +# File generated from our OpenAPI spec by Stainless. See CONTRIBUTING.md for details. + +from typing import Optional +from datetime import datetime + +from .._models import BaseModel + +__all__ = ["Profile"] + + +class Profile(BaseModel): + """Browser profile metadata.""" + + id: str + """Unique identifier for the profile""" + + created_at: datetime + """Timestamp when the profile was created""" + + last_used_at: Optional[datetime] = None + """Timestamp when the profile was last used""" + + name: Optional[str] = None + """Optional, easier-to-reference name for the profile""" + + updated_at: Optional[datetime] = None + """Timestamp when the profile was last updated""" diff --git a/src/kernel/types/profile_create_params.py b/src/kernel/types/profile_create_params.py new file mode 100644 index 00000000..8d1c2f4f --- /dev/null +++ b/src/kernel/types/profile_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 TypedDict + +__all__ = ["ProfileCreateParams"] + + +class ProfileCreateParams(TypedDict, total=False): + name: str + """Optional name of the profile. + + Must be unique within the logical project; during the default-project migration, + unscoped profiles and profiles in the org default project are treated as the + same project. + """ diff --git a/src/kernel/types/profile_download_params.py b/src/kernel/types/profile_download_params.py new file mode 100644 index 00000000..32bfa3dd --- /dev/null +++ b/src/kernel/types/profile_download_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, TypedDict + +__all__ = ["ProfileDownloadParams"] + + +class ProfileDownloadParams(TypedDict, total=False): + format: Literal["tar.zst", "tar"] + """Response format for current profile archives. + + Legacy profiles are always returned as JSON. + """ diff --git a/src/kernel/types/profile_list_params.py b/src/kernel/types/profile_list_params.py new file mode 100644 index 00000000..5a572f6b --- /dev/null +++ b/src/kernel/types/profile_list_params.py @@ -0,0 +1,26 @@ +# File generated from our OpenAPI spec by Stainless. See CONTRIBUTING.md for details. + +from __future__ import annotations + +from typing_extensions import TypedDict + +__all__ = ["ProfileListParams"] + + +class ProfileListParams(TypedDict, total=False): + limit: int + """Limit the number of profiles to return.""" + + name: str + """Exact-match filter on profile name using the database collation. + + In production, matching is case- and accent-insensitive. During the + default-project migration, unscoped requests prefer a concrete default-project + profile over a legacy unscoped profile with the same name. + """ + + offset: int + """Offset the number of profiles to return.""" + + query: str + """Case-insensitive substring match against profile name or ID.""" diff --git a/src/kernel/types/profile_update_params.py b/src/kernel/types/profile_update_params.py new file mode 100644 index 00000000..bd0165b7 --- /dev/null +++ b/src/kernel/types/profile_update_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, TypedDict + +__all__ = ["ProfileUpdateParams"] + + +class ProfileUpdateParams(TypedDict, total=False): + name: Required[str] + """New profile name. + + Must be unique within the logical project; during the default-project migration, + unscoped profiles and profiles in the org default project are treated as the + same project. + """ diff --git a/src/kernel/types/project.py b/src/kernel/types/project.py new file mode 100644 index 00000000..db2a377c --- /dev/null +++ b/src/kernel/types/project.py @@ -0,0 +1,25 @@ +# File generated from our OpenAPI spec by Stainless. See CONTRIBUTING.md for details. + +from datetime import datetime +from typing_extensions import Literal + +from .._models import BaseModel + +__all__ = ["Project"] + + +class Project(BaseModel): + id: str + """Unique project identifier""" + + created_at: datetime + """When the project was created""" + + name: str + """Project name""" + + status: Literal["active", "archived"] + """Project status""" + + updated_at: datetime + """When the project was last updated""" diff --git a/src/kernel/types/project_create_params.py b/src/kernel/types/project_create_params.py new file mode 100644 index 00000000..9c51c570 --- /dev/null +++ b/src/kernel/types/project_create_params.py @@ -0,0 +1,12 @@ +# File generated from our OpenAPI spec by Stainless. See CONTRIBUTING.md for details. + +from __future__ import annotations + +from typing_extensions import Required, TypedDict + +__all__ = ["ProjectCreateParams"] + + +class ProjectCreateParams(TypedDict, total=False): + name: Required[str] + """Project name (1-255 Unicode code points)""" diff --git a/src/kernel/types/project_list_params.py b/src/kernel/types/project_list_params.py new file mode 100644 index 00000000..dd307d17 --- /dev/null +++ b/src/kernel/types/project_list_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_extensions import TypedDict + +__all__ = ["ProjectListParams"] + + +class ProjectListParams(TypedDict, total=False): + limit: int + """Maximum number of results to return""" + + name: str + """Exact-match filter on project name using the database collation. + + In production, matching is case- and accent-insensitive. + """ + + offset: int + """Number of results to skip""" + + query: str + """Case-insensitive substring match against project name""" diff --git a/src/kernel/types/project_update_params.py b/src/kernel/types/project_update_params.py new file mode 100644 index 00000000..7b4359ff --- /dev/null +++ b/src/kernel/types/project_update_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, TypedDict + +__all__ = ["ProjectUpdateParams"] + + +class ProjectUpdateParams(TypedDict, total=False): + name: str + """New project name (1-255 Unicode code points)""" + + status: Literal["active", "archived"] + """New project status""" diff --git a/src/kernel/types/projects/__init__.py b/src/kernel/types/projects/__init__.py new file mode 100644 index 00000000..acf030c8 --- /dev/null +++ b/src/kernel/types/projects/__init__.py @@ -0,0 +1,6 @@ +# File generated from our OpenAPI spec by Stainless. See CONTRIBUTING.md for details. + +from __future__ import annotations + +from .project_limits import ProjectLimits as ProjectLimits +from .limit_update_params import LimitUpdateParams as LimitUpdateParams diff --git a/src/kernel/types/projects/limit_update_params.py b/src/kernel/types/projects/limit_update_params.py new file mode 100644 index 00000000..e8bd1b60 --- /dev/null +++ b/src/kernel/types/projects/limit_update_params.py @@ -0,0 +1,29 @@ +# File generated from our OpenAPI spec by Stainless. See CONTRIBUTING.md for details. + +from __future__ import annotations + +from typing import Optional +from typing_extensions import TypedDict + +__all__ = ["LimitUpdateParams"] + + +class LimitUpdateParams(TypedDict, total=False): + max_concurrent_invocations: Optional[int] + """Maximum concurrent app invocations for this project. + + Set to 0 to remove the cap; omit to leave unchanged. + """ + + max_concurrent_sessions: Optional[int] + """ + Maximum concurrent browsers for this project, covering both on-demand sessions + and browser pool reservations. Set to 0 to remove the cap; omit to leave + unchanged. + """ + + max_pooled_sessions: Optional[int] + """Deprecated: pooled browsers now count toward `max_concurrent_sessions`. + + Requests that set this field are rejected with a 400. + """ diff --git a/src/kernel/types/projects/project_limits.py b/src/kernel/types/projects/project_limits.py new file mode 100644 index 00000000..712e84c1 --- /dev/null +++ b/src/kernel/types/projects/project_limits.py @@ -0,0 +1,28 @@ +# File generated from our OpenAPI spec by Stainless. See CONTRIBUTING.md for details. + +from typing import Optional + +from ..._models import BaseModel + +__all__ = ["ProjectLimits"] + + +class ProjectLimits(BaseModel): + max_concurrent_invocations: Optional[int] = None + """Maximum concurrent app invocations for this project. + + Null means no project-level cap. + """ + + max_concurrent_sessions: Optional[int] = None + """ + Maximum concurrent browsers for this project, covering both on-demand sessions + (`browsers.create()`) and browser pool reservations. Null means no project-level + cap. + """ + + max_pooled_sessions: Optional[int] = None + """Deprecated: pooled browsers now count toward `max_concurrent_sessions`. + + Always null. + """ diff --git a/src/kernel/types/proxy_check_params.py b/src/kernel/types/proxy_check_params.py new file mode 100644 index 00000000..47f99f04 --- /dev/null +++ b/src/kernel/types/proxy_check_params.py @@ -0,0 +1,26 @@ +# File generated from our OpenAPI spec by Stainless. See CONTRIBUTING.md for details. + +from __future__ import annotations + +from typing_extensions import TypedDict + +__all__ = ["ProxyCheckParams"] + + +class ProxyCheckParams(TypedDict, total=False): + url: str + """An optional URL to test reachability against. + + If provided, the proxy check will test connectivity to this URL instead of the + default test URLs. Only HTTP and HTTPS schemes are allowed, and the URL must + resolve to a public IP address. For ISP and datacenter proxies, the exit IP is + stable, so a successful check reliably indicates that subsequent browser + sessions will reach the target site with the same IP. For residential and mobile + proxies, the exit node changes between requests, so a successful check validates + proxy configuration but does not guarantee that a subsequent browser session + will use the same exit IP or reach the same site — it is useful for verifying + credentials and connectivity, not for predicting site-specific behavior. When + provided, the check result does not update the proxy's health status, since a + failure may indicate a problem with the target site rather than the proxy + itself. + """ diff --git a/src/kernel/types/proxy_check_response.py b/src/kernel/types/proxy_check_response.py new file mode 100644 index 00000000..49833d66 --- /dev/null +++ b/src/kernel/types/proxy_check_response.py @@ -0,0 +1,131 @@ +# File generated from our OpenAPI spec by Stainless. See CONTRIBUTING.md for details. + +from typing import List, Union, Optional +from datetime import datetime +from typing_extensions import Literal, TypeAlias + +from .._models import BaseModel + +__all__ = [ + "ProxyCheckResponse", + "Config", + "ConfigDatacenterProxyConfig", + "ConfigIspProxyConfig", + "ConfigResidentialProxyConfig", + "ConfigMobileProxyConfig", + "ConfigCustomProxyConfig", +] + + +class ConfigDatacenterProxyConfig(BaseModel): + """Configuration for a datacenter proxy.""" + + country: Optional[str] = None + """ISO 3166 country code. Defaults to US if not provided.""" + + +class ConfigIspProxyConfig(BaseModel): + """Configuration for an ISP proxy.""" + + country: Optional[str] = None + """ISO 3166 country code. Defaults to US if not provided.""" + + +class ConfigResidentialProxyConfig(BaseModel): + """Configuration for residential proxies.""" + + asn: Optional[str] = None + """Autonomous system number. See https://bgp.potaroo.net/cidr/autnums.html""" + + city: Optional[str] = None + """City name (no spaces, e.g. + + `sanfrancisco`). If provided, `country` must also be provided. + """ + + country: Optional[str] = None + """ISO 3166 country code.""" + + os: Optional[Literal["windows", "macos", "android"]] = None + """Operating system of the residential device.""" + + state: Optional[str] = None + """Two-letter state code.""" + + zip: Optional[str] = None + """US ZIP code.""" + + +class ConfigMobileProxyConfig(BaseModel): + """Configuration for mobile proxies.""" + + city: Optional[str] = None + """Provider city alias. Mobile carrier routing can make observed geo vary.""" + + country: Optional[str] = None + """ISO 3166 country code""" + + state: Optional[str] = None + """US-only state code. Mobile carrier routing can make observed geo vary.""" + + +class ConfigCustomProxyConfig(BaseModel): + """Configuration for a custom proxy (e.g., private proxy server).""" + + host: str + """Proxy host address or IP.""" + + port: int + """Proxy port.""" + + has_ca_bundle: Optional[bool] = None + """Whether the proxy has a custom CA bundle configured.""" + + has_password: Optional[bool] = None + """Whether the proxy has a password.""" + + username: Optional[str] = None + """Username for proxy authentication.""" + + +Config: TypeAlias = Union[ + ConfigDatacenterProxyConfig, + ConfigIspProxyConfig, + ConfigResidentialProxyConfig, + ConfigMobileProxyConfig, + ConfigCustomProxyConfig, +] + + +class ProxyCheckResponse(BaseModel): + """Configuration for routing traffic through a proxy.""" + + type: Literal["datacenter", "isp", "residential", "mobile", "custom"] + """Proxy type to use. + + In terms of quality for avoiding bot-detection, from best to worst: `mobile` > + `residential` > `isp` > `datacenter`. + """ + + id: Optional[str] = None + + bypass_hosts: Optional[List[str]] = None + """Hostnames that should bypass the parent proxy and connect directly.""" + + config: Optional[Config] = None + """Configuration specific to the selected proxy `type`.""" + + ip_address: Optional[str] = None + """IP address that the proxy uses when making requests.""" + + last_checked: Optional[datetime] = None + """Timestamp of the last health check performed on this proxy.""" + + name: Optional[str] = None + """Readable name of the proxy.""" + + protocol: Optional[Literal["http", "https"]] = None + """Protocol to use for the proxy connection.""" + + status: Optional[Literal["available", "unavailable"]] = None + """Current health status of the proxy.""" diff --git a/src/kernel/types/proxy_create_params.py b/src/kernel/types/proxy_create_params.py new file mode 100644 index 00000000..7069d9b1 --- /dev/null +++ b/src/kernel/types/proxy_create_params.py @@ -0,0 +1,123 @@ +# 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 Literal, Required, TypeAlias, TypedDict + +from .._types import SequenceNotStr + +__all__ = [ + "ProxyCreateParams", + "Config", + "ConfigDatacenterProxyConfig", + "ConfigIspProxyConfig", + "ConfigResidentialProxyConfig", + "ConfigMobileProxyConfig", + "ConfigCreateCustomProxyConfig", +] + + +class ProxyCreateParams(TypedDict, total=False): + type: Required[Literal["datacenter", "isp", "residential", "mobile", "custom"]] + """Proxy type to use. + + In terms of quality for avoiding bot-detection, from best to worst: `mobile` > + `residential` > `isp` > `datacenter`. + """ + + bypass_hosts: SequenceNotStr[str] + """Hostnames that should bypass the parent proxy and connect directly.""" + + config: Config + """Configuration specific to the selected proxy `type`.""" + + name: str + """Readable name of the proxy.""" + + protocol: Literal["http", "https"] + """Protocol to use for the proxy connection.""" + + +class ConfigDatacenterProxyConfig(TypedDict, total=False): + """Configuration for a datacenter proxy.""" + + country: str + """ISO 3166 country code. Defaults to US if not provided.""" + + +class ConfigIspProxyConfig(TypedDict, total=False): + """Configuration for an ISP proxy.""" + + country: str + """ISO 3166 country code. Defaults to US if not provided.""" + + +class ConfigResidentialProxyConfig(TypedDict, total=False): + """Configuration for residential proxies.""" + + asn: str + """Autonomous system number. See https://bgp.potaroo.net/cidr/autnums.html""" + + city: str + """City name (no spaces, e.g. + + `sanfrancisco`). If provided, `country` must also be provided. + """ + + country: str + """ISO 3166 country code.""" + + os: Literal["windows", "macos", "android"] + """Operating system of the residential device.""" + + state: str + """Two-letter state code.""" + + zip: str + """US ZIP code.""" + + +class ConfigMobileProxyConfig(TypedDict, total=False): + """Configuration for mobile proxies.""" + + city: str + """Provider city alias. Mobile carrier routing can make observed geo vary.""" + + country: str + """ISO 3166 country code""" + + state: str + """US-only state code. Mobile carrier routing can make observed geo vary.""" + + +class ConfigCreateCustomProxyConfig(TypedDict, total=False): + """Configuration for a custom proxy (e.g., private proxy server).""" + + host: Required[str] + """Proxy host address or IP.""" + + port: Required[int] + """Proxy port.""" + + ca_bundle: str + """PEM-encoded CA certificate bundle the proxy re-signs upstream TLS with. + + Provide when the proxy terminates TLS (MITM) so the browser trusts its + certificates. May contain multiple concatenated certificates. + """ + + password: str + """Password for proxy authentication.""" + + username: str + """Username for proxy authentication.""" + + +Config: TypeAlias = Union[ + ConfigDatacenterProxyConfig, + ConfigIspProxyConfig, + ConfigResidentialProxyConfig, + ConfigMobileProxyConfig, + ConfigCreateCustomProxyConfig, +] diff --git a/src/kernel/types/proxy_create_response.py b/src/kernel/types/proxy_create_response.py new file mode 100644 index 00000000..eeddcc3a --- /dev/null +++ b/src/kernel/types/proxy_create_response.py @@ -0,0 +1,131 @@ +# File generated from our OpenAPI spec by Stainless. See CONTRIBUTING.md for details. + +from typing import List, Union, Optional +from datetime import datetime +from typing_extensions import Literal, TypeAlias + +from .._models import BaseModel + +__all__ = [ + "ProxyCreateResponse", + "Config", + "ConfigDatacenterProxyConfig", + "ConfigIspProxyConfig", + "ConfigResidentialProxyConfig", + "ConfigMobileProxyConfig", + "ConfigCustomProxyConfig", +] + + +class ConfigDatacenterProxyConfig(BaseModel): + """Configuration for a datacenter proxy.""" + + country: Optional[str] = None + """ISO 3166 country code. Defaults to US if not provided.""" + + +class ConfigIspProxyConfig(BaseModel): + """Configuration for an ISP proxy.""" + + country: Optional[str] = None + """ISO 3166 country code. Defaults to US if not provided.""" + + +class ConfigResidentialProxyConfig(BaseModel): + """Configuration for residential proxies.""" + + asn: Optional[str] = None + """Autonomous system number. See https://bgp.potaroo.net/cidr/autnums.html""" + + city: Optional[str] = None + """City name (no spaces, e.g. + + `sanfrancisco`). If provided, `country` must also be provided. + """ + + country: Optional[str] = None + """ISO 3166 country code.""" + + os: Optional[Literal["windows", "macos", "android"]] = None + """Operating system of the residential device.""" + + state: Optional[str] = None + """Two-letter state code.""" + + zip: Optional[str] = None + """US ZIP code.""" + + +class ConfigMobileProxyConfig(BaseModel): + """Configuration for mobile proxies.""" + + city: Optional[str] = None + """Provider city alias. Mobile carrier routing can make observed geo vary.""" + + country: Optional[str] = None + """ISO 3166 country code""" + + state: Optional[str] = None + """US-only state code. Mobile carrier routing can make observed geo vary.""" + + +class ConfigCustomProxyConfig(BaseModel): + """Configuration for a custom proxy (e.g., private proxy server).""" + + host: str + """Proxy host address or IP.""" + + port: int + """Proxy port.""" + + has_ca_bundle: Optional[bool] = None + """Whether the proxy has a custom CA bundle configured.""" + + has_password: Optional[bool] = None + """Whether the proxy has a password.""" + + username: Optional[str] = None + """Username for proxy authentication.""" + + +Config: TypeAlias = Union[ + ConfigDatacenterProxyConfig, + ConfigIspProxyConfig, + ConfigResidentialProxyConfig, + ConfigMobileProxyConfig, + ConfigCustomProxyConfig, +] + + +class ProxyCreateResponse(BaseModel): + """Configuration for routing traffic through a proxy.""" + + type: Literal["datacenter", "isp", "residential", "mobile", "custom"] + """Proxy type to use. + + In terms of quality for avoiding bot-detection, from best to worst: `mobile` > + `residential` > `isp` > `datacenter`. + """ + + id: Optional[str] = None + + bypass_hosts: Optional[List[str]] = None + """Hostnames that should bypass the parent proxy and connect directly.""" + + config: Optional[Config] = None + """Configuration specific to the selected proxy `type`.""" + + ip_address: Optional[str] = None + """IP address that the proxy uses when making requests.""" + + last_checked: Optional[datetime] = None + """Timestamp of the last health check performed on this proxy.""" + + name: Optional[str] = None + """Readable name of the proxy.""" + + protocol: Optional[Literal["http", "https"]] = None + """Protocol to use for the proxy connection.""" + + status: Optional[Literal["available", "unavailable"]] = None + """Current health status of the proxy.""" diff --git a/src/kernel/types/proxy_list_params.py b/src/kernel/types/proxy_list_params.py new file mode 100644 index 00000000..1c7b30e6 --- /dev/null +++ b/src/kernel/types/proxy_list_params.py @@ -0,0 +1,28 @@ +# File generated from our OpenAPI spec by Stainless. See CONTRIBUTING.md for details. + +from __future__ import annotations + +from typing_extensions import TypedDict + +__all__ = ["ProxyListParams"] + + +class ProxyListParams(TypedDict, total=False): + limit: int + """Limit the number of proxies to return.""" + + name: str + """Exact-match filter on proxy name using the database collation. + + In production, matching is case- and accent-insensitive. Names are not required + to be unique, so multiple proxies may match. + """ + + offset: int + """Offset the number of proxies to return.""" + + query: str + """Case-insensitive substring match against proxy name, host, or IP address. + + IDs match by exact value. + """ diff --git a/src/kernel/types/proxy_list_response.py b/src/kernel/types/proxy_list_response.py new file mode 100644 index 00000000..878ad7f2 --- /dev/null +++ b/src/kernel/types/proxy_list_response.py @@ -0,0 +1,131 @@ +# File generated from our OpenAPI spec by Stainless. See CONTRIBUTING.md for details. + +from typing import List, Union, Optional +from datetime import datetime +from typing_extensions import Literal, TypeAlias + +from .._models import BaseModel + +__all__ = [ + "ProxyListResponse", + "Config", + "ConfigDatacenterProxyConfig", + "ConfigIspProxyConfig", + "ConfigResidentialProxyConfig", + "ConfigMobileProxyConfig", + "ConfigCustomProxyConfig", +] + + +class ConfigDatacenterProxyConfig(BaseModel): + """Configuration for a datacenter proxy.""" + + country: Optional[str] = None + """ISO 3166 country code. Defaults to US if not provided.""" + + +class ConfigIspProxyConfig(BaseModel): + """Configuration for an ISP proxy.""" + + country: Optional[str] = None + """ISO 3166 country code. Defaults to US if not provided.""" + + +class ConfigResidentialProxyConfig(BaseModel): + """Configuration for residential proxies.""" + + asn: Optional[str] = None + """Autonomous system number. See https://bgp.potaroo.net/cidr/autnums.html""" + + city: Optional[str] = None + """City name (no spaces, e.g. + + `sanfrancisco`). If provided, `country` must also be provided. + """ + + country: Optional[str] = None + """ISO 3166 country code.""" + + os: Optional[Literal["windows", "macos", "android"]] = None + """Operating system of the residential device.""" + + state: Optional[str] = None + """Two-letter state code.""" + + zip: Optional[str] = None + """US ZIP code.""" + + +class ConfigMobileProxyConfig(BaseModel): + """Configuration for mobile proxies.""" + + city: Optional[str] = None + """Provider city alias. Mobile carrier routing can make observed geo vary.""" + + country: Optional[str] = None + """ISO 3166 country code""" + + state: Optional[str] = None + """US-only state code. Mobile carrier routing can make observed geo vary.""" + + +class ConfigCustomProxyConfig(BaseModel): + """Configuration for a custom proxy (e.g., private proxy server).""" + + host: str + """Proxy host address or IP.""" + + port: int + """Proxy port.""" + + has_ca_bundle: Optional[bool] = None + """Whether the proxy has a custom CA bundle configured.""" + + has_password: Optional[bool] = None + """Whether the proxy has a password.""" + + username: Optional[str] = None + """Username for proxy authentication.""" + + +Config: TypeAlias = Union[ + ConfigDatacenterProxyConfig, + ConfigIspProxyConfig, + ConfigResidentialProxyConfig, + ConfigMobileProxyConfig, + ConfigCustomProxyConfig, +] + + +class ProxyListResponse(BaseModel): + """Configuration for routing traffic through a proxy.""" + + type: Literal["datacenter", "isp", "residential", "mobile", "custom"] + """Proxy type to use. + + In terms of quality for avoiding bot-detection, from best to worst: `mobile` > + `residential` > `isp` > `datacenter`. + """ + + id: Optional[str] = None + + bypass_hosts: Optional[List[str]] = None + """Hostnames that should bypass the parent proxy and connect directly.""" + + config: Optional[Config] = None + """Configuration specific to the selected proxy `type`.""" + + ip_address: Optional[str] = None + """IP address that the proxy uses when making requests.""" + + last_checked: Optional[datetime] = None + """Timestamp of the last health check performed on this proxy.""" + + name: Optional[str] = None + """Readable name of the proxy.""" + + protocol: Optional[Literal["http", "https"]] = None + """Protocol to use for the proxy connection.""" + + status: Optional[Literal["available", "unavailable"]] = None + """Current health status of the proxy.""" diff --git a/src/kernel/types/proxy_retrieve_response.py b/src/kernel/types/proxy_retrieve_response.py new file mode 100644 index 00000000..f2eb1487 --- /dev/null +++ b/src/kernel/types/proxy_retrieve_response.py @@ -0,0 +1,131 @@ +# File generated from our OpenAPI spec by Stainless. See CONTRIBUTING.md for details. + +from typing import List, Union, Optional +from datetime import datetime +from typing_extensions import Literal, TypeAlias + +from .._models import BaseModel + +__all__ = [ + "ProxyRetrieveResponse", + "Config", + "ConfigDatacenterProxyConfig", + "ConfigIspProxyConfig", + "ConfigResidentialProxyConfig", + "ConfigMobileProxyConfig", + "ConfigCustomProxyConfig", +] + + +class ConfigDatacenterProxyConfig(BaseModel): + """Configuration for a datacenter proxy.""" + + country: Optional[str] = None + """ISO 3166 country code. Defaults to US if not provided.""" + + +class ConfigIspProxyConfig(BaseModel): + """Configuration for an ISP proxy.""" + + country: Optional[str] = None + """ISO 3166 country code. Defaults to US if not provided.""" + + +class ConfigResidentialProxyConfig(BaseModel): + """Configuration for residential proxies.""" + + asn: Optional[str] = None + """Autonomous system number. See https://bgp.potaroo.net/cidr/autnums.html""" + + city: Optional[str] = None + """City name (no spaces, e.g. + + `sanfrancisco`). If provided, `country` must also be provided. + """ + + country: Optional[str] = None + """ISO 3166 country code.""" + + os: Optional[Literal["windows", "macos", "android"]] = None + """Operating system of the residential device.""" + + state: Optional[str] = None + """Two-letter state code.""" + + zip: Optional[str] = None + """US ZIP code.""" + + +class ConfigMobileProxyConfig(BaseModel): + """Configuration for mobile proxies.""" + + city: Optional[str] = None + """Provider city alias. Mobile carrier routing can make observed geo vary.""" + + country: Optional[str] = None + """ISO 3166 country code""" + + state: Optional[str] = None + """US-only state code. Mobile carrier routing can make observed geo vary.""" + + +class ConfigCustomProxyConfig(BaseModel): + """Configuration for a custom proxy (e.g., private proxy server).""" + + host: str + """Proxy host address or IP.""" + + port: int + """Proxy port.""" + + has_ca_bundle: Optional[bool] = None + """Whether the proxy has a custom CA bundle configured.""" + + has_password: Optional[bool] = None + """Whether the proxy has a password.""" + + username: Optional[str] = None + """Username for proxy authentication.""" + + +Config: TypeAlias = Union[ + ConfigDatacenterProxyConfig, + ConfigIspProxyConfig, + ConfigResidentialProxyConfig, + ConfigMobileProxyConfig, + ConfigCustomProxyConfig, +] + + +class ProxyRetrieveResponse(BaseModel): + """Configuration for routing traffic through a proxy.""" + + type: Literal["datacenter", "isp", "residential", "mobile", "custom"] + """Proxy type to use. + + In terms of quality for avoiding bot-detection, from best to worst: `mobile` > + `residential` > `isp` > `datacenter`. + """ + + id: Optional[str] = None + + bypass_hosts: Optional[List[str]] = None + """Hostnames that should bypass the parent proxy and connect directly.""" + + config: Optional[Config] = None + """Configuration specific to the selected proxy `type`.""" + + ip_address: Optional[str] = None + """IP address that the proxy uses when making requests.""" + + last_checked: Optional[datetime] = None + """Timestamp of the last health check performed on this proxy.""" + + name: Optional[str] = None + """Readable name of the proxy.""" + + protocol: Optional[Literal["http", "https"]] = None + """Protocol to use for the proxy connection.""" + + status: Optional[Literal["available", "unavailable"]] = None + """Current health status of the proxy.""" diff --git a/src/kernel/types/proxy_update_params.py b/src/kernel/types/proxy_update_params.py new file mode 100644 index 00000000..0db118a4 --- /dev/null +++ b/src/kernel/types/proxy_update_params.py @@ -0,0 +1,16 @@ +# File generated from our OpenAPI spec by Stainless. See CONTRIBUTING.md for details. + +from __future__ import annotations + +from typing_extensions import Required, TypedDict + +__all__ = ["ProxyUpdateParams"] + + +class ProxyUpdateParams(TypedDict, total=False): + name: Required[str] + """New proxy name. + + Proxy names are trimmed and length-checked only; duplicates are allowed because + proxies are updated by ID, not by name. + """ diff --git a/src/kernel/types/proxy_update_response.py b/src/kernel/types/proxy_update_response.py new file mode 100644 index 00000000..bae95bba --- /dev/null +++ b/src/kernel/types/proxy_update_response.py @@ -0,0 +1,131 @@ +# File generated from our OpenAPI spec by Stainless. See CONTRIBUTING.md for details. + +from typing import List, Union, Optional +from datetime import datetime +from typing_extensions import Literal, TypeAlias + +from .._models import BaseModel + +__all__ = [ + "ProxyUpdateResponse", + "Config", + "ConfigDatacenterProxyConfig", + "ConfigIspProxyConfig", + "ConfigResidentialProxyConfig", + "ConfigMobileProxyConfig", + "ConfigCustomProxyConfig", +] + + +class ConfigDatacenterProxyConfig(BaseModel): + """Configuration for a datacenter proxy.""" + + country: Optional[str] = None + """ISO 3166 country code. Defaults to US if not provided.""" + + +class ConfigIspProxyConfig(BaseModel): + """Configuration for an ISP proxy.""" + + country: Optional[str] = None + """ISO 3166 country code. Defaults to US if not provided.""" + + +class ConfigResidentialProxyConfig(BaseModel): + """Configuration for residential proxies.""" + + asn: Optional[str] = None + """Autonomous system number. See https://bgp.potaroo.net/cidr/autnums.html""" + + city: Optional[str] = None + """City name (no spaces, e.g. + + `sanfrancisco`). If provided, `country` must also be provided. + """ + + country: Optional[str] = None + """ISO 3166 country code.""" + + os: Optional[Literal["windows", "macos", "android"]] = None + """Operating system of the residential device.""" + + state: Optional[str] = None + """Two-letter state code.""" + + zip: Optional[str] = None + """US ZIP code.""" + + +class ConfigMobileProxyConfig(BaseModel): + """Configuration for mobile proxies.""" + + city: Optional[str] = None + """Provider city alias. Mobile carrier routing can make observed geo vary.""" + + country: Optional[str] = None + """ISO 3166 country code""" + + state: Optional[str] = None + """US-only state code. Mobile carrier routing can make observed geo vary.""" + + +class ConfigCustomProxyConfig(BaseModel): + """Configuration for a custom proxy (e.g., private proxy server).""" + + host: str + """Proxy host address or IP.""" + + port: int + """Proxy port.""" + + has_ca_bundle: Optional[bool] = None + """Whether the proxy has a custom CA bundle configured.""" + + has_password: Optional[bool] = None + """Whether the proxy has a password.""" + + username: Optional[str] = None + """Username for proxy authentication.""" + + +Config: TypeAlias = Union[ + ConfigDatacenterProxyConfig, + ConfigIspProxyConfig, + ConfigResidentialProxyConfig, + ConfigMobileProxyConfig, + ConfigCustomProxyConfig, +] + + +class ProxyUpdateResponse(BaseModel): + """Configuration for routing traffic through a proxy.""" + + type: Literal["datacenter", "isp", "residential", "mobile", "custom"] + """Proxy type to use. + + In terms of quality for avoiding bot-detection, from best to worst: `mobile` > + `residential` > `isp` > `datacenter`. + """ + + id: Optional[str] = None + + bypass_hosts: Optional[List[str]] = None + """Hostnames that should bypass the parent proxy and connect directly.""" + + config: Optional[Config] = None + """Configuration specific to the selected proxy `type`.""" + + ip_address: Optional[str] = None + """IP address that the proxy uses when making requests.""" + + last_checked: Optional[datetime] = None + """Timestamp of the last health check performed on this proxy.""" + + name: Optional[str] = None + """Readable name of the proxy.""" + + protocol: Optional[Literal["http", "https"]] = None + """Protocol to use for the proxy connection.""" + + status: Optional[Literal["available", "unavailable"]] = None + """Current health status of the proxy.""" diff --git a/src/kernel/types/shared/__init__.py b/src/kernel/types/shared/__init__.py new file mode 100644 index 00000000..6b649199 --- /dev/null +++ b/src/kernel/types/shared/__init__.py @@ -0,0 +1,11 @@ +# File generated from our OpenAPI spec by Stainless. See CONTRIBUTING.md for details. + +from .log_event import LogEvent as LogEvent +from .app_action import AppAction as AppAction +from .error_event import ErrorEvent as ErrorEvent +from .error_model import ErrorModel as ErrorModel +from .error_detail import ErrorDetail as ErrorDetail +from .browser_profile import BrowserProfile as BrowserProfile +from .heartbeat_event import HeartbeatEvent as HeartbeatEvent +from .browser_viewport import BrowserViewport as BrowserViewport +from .browser_extension import BrowserExtension as BrowserExtension diff --git a/src/kernel/types/shared/app_action.py b/src/kernel/types/shared/app_action.py new file mode 100644 index 00000000..753bed78 --- /dev/null +++ b/src/kernel/types/shared/app_action.py @@ -0,0 +1,26 @@ +# File generated from our OpenAPI spec by Stainless. See CONTRIBUTING.md for details. + +from typing import Dict, Optional + +from ..._models import BaseModel + +__all__ = ["AppAction"] + + +class AppAction(BaseModel): + """An action available on the app""" + + name: str + """Name of the action""" + + input_schema: Optional[Dict[str, object]] = None + """JSON Schema (draft-07) describing the expected input payload. + + Null if schema could not be automatically generated. + """ + + output_schema: Optional[Dict[str, object]] = None + """JSON Schema (draft-07) describing the expected output payload. + + Null if schema could not be automatically generated. + """ diff --git a/src/kernel/types/shared/browser_extension.py b/src/kernel/types/shared/browser_extension.py new file mode 100644 index 00000000..a91d2dc6 --- /dev/null +++ b/src/kernel/types/shared/browser_extension.py @@ -0,0 +1,23 @@ +# File generated from our OpenAPI spec by Stainless. See CONTRIBUTING.md for details. + +from typing import Optional + +from ..._models import BaseModel + +__all__ = ["BrowserExtension"] + + +class BrowserExtension(BaseModel): + """Extension selection for the browser session. + + Provide either id or name of an extension uploaded to Kernel. + """ + + id: Optional[str] = None + """Extension ID to load for this browser session""" + + name: Optional[str] = None + """Extension name to load for this browser session (instead of id). + + Must be 1-255 characters, using letters, numbers, dots, underscores, or hyphens. + """ diff --git a/src/kernel/types/shared/browser_profile.py b/src/kernel/types/shared/browser_profile.py new file mode 100644 index 00000000..4aadc313 --- /dev/null +++ b/src/kernel/types/shared/browser_profile.py @@ -0,0 +1,30 @@ +# File generated from our OpenAPI spec by Stainless. See CONTRIBUTING.md for details. + +from typing import Optional + +from ..._models import BaseModel + +__all__ = ["BrowserProfile"] + + +class BrowserProfile(BaseModel): + """Profile selection for the browser session. + + Provide either id or name. If specified, the + matching profile will be loaded into the browser session. Profiles must be created beforehand. + """ + + id: Optional[str] = None + """Profile ID to load for this browser session""" + + name: Optional[str] = None + """Profile name to load for this browser session (instead of id). + + Must be 1-255 characters, using letters, numbers, dots, underscores, or hyphens. + """ + + save_changes: Optional[bool] = None + """ + If true, save changes made during the session back to the profile when the + session ends. + """ diff --git a/src/kernel/types/shared/browser_viewport.py b/src/kernel/types/shared/browser_viewport.py new file mode 100644 index 00000000..bfb82aa4 --- /dev/null +++ b/src/kernel/types/shared/browser_viewport.py @@ -0,0 +1,36 @@ +# File generated from our OpenAPI spec by Stainless. See CONTRIBUTING.md for details. + +from typing import Optional + +from ..._models import BaseModel + +__all__ = ["BrowserViewport"] + + +class BrowserViewport(BaseModel): + """ + Initial browser window size in pixels with optional refresh rate. + If omitted, image defaults apply (1920x1080@25). + For GPU images, the default is 1920x1080@60. + Arbitrary viewport dimensions and refresh rates are accepted. + Known-good presets include: + 2560x1440@10, 1920x1080@25, 1920x1200@25, 1440x900@25, 1280x800@60, 1024x768@60, 1200x800@60, 768x1024@60, 390x844@60. + For GPU images, recommended presets use one of these resolutions with refresh rates 60, 30, 25, or 10: + 800x600, 960x720, 1024x576, 1024x768, 1152x648, 1200x800, 1280x720, 1368x768, 1440x900, 1600x900, 1920x1080, 1920x1200, 390x844, 360x250, 768x1024, 800x1600. + Viewports outside this list may exhibit unstable live view or recording behavior. + If refresh_rate is not provided, it will be automatically determined based on the resolution + (higher resolutions use lower refresh rates to keep bandwidth reasonable). + """ + + height: int + """Browser window height in pixels. Any positive integer is accepted.""" + + width: int + """Browser window width in pixels. Any positive integer is accepted.""" + + refresh_rate: Optional[int] = None + """Display refresh rate in Hz. + + Any positive integer is accepted; if omitted, automatically determined from + width and height. + """ diff --git a/src/kernel/types/shared/error_detail.py b/src/kernel/types/shared/error_detail.py new file mode 100644 index 00000000..24e655fc --- /dev/null +++ b/src/kernel/types/shared/error_detail.py @@ -0,0 +1,15 @@ +# File generated from our OpenAPI spec by Stainless. See CONTRIBUTING.md for details. + +from typing import Optional + +from ..._models import BaseModel + +__all__ = ["ErrorDetail"] + + +class ErrorDetail(BaseModel): + code: Optional[str] = None + """Lower-level error code providing more specific detail""" + + message: Optional[str] = None + """Further detail about the error""" diff --git a/src/kernel/types/shared/error_event.py b/src/kernel/types/shared/error_event.py new file mode 100644 index 00000000..35175f5f --- /dev/null +++ b/src/kernel/types/shared/error_event.py @@ -0,0 +1,21 @@ +# File generated from our OpenAPI spec by Stainless. See CONTRIBUTING.md for details. + +from datetime import datetime +from typing_extensions import Literal + +from ..._models import BaseModel +from .error_model import ErrorModel + +__all__ = ["ErrorEvent"] + + +class ErrorEvent(BaseModel): + """An error event from the application.""" + + error: ErrorModel + + event: Literal["error"] + """Event type identifier (always "error").""" + + timestamp: datetime + """Time the error occurred.""" diff --git a/src/kernel/types/shared/error_model.py b/src/kernel/types/shared/error_model.py new file mode 100644 index 00000000..6cb4811c --- /dev/null +++ b/src/kernel/types/shared/error_model.py @@ -0,0 +1,21 @@ +# File generated from our OpenAPI spec by Stainless. See CONTRIBUTING.md for details. + +from typing import List, Optional + +from ..._models import BaseModel +from .error_detail import ErrorDetail + +__all__ = ["ErrorModel"] + + +class ErrorModel(BaseModel): + code: str + """Application-specific error code (machine-readable)""" + + message: str + """Human-readable error description for debugging""" + + details: Optional[List[ErrorDetail]] = None + """Additional error details (for multiple errors)""" + + inner_error: Optional[ErrorDetail] = None diff --git a/src/kernel/types/shared/heartbeat_event.py b/src/kernel/types/shared/heartbeat_event.py new file mode 100644 index 00000000..3745e9b9 --- /dev/null +++ b/src/kernel/types/shared/heartbeat_event.py @@ -0,0 +1,18 @@ +# File generated from our OpenAPI spec by Stainless. See CONTRIBUTING.md for details. + +from datetime import datetime +from typing_extensions import Literal + +from ..._models import BaseModel + +__all__ = ["HeartbeatEvent"] + + +class HeartbeatEvent(BaseModel): + """Heartbeat event sent periodically to keep SSE connection alive.""" + + event: Literal["sse_heartbeat"] + """Event type identifier (always "sse_heartbeat").""" + + timestamp: datetime + """Time the heartbeat was sent.""" diff --git a/src/kernel/types/shared/log_event.py b/src/kernel/types/shared/log_event.py new file mode 100644 index 00000000..078b6eca --- /dev/null +++ b/src/kernel/types/shared/log_event.py @@ -0,0 +1,21 @@ +# File generated from our OpenAPI spec by Stainless. See CONTRIBUTING.md for details. + +from datetime import datetime +from typing_extensions import Literal + +from ..._models import BaseModel + +__all__ = ["LogEvent"] + + +class LogEvent(BaseModel): + """A log entry from the application.""" + + event: Literal["log"] + """Event type identifier (always "log").""" + + message: str + """Log message text.""" + + timestamp: datetime + """Time the log entry was produced.""" diff --git a/src/kernel/types/shared_params/__init__.py b/src/kernel/types/shared_params/__init__.py new file mode 100644 index 00000000..de63c649 --- /dev/null +++ b/src/kernel/types/shared_params/__init__.py @@ -0,0 +1,5 @@ +# File generated from our OpenAPI spec by Stainless. See CONTRIBUTING.md for details. + +from .browser_profile import BrowserProfile as BrowserProfile +from .browser_viewport import BrowserViewport as BrowserViewport +from .browser_extension import BrowserExtension as BrowserExtension diff --git a/src/kernel/types/shared_params/browser_extension.py b/src/kernel/types/shared_params/browser_extension.py new file mode 100644 index 00000000..e6c2b8fa --- /dev/null +++ b/src/kernel/types/shared_params/browser_extension.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 TypedDict + +__all__ = ["BrowserExtension"] + + +class BrowserExtension(TypedDict, total=False): + """Extension selection for the browser session. + + Provide either id or name of an extension uploaded to Kernel. + """ + + id: str + """Extension ID to load for this browser session""" + + name: str + """Extension name to load for this browser session (instead of id). + + Must be 1-255 characters, using letters, numbers, dots, underscores, or hyphens. + """ diff --git a/src/kernel/types/shared_params/browser_profile.py b/src/kernel/types/shared_params/browser_profile.py new file mode 100644 index 00000000..51187dbf --- /dev/null +++ b/src/kernel/types/shared_params/browser_profile.py @@ -0,0 +1,30 @@ +# File generated from our OpenAPI spec by Stainless. See CONTRIBUTING.md for details. + +from __future__ import annotations + +from typing_extensions import TypedDict + +__all__ = ["BrowserProfile"] + + +class BrowserProfile(TypedDict, total=False): + """Profile selection for the browser session. + + Provide either id or name. If specified, the + matching profile will be loaded into the browser session. Profiles must be created beforehand. + """ + + id: str + """Profile ID to load for this browser session""" + + name: str + """Profile name to load for this browser session (instead of id). + + Must be 1-255 characters, using letters, numbers, dots, underscores, or hyphens. + """ + + save_changes: bool + """ + If true, save changes made during the session back to the profile when the + session ends. + """ diff --git a/src/kernel/types/shared_params/browser_viewport.py b/src/kernel/types/shared_params/browser_viewport.py new file mode 100644 index 00000000..5b03b2f9 --- /dev/null +++ b/src/kernel/types/shared_params/browser_viewport.py @@ -0,0 +1,36 @@ +# File generated from our OpenAPI spec by Stainless. See CONTRIBUTING.md for details. + +from __future__ import annotations + +from typing_extensions import Required, TypedDict + +__all__ = ["BrowserViewport"] + + +class BrowserViewport(TypedDict, total=False): + """ + Initial browser window size in pixels with optional refresh rate. + If omitted, image defaults apply (1920x1080@25). + For GPU images, the default is 1920x1080@60. + Arbitrary viewport dimensions and refresh rates are accepted. + Known-good presets include: + 2560x1440@10, 1920x1080@25, 1920x1200@25, 1440x900@25, 1280x800@60, 1024x768@60, 1200x800@60, 768x1024@60, 390x844@60. + For GPU images, recommended presets use one of these resolutions with refresh rates 60, 30, 25, or 10: + 800x600, 960x720, 1024x576, 1024x768, 1152x648, 1200x800, 1280x720, 1368x768, 1440x900, 1600x900, 1920x1080, 1920x1200, 390x844, 360x250, 768x1024, 800x1600. + Viewports outside this list may exhibit unstable live view or recording behavior. + If refresh_rate is not provided, it will be automatically determined based on the resolution + (higher resolutions use lower refresh rates to keep bandwidth reasonable). + """ + + height: Required[int] + """Browser window height in pixels. Any positive integer is accepted.""" + + width: Required[int] + """Browser window width in pixels. Any positive integer is accepted.""" + + refresh_rate: int + """Display refresh rate in Hz. + + Any positive integer is accepted; if omitted, automatically determined from + width and height. + """ diff --git a/src/kernel/types/tags.py b/src/kernel/types/tags.py new file mode 100644 index 00000000..988894da --- /dev/null +++ b/src/kernel/types/tags.py @@ -0,0 +1,8 @@ +# File generated from our OpenAPI spec by Stainless. See CONTRIBUTING.md for details. + +from typing import Dict +from typing_extensions import TypeAlias + +__all__ = ["Tags"] + +Tags: TypeAlias = Dict[str, str] diff --git a/src/kernel/types/tags_param.py b/src/kernel/types/tags_param.py new file mode 100644 index 00000000..9e9e5cba --- /dev/null +++ b/src/kernel/types/tags_param.py @@ -0,0 +1,10 @@ +# 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 TypeAlias + +__all__ = ["TagsParam"] + +TagsParam: TypeAlias = Dict[str, str] diff --git a/tests/__init__.py b/tests/__init__.py new file mode 100644 index 00000000..fd8019a9 --- /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 00000000..fd8019a9 --- /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/auth/__init__.py b/tests/api_resources/auth/__init__.py new file mode 100644 index 00000000..fd8019a9 --- /dev/null +++ b/tests/api_resources/auth/__init__.py @@ -0,0 +1 @@ +# File generated from our OpenAPI spec by Stainless. See CONTRIBUTING.md for details. diff --git a/tests/api_resources/auth/test_connections.py b/tests/api_resources/auth/test_connections.py new file mode 100644 index 00000000..6c7423e6 --- /dev/null +++ b/tests/api_resources/auth/test_connections.py @@ -0,0 +1,1046 @@ +# 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 kernel import Kernel, AsyncKernel +from tests.utils import assert_matches_type +from kernel.pagination import SyncOffsetPagination, AsyncOffsetPagination +from kernel.types.auth import ( + ManagedAuth, + LoginResponse, + SubmitFieldsResponse, + ManagedAuthTimelineEvent, +) + +base_url = os.environ.get("TEST_API_BASE_URL", "http://127.0.0.1:4010") + + +class TestConnections: + parametrize = pytest.mark.parametrize("client", [False, True], indirect=True, ids=["loose", "strict"]) + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + def test_method_create(self, client: Kernel) -> None: + connection = client.auth.connections.create( + domain="netflix.com", + profile_name="user-123", + ) + assert_matches_type(ManagedAuth, connection, path=["response"]) + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + def test_method_create_with_all_params(self, client: Kernel) -> None: + connection = client.auth.connections.create( + domain="netflix.com", + profile_name="user-123", + allowed_domains=["login.netflix.com", "auth.netflix.com"], + auto_reauth=True, + browser_telemetry={ + "browser": { + "captcha": {"enabled": True}, + "connection": {"enabled": True}, + "console": {"enabled": True}, + "control": {"enabled": True}, + "interaction": {"enabled": True}, + "network": {"enabled": True}, + "page": {"enabled": True}, + "screenshot": {"enabled": True}, + "system": {"enabled": True}, + }, + "enabled": True, + }, + credential={ + "auto": True, + "name": "my-netflix-creds", + "path": "Personal/Netflix", + "provider": "my-1p", + }, + health_check_interval=3600, + health_checks=True, + login_url="https://netflix.com/login", + proxy={ + "id": "id", + "name": "name", + }, + record_session=False, + save_credentials=True, + ) + assert_matches_type(ManagedAuth, connection, path=["response"]) + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + def test_raw_response_create(self, client: Kernel) -> None: + response = client.auth.connections.with_raw_response.create( + domain="netflix.com", + profile_name="user-123", + ) + + assert response.is_closed is True + assert response.http_request.headers.get("X-Stainless-Lang") == "python" + connection = response.parse() + assert_matches_type(ManagedAuth, connection, path=["response"]) + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + def test_streaming_response_create(self, client: Kernel) -> None: + with client.auth.connections.with_streaming_response.create( + domain="netflix.com", + profile_name="user-123", + ) as response: + assert not response.is_closed + assert response.http_request.headers.get("X-Stainless-Lang") == "python" + + connection = response.parse() + assert_matches_type(ManagedAuth, connection, path=["response"]) + + assert cast(Any, response.is_closed) is True + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + def test_method_retrieve(self, client: Kernel) -> None: + connection = client.auth.connections.retrieve( + "id", + ) + assert_matches_type(ManagedAuth, connection, path=["response"]) + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + def test_raw_response_retrieve(self, client: Kernel) -> None: + response = client.auth.connections.with_raw_response.retrieve( + "id", + ) + + assert response.is_closed is True + assert response.http_request.headers.get("X-Stainless-Lang") == "python" + connection = response.parse() + assert_matches_type(ManagedAuth, connection, path=["response"]) + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + def test_streaming_response_retrieve(self, client: Kernel) -> None: + with client.auth.connections.with_streaming_response.retrieve( + "id", + ) as response: + assert not response.is_closed + assert response.http_request.headers.get("X-Stainless-Lang") == "python" + + connection = response.parse() + assert_matches_type(ManagedAuth, connection, path=["response"]) + + assert cast(Any, response.is_closed) is True + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + def test_path_params_retrieve(self, client: Kernel) -> None: + with pytest.raises(ValueError, match=r"Expected a non-empty value for `id` but received ''"): + client.auth.connections.with_raw_response.retrieve( + "", + ) + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + def test_method_update(self, client: Kernel) -> None: + connection = client.auth.connections.update( + id="id", + ) + assert_matches_type(ManagedAuth, connection, path=["response"]) + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + def test_method_update_with_all_params(self, client: Kernel) -> None: + connection = client.auth.connections.update( + id="id", + allowed_domains=["login.netflix.com", "auth.netflix.com"], + auto_reauth=True, + browser_telemetry={ + "browser": { + "captcha": {"enabled": True}, + "connection": {"enabled": True}, + "console": {"enabled": True}, + "control": {"enabled": True}, + "interaction": {"enabled": True}, + "network": {"enabled": True}, + "page": {"enabled": True}, + "screenshot": {"enabled": True}, + "system": {"enabled": True}, + }, + "enabled": True, + }, + credential={ + "auto": True, + "name": "my-netflix-creds", + "path": "Personal/Netflix", + "provider": "my-1p", + }, + health_check_interval=3600, + health_checks=True, + login_url="https://netflix.com/login", + proxy={ + "id": "id", + "name": "name", + }, + record_session=False, + save_credentials=True, + ) + assert_matches_type(ManagedAuth, connection, path=["response"]) + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + def test_raw_response_update(self, client: Kernel) -> None: + response = client.auth.connections.with_raw_response.update( + id="id", + ) + + assert response.is_closed is True + assert response.http_request.headers.get("X-Stainless-Lang") == "python" + connection = response.parse() + assert_matches_type(ManagedAuth, connection, path=["response"]) + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + def test_streaming_response_update(self, client: Kernel) -> None: + with client.auth.connections.with_streaming_response.update( + id="id", + ) as response: + assert not response.is_closed + assert response.http_request.headers.get("X-Stainless-Lang") == "python" + + connection = response.parse() + assert_matches_type(ManagedAuth, connection, path=["response"]) + + assert cast(Any, response.is_closed) is True + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + def test_path_params_update(self, client: Kernel) -> None: + with pytest.raises(ValueError, match=r"Expected a non-empty value for `id` but received ''"): + client.auth.connections.with_raw_response.update( + id="", + ) + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + def test_method_list(self, client: Kernel) -> None: + connection = client.auth.connections.list() + assert_matches_type(SyncOffsetPagination[ManagedAuth], connection, path=["response"]) + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + def test_method_list_with_all_params(self, client: Kernel) -> None: + connection = client.auth.connections.list( + domain="domain", + limit=100, + offset=0, + profile_name="profile_name", + query="query", + ) + assert_matches_type(SyncOffsetPagination[ManagedAuth], connection, path=["response"]) + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + def test_raw_response_list(self, client: Kernel) -> None: + response = client.auth.connections.with_raw_response.list() + + assert response.is_closed is True + assert response.http_request.headers.get("X-Stainless-Lang") == "python" + connection = response.parse() + assert_matches_type(SyncOffsetPagination[ManagedAuth], connection, path=["response"]) + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + def test_streaming_response_list(self, client: Kernel) -> None: + with client.auth.connections.with_streaming_response.list() as response: + assert not response.is_closed + assert response.http_request.headers.get("X-Stainless-Lang") == "python" + + connection = response.parse() + assert_matches_type(SyncOffsetPagination[ManagedAuth], connection, path=["response"]) + + assert cast(Any, response.is_closed) is True + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + def test_method_delete(self, client: Kernel) -> None: + connection = client.auth.connections.delete( + "id", + ) + assert connection is None + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + def test_raw_response_delete(self, client: Kernel) -> None: + response = client.auth.connections.with_raw_response.delete( + "id", + ) + + assert response.is_closed is True + assert response.http_request.headers.get("X-Stainless-Lang") == "python" + connection = response.parse() + assert connection is None + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + def test_streaming_response_delete(self, client: Kernel) -> None: + with client.auth.connections.with_streaming_response.delete( + "id", + ) as response: + assert not response.is_closed + assert response.http_request.headers.get("X-Stainless-Lang") == "python" + + connection = response.parse() + assert connection is None + + assert cast(Any, response.is_closed) is True + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + def test_path_params_delete(self, client: Kernel) -> None: + with pytest.raises(ValueError, match=r"Expected a non-empty value for `id` but received ''"): + client.auth.connections.with_raw_response.delete( + "", + ) + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + def test_method_follow(self, client: Kernel) -> None: + connection_stream = client.auth.connections.follow( + "id", + ) + connection_stream.response.close() + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + def test_raw_response_follow(self, client: Kernel) -> None: + response = client.auth.connections.with_raw_response.follow( + "id", + ) + + assert response.http_request.headers.get("X-Stainless-Lang") == "python" + stream = response.parse() + stream.close() + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + def test_streaming_response_follow(self, client: Kernel) -> None: + with client.auth.connections.with_streaming_response.follow( + "id", + ) 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 + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + def test_path_params_follow(self, client: Kernel) -> None: + with pytest.raises(ValueError, match=r"Expected a non-empty value for `id` but received ''"): + client.auth.connections.with_raw_response.follow( + "", + ) + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + def test_method_login(self, client: Kernel) -> None: + connection = client.auth.connections.login( + id="id", + ) + assert_matches_type(LoginResponse, connection, path=["response"]) + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + def test_method_login_with_all_params(self, client: Kernel) -> None: + connection = client.auth.connections.login( + id="id", + browser_telemetry={ + "browser": { + "captcha": {"enabled": True}, + "connection": {"enabled": True}, + "console": {"enabled": True}, + "control": {"enabled": True}, + "interaction": {"enabled": True}, + "network": {"enabled": True}, + "page": {"enabled": True}, + "screenshot": {"enabled": True}, + "system": {"enabled": True}, + }, + "enabled": True, + }, + proxy={ + "id": "id", + "name": "name", + }, + record_session=True, + ) + assert_matches_type(LoginResponse, connection, path=["response"]) + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + def test_raw_response_login(self, client: Kernel) -> None: + response = client.auth.connections.with_raw_response.login( + id="id", + ) + + assert response.is_closed is True + assert response.http_request.headers.get("X-Stainless-Lang") == "python" + connection = response.parse() + assert_matches_type(LoginResponse, connection, path=["response"]) + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + def test_streaming_response_login(self, client: Kernel) -> None: + with client.auth.connections.with_streaming_response.login( + id="id", + ) as response: + assert not response.is_closed + assert response.http_request.headers.get("X-Stainless-Lang") == "python" + + connection = response.parse() + assert_matches_type(LoginResponse, connection, path=["response"]) + + assert cast(Any, response.is_closed) is True + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + def test_path_params_login(self, client: Kernel) -> None: + with pytest.raises(ValueError, match=r"Expected a non-empty value for `id` but received ''"): + client.auth.connections.with_raw_response.login( + id="", + ) + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + def test_method_submit(self, client: Kernel) -> None: + connection = client.auth.connections.submit( + id="id", + ) + assert_matches_type(SubmitFieldsResponse, connection, path=["response"]) + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + def test_method_submit_with_all_params(self, client: Kernel) -> None: + connection = client.auth.connections.submit( + id="id", + field_values={ + "field_email": "user@example.com", + "field_password": "secret", + }, + fields={ + "email": "user@example.com", + "password": "secret", + }, + mfa_option_id="sms", + selected_choice_id="google", + sign_in_option_id="work-account", + sso_button_selector="xpath=//button[contains(text(), 'Continue with Google')]", + sso_provider="google", + ) + assert_matches_type(SubmitFieldsResponse, connection, path=["response"]) + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + def test_raw_response_submit(self, client: Kernel) -> None: + response = client.auth.connections.with_raw_response.submit( + id="id", + ) + + assert response.is_closed is True + assert response.http_request.headers.get("X-Stainless-Lang") == "python" + connection = response.parse() + assert_matches_type(SubmitFieldsResponse, connection, path=["response"]) + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + def test_streaming_response_submit(self, client: Kernel) -> None: + with client.auth.connections.with_streaming_response.submit( + id="id", + ) as response: + assert not response.is_closed + assert response.http_request.headers.get("X-Stainless-Lang") == "python" + + connection = response.parse() + assert_matches_type(SubmitFieldsResponse, connection, path=["response"]) + + assert cast(Any, response.is_closed) is True + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + def test_path_params_submit(self, client: Kernel) -> None: + with pytest.raises(ValueError, match=r"Expected a non-empty value for `id` but received ''"): + client.auth.connections.with_raw_response.submit( + id="", + ) + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + def test_method_timeline(self, client: Kernel) -> None: + connection = client.auth.connections.timeline( + id="id", + ) + assert_matches_type(SyncOffsetPagination[ManagedAuthTimelineEvent], connection, path=["response"]) + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + def test_method_timeline_with_all_params(self, client: Kernel) -> None: + connection = client.auth.connections.timeline( + id="id", + limit=100, + offset=0, + type="login", + ) + assert_matches_type(SyncOffsetPagination[ManagedAuthTimelineEvent], connection, path=["response"]) + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + def test_raw_response_timeline(self, client: Kernel) -> None: + response = client.auth.connections.with_raw_response.timeline( + id="id", + ) + + assert response.is_closed is True + assert response.http_request.headers.get("X-Stainless-Lang") == "python" + connection = response.parse() + assert_matches_type(SyncOffsetPagination[ManagedAuthTimelineEvent], connection, path=["response"]) + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + def test_streaming_response_timeline(self, client: Kernel) -> None: + with client.auth.connections.with_streaming_response.timeline( + id="id", + ) as response: + assert not response.is_closed + assert response.http_request.headers.get("X-Stainless-Lang") == "python" + + connection = response.parse() + assert_matches_type(SyncOffsetPagination[ManagedAuthTimelineEvent], connection, path=["response"]) + + assert cast(Any, response.is_closed) is True + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + def test_path_params_timeline(self, client: Kernel) -> None: + with pytest.raises(ValueError, match=r"Expected a non-empty value for `id` but received ''"): + client.auth.connections.with_raw_response.timeline( + id="", + ) + + +class TestAsyncConnections: + parametrize = pytest.mark.parametrize( + "async_client", [False, True, {"http_client": "aiohttp"}], indirect=True, ids=["loose", "strict", "aiohttp"] + ) + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + async def test_method_create(self, async_client: AsyncKernel) -> None: + connection = await async_client.auth.connections.create( + domain="netflix.com", + profile_name="user-123", + ) + assert_matches_type(ManagedAuth, connection, path=["response"]) + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + async def test_method_create_with_all_params(self, async_client: AsyncKernel) -> None: + connection = await async_client.auth.connections.create( + domain="netflix.com", + profile_name="user-123", + allowed_domains=["login.netflix.com", "auth.netflix.com"], + auto_reauth=True, + browser_telemetry={ + "browser": { + "captcha": {"enabled": True}, + "connection": {"enabled": True}, + "console": {"enabled": True}, + "control": {"enabled": True}, + "interaction": {"enabled": True}, + "network": {"enabled": True}, + "page": {"enabled": True}, + "screenshot": {"enabled": True}, + "system": {"enabled": True}, + }, + "enabled": True, + }, + credential={ + "auto": True, + "name": "my-netflix-creds", + "path": "Personal/Netflix", + "provider": "my-1p", + }, + health_check_interval=3600, + health_checks=True, + login_url="https://netflix.com/login", + proxy={ + "id": "id", + "name": "name", + }, + record_session=False, + save_credentials=True, + ) + assert_matches_type(ManagedAuth, connection, path=["response"]) + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + async def test_raw_response_create(self, async_client: AsyncKernel) -> None: + response = await async_client.auth.connections.with_raw_response.create( + domain="netflix.com", + profile_name="user-123", + ) + + assert response.is_closed is True + assert response.http_request.headers.get("X-Stainless-Lang") == "python" + connection = await response.parse() + assert_matches_type(ManagedAuth, connection, path=["response"]) + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + async def test_streaming_response_create(self, async_client: AsyncKernel) -> None: + async with async_client.auth.connections.with_streaming_response.create( + domain="netflix.com", + profile_name="user-123", + ) as response: + assert not response.is_closed + assert response.http_request.headers.get("X-Stainless-Lang") == "python" + + connection = await response.parse() + assert_matches_type(ManagedAuth, connection, path=["response"]) + + assert cast(Any, response.is_closed) is True + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + async def test_method_retrieve(self, async_client: AsyncKernel) -> None: + connection = await async_client.auth.connections.retrieve( + "id", + ) + assert_matches_type(ManagedAuth, connection, path=["response"]) + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + async def test_raw_response_retrieve(self, async_client: AsyncKernel) -> None: + response = await async_client.auth.connections.with_raw_response.retrieve( + "id", + ) + + assert response.is_closed is True + assert response.http_request.headers.get("X-Stainless-Lang") == "python" + connection = await response.parse() + assert_matches_type(ManagedAuth, connection, path=["response"]) + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + async def test_streaming_response_retrieve(self, async_client: AsyncKernel) -> None: + async with async_client.auth.connections.with_streaming_response.retrieve( + "id", + ) as response: + assert not response.is_closed + assert response.http_request.headers.get("X-Stainless-Lang") == "python" + + connection = await response.parse() + assert_matches_type(ManagedAuth, connection, path=["response"]) + + assert cast(Any, response.is_closed) is True + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + async def test_path_params_retrieve(self, async_client: AsyncKernel) -> None: + with pytest.raises(ValueError, match=r"Expected a non-empty value for `id` but received ''"): + await async_client.auth.connections.with_raw_response.retrieve( + "", + ) + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + async def test_method_update(self, async_client: AsyncKernel) -> None: + connection = await async_client.auth.connections.update( + id="id", + ) + assert_matches_type(ManagedAuth, connection, path=["response"]) + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + async def test_method_update_with_all_params(self, async_client: AsyncKernel) -> None: + connection = await async_client.auth.connections.update( + id="id", + allowed_domains=["login.netflix.com", "auth.netflix.com"], + auto_reauth=True, + browser_telemetry={ + "browser": { + "captcha": {"enabled": True}, + "connection": {"enabled": True}, + "console": {"enabled": True}, + "control": {"enabled": True}, + "interaction": {"enabled": True}, + "network": {"enabled": True}, + "page": {"enabled": True}, + "screenshot": {"enabled": True}, + "system": {"enabled": True}, + }, + "enabled": True, + }, + credential={ + "auto": True, + "name": "my-netflix-creds", + "path": "Personal/Netflix", + "provider": "my-1p", + }, + health_check_interval=3600, + health_checks=True, + login_url="https://netflix.com/login", + proxy={ + "id": "id", + "name": "name", + }, + record_session=False, + save_credentials=True, + ) + assert_matches_type(ManagedAuth, connection, path=["response"]) + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + async def test_raw_response_update(self, async_client: AsyncKernel) -> None: + response = await async_client.auth.connections.with_raw_response.update( + id="id", + ) + + assert response.is_closed is True + assert response.http_request.headers.get("X-Stainless-Lang") == "python" + connection = await response.parse() + assert_matches_type(ManagedAuth, connection, path=["response"]) + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + async def test_streaming_response_update(self, async_client: AsyncKernel) -> None: + async with async_client.auth.connections.with_streaming_response.update( + id="id", + ) as response: + assert not response.is_closed + assert response.http_request.headers.get("X-Stainless-Lang") == "python" + + connection = await response.parse() + assert_matches_type(ManagedAuth, connection, path=["response"]) + + assert cast(Any, response.is_closed) is True + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + async def test_path_params_update(self, async_client: AsyncKernel) -> None: + with pytest.raises(ValueError, match=r"Expected a non-empty value for `id` but received ''"): + await async_client.auth.connections.with_raw_response.update( + id="", + ) + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + async def test_method_list(self, async_client: AsyncKernel) -> None: + connection = await async_client.auth.connections.list() + assert_matches_type(AsyncOffsetPagination[ManagedAuth], connection, path=["response"]) + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + async def test_method_list_with_all_params(self, async_client: AsyncKernel) -> None: + connection = await async_client.auth.connections.list( + domain="domain", + limit=100, + offset=0, + profile_name="profile_name", + query="query", + ) + assert_matches_type(AsyncOffsetPagination[ManagedAuth], connection, path=["response"]) + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + async def test_raw_response_list(self, async_client: AsyncKernel) -> None: + response = await async_client.auth.connections.with_raw_response.list() + + assert response.is_closed is True + assert response.http_request.headers.get("X-Stainless-Lang") == "python" + connection = await response.parse() + assert_matches_type(AsyncOffsetPagination[ManagedAuth], connection, path=["response"]) + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + async def test_streaming_response_list(self, async_client: AsyncKernel) -> None: + async with async_client.auth.connections.with_streaming_response.list() as response: + assert not response.is_closed + assert response.http_request.headers.get("X-Stainless-Lang") == "python" + + connection = await response.parse() + assert_matches_type(AsyncOffsetPagination[ManagedAuth], connection, path=["response"]) + + assert cast(Any, response.is_closed) is True + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + async def test_method_delete(self, async_client: AsyncKernel) -> None: + connection = await async_client.auth.connections.delete( + "id", + ) + assert connection is None + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + async def test_raw_response_delete(self, async_client: AsyncKernel) -> None: + response = await async_client.auth.connections.with_raw_response.delete( + "id", + ) + + assert response.is_closed is True + assert response.http_request.headers.get("X-Stainless-Lang") == "python" + connection = await response.parse() + assert connection is None + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + async def test_streaming_response_delete(self, async_client: AsyncKernel) -> None: + async with async_client.auth.connections.with_streaming_response.delete( + "id", + ) as response: + assert not response.is_closed + assert response.http_request.headers.get("X-Stainless-Lang") == "python" + + connection = await response.parse() + assert connection is None + + assert cast(Any, response.is_closed) is True + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + async def test_path_params_delete(self, async_client: AsyncKernel) -> None: + with pytest.raises(ValueError, match=r"Expected a non-empty value for `id` but received ''"): + await async_client.auth.connections.with_raw_response.delete( + "", + ) + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + async def test_method_follow(self, async_client: AsyncKernel) -> None: + connection_stream = await async_client.auth.connections.follow( + "id", + ) + await connection_stream.response.aclose() + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + async def test_raw_response_follow(self, async_client: AsyncKernel) -> None: + response = await async_client.auth.connections.with_raw_response.follow( + "id", + ) + + assert response.http_request.headers.get("X-Stainless-Lang") == "python" + stream = await response.parse() + await stream.close() + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + async def test_streaming_response_follow(self, async_client: AsyncKernel) -> None: + async with async_client.auth.connections.with_streaming_response.follow( + "id", + ) 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 + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + async def test_path_params_follow(self, async_client: AsyncKernel) -> None: + with pytest.raises(ValueError, match=r"Expected a non-empty value for `id` but received ''"): + await async_client.auth.connections.with_raw_response.follow( + "", + ) + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + async def test_method_login(self, async_client: AsyncKernel) -> None: + connection = await async_client.auth.connections.login( + id="id", + ) + assert_matches_type(LoginResponse, connection, path=["response"]) + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + async def test_method_login_with_all_params(self, async_client: AsyncKernel) -> None: + connection = await async_client.auth.connections.login( + id="id", + browser_telemetry={ + "browser": { + "captcha": {"enabled": True}, + "connection": {"enabled": True}, + "console": {"enabled": True}, + "control": {"enabled": True}, + "interaction": {"enabled": True}, + "network": {"enabled": True}, + "page": {"enabled": True}, + "screenshot": {"enabled": True}, + "system": {"enabled": True}, + }, + "enabled": True, + }, + proxy={ + "id": "id", + "name": "name", + }, + record_session=True, + ) + assert_matches_type(LoginResponse, connection, path=["response"]) + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + async def test_raw_response_login(self, async_client: AsyncKernel) -> None: + response = await async_client.auth.connections.with_raw_response.login( + id="id", + ) + + assert response.is_closed is True + assert response.http_request.headers.get("X-Stainless-Lang") == "python" + connection = await response.parse() + assert_matches_type(LoginResponse, connection, path=["response"]) + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + async def test_streaming_response_login(self, async_client: AsyncKernel) -> None: + async with async_client.auth.connections.with_streaming_response.login( + id="id", + ) as response: + assert not response.is_closed + assert response.http_request.headers.get("X-Stainless-Lang") == "python" + + connection = await response.parse() + assert_matches_type(LoginResponse, connection, path=["response"]) + + assert cast(Any, response.is_closed) is True + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + async def test_path_params_login(self, async_client: AsyncKernel) -> None: + with pytest.raises(ValueError, match=r"Expected a non-empty value for `id` but received ''"): + await async_client.auth.connections.with_raw_response.login( + id="", + ) + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + async def test_method_submit(self, async_client: AsyncKernel) -> None: + connection = await async_client.auth.connections.submit( + id="id", + ) + assert_matches_type(SubmitFieldsResponse, connection, path=["response"]) + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + async def test_method_submit_with_all_params(self, async_client: AsyncKernel) -> None: + connection = await async_client.auth.connections.submit( + id="id", + field_values={ + "field_email": "user@example.com", + "field_password": "secret", + }, + fields={ + "email": "user@example.com", + "password": "secret", + }, + mfa_option_id="sms", + selected_choice_id="google", + sign_in_option_id="work-account", + sso_button_selector="xpath=//button[contains(text(), 'Continue with Google')]", + sso_provider="google", + ) + assert_matches_type(SubmitFieldsResponse, connection, path=["response"]) + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + async def test_raw_response_submit(self, async_client: AsyncKernel) -> None: + response = await async_client.auth.connections.with_raw_response.submit( + id="id", + ) + + assert response.is_closed is True + assert response.http_request.headers.get("X-Stainless-Lang") == "python" + connection = await response.parse() + assert_matches_type(SubmitFieldsResponse, connection, path=["response"]) + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + async def test_streaming_response_submit(self, async_client: AsyncKernel) -> None: + async with async_client.auth.connections.with_streaming_response.submit( + id="id", + ) as response: + assert not response.is_closed + assert response.http_request.headers.get("X-Stainless-Lang") == "python" + + connection = await response.parse() + assert_matches_type(SubmitFieldsResponse, connection, path=["response"]) + + assert cast(Any, response.is_closed) is True + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + async def test_path_params_submit(self, async_client: AsyncKernel) -> None: + with pytest.raises(ValueError, match=r"Expected a non-empty value for `id` but received ''"): + await async_client.auth.connections.with_raw_response.submit( + id="", + ) + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + async def test_method_timeline(self, async_client: AsyncKernel) -> None: + connection = await async_client.auth.connections.timeline( + id="id", + ) + assert_matches_type(AsyncOffsetPagination[ManagedAuthTimelineEvent], connection, path=["response"]) + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + async def test_method_timeline_with_all_params(self, async_client: AsyncKernel) -> None: + connection = await async_client.auth.connections.timeline( + id="id", + limit=100, + offset=0, + type="login", + ) + assert_matches_type(AsyncOffsetPagination[ManagedAuthTimelineEvent], connection, path=["response"]) + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + async def test_raw_response_timeline(self, async_client: AsyncKernel) -> None: + response = await async_client.auth.connections.with_raw_response.timeline( + id="id", + ) + + assert response.is_closed is True + assert response.http_request.headers.get("X-Stainless-Lang") == "python" + connection = await response.parse() + assert_matches_type(AsyncOffsetPagination[ManagedAuthTimelineEvent], connection, path=["response"]) + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + async def test_streaming_response_timeline(self, async_client: AsyncKernel) -> None: + async with async_client.auth.connections.with_streaming_response.timeline( + id="id", + ) as response: + assert not response.is_closed + assert response.http_request.headers.get("X-Stainless-Lang") == "python" + + connection = await response.parse() + assert_matches_type(AsyncOffsetPagination[ManagedAuthTimelineEvent], connection, path=["response"]) + + assert cast(Any, response.is_closed) is True + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + async def test_path_params_timeline(self, async_client: AsyncKernel) -> None: + with pytest.raises(ValueError, match=r"Expected a non-empty value for `id` but received ''"): + await async_client.auth.connections.with_raw_response.timeline( + id="", + ) diff --git a/tests/api_resources/browsers/__init__.py b/tests/api_resources/browsers/__init__.py new file mode 100644 index 00000000..fd8019a9 --- /dev/null +++ b/tests/api_resources/browsers/__init__.py @@ -0,0 +1 @@ +# File generated from our OpenAPI spec by Stainless. See CONTRIBUTING.md for details. diff --git a/tests/api_resources/browsers/fs/__init__.py b/tests/api_resources/browsers/fs/__init__.py new file mode 100644 index 00000000..fd8019a9 --- /dev/null +++ b/tests/api_resources/browsers/fs/__init__.py @@ -0,0 +1 @@ +# File generated from our OpenAPI spec by Stainless. See CONTRIBUTING.md for details. diff --git a/tests/api_resources/browsers/fs/test_watch.py b/tests/api_resources/browsers/fs/test_watch.py new file mode 100644 index 00000000..b28086ba --- /dev/null +++ b/tests/api_resources/browsers/fs/test_watch.py @@ -0,0 +1,342 @@ +# 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 kernel import Kernel, AsyncKernel +from tests.utils import assert_matches_type +from kernel.types.browsers.fs import WatchStartResponse + +base_url = os.environ.get("TEST_API_BASE_URL", "http://127.0.0.1:4010") + + +class TestWatch: + parametrize = pytest.mark.parametrize("client", [False, True], indirect=True, ids=["loose", "strict"]) + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + def test_method_events(self, client: Kernel) -> None: + watch_stream = client.browsers.fs.watch.events( + watch_id="watch_id", + id="id", + ) + watch_stream.response.close() + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + def test_raw_response_events(self, client: Kernel) -> None: + response = client.browsers.fs.watch.with_raw_response.events( + watch_id="watch_id", + id="id", + ) + + assert response.http_request.headers.get("X-Stainless-Lang") == "python" + stream = response.parse() + stream.close() + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + def test_streaming_response_events(self, client: Kernel) -> None: + with client.browsers.fs.watch.with_streaming_response.events( + watch_id="watch_id", + id="id", + ) 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 + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + def test_path_params_events(self, client: Kernel) -> None: + with pytest.raises(ValueError, match=r"Expected a non-empty value for `id` but received ''"): + client.browsers.fs.watch.with_raw_response.events( + watch_id="watch_id", + id="", + ) + + with pytest.raises(ValueError, match=r"Expected a non-empty value for `watch_id` but received ''"): + client.browsers.fs.watch.with_raw_response.events( + watch_id="", + id="id", + ) + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + def test_method_start(self, client: Kernel) -> None: + watch = client.browsers.fs.watch.start( + id="id", + path="path", + ) + assert_matches_type(WatchStartResponse, watch, path=["response"]) + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + def test_method_start_with_all_params(self, client: Kernel) -> None: + watch = client.browsers.fs.watch.start( + id="id", + path="path", + recursive=True, + ) + assert_matches_type(WatchStartResponse, watch, path=["response"]) + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + def test_raw_response_start(self, client: Kernel) -> None: + response = client.browsers.fs.watch.with_raw_response.start( + id="id", + path="path", + ) + + assert response.is_closed is True + assert response.http_request.headers.get("X-Stainless-Lang") == "python" + watch = response.parse() + assert_matches_type(WatchStartResponse, watch, path=["response"]) + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + def test_streaming_response_start(self, client: Kernel) -> None: + with client.browsers.fs.watch.with_streaming_response.start( + id="id", + path="path", + ) as response: + assert not response.is_closed + assert response.http_request.headers.get("X-Stainless-Lang") == "python" + + watch = response.parse() + assert_matches_type(WatchStartResponse, watch, path=["response"]) + + assert cast(Any, response.is_closed) is True + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + def test_path_params_start(self, client: Kernel) -> None: + with pytest.raises(ValueError, match=r"Expected a non-empty value for `id` but received ''"): + client.browsers.fs.watch.with_raw_response.start( + id="", + path="path", + ) + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + def test_method_stop(self, client: Kernel) -> None: + watch = client.browsers.fs.watch.stop( + watch_id="watch_id", + id="id", + ) + assert watch is None + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + def test_raw_response_stop(self, client: Kernel) -> None: + response = client.browsers.fs.watch.with_raw_response.stop( + watch_id="watch_id", + id="id", + ) + + assert response.is_closed is True + assert response.http_request.headers.get("X-Stainless-Lang") == "python" + watch = response.parse() + assert watch is None + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + def test_streaming_response_stop(self, client: Kernel) -> None: + with client.browsers.fs.watch.with_streaming_response.stop( + watch_id="watch_id", + id="id", + ) as response: + assert not response.is_closed + assert response.http_request.headers.get("X-Stainless-Lang") == "python" + + watch = response.parse() + assert watch is None + + assert cast(Any, response.is_closed) is True + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + def test_path_params_stop(self, client: Kernel) -> None: + with pytest.raises(ValueError, match=r"Expected a non-empty value for `id` but received ''"): + client.browsers.fs.watch.with_raw_response.stop( + watch_id="watch_id", + id="", + ) + + with pytest.raises(ValueError, match=r"Expected a non-empty value for `watch_id` but received ''"): + client.browsers.fs.watch.with_raw_response.stop( + watch_id="", + id="id", + ) + + +class TestAsyncWatch: + parametrize = pytest.mark.parametrize( + "async_client", [False, True, {"http_client": "aiohttp"}], indirect=True, ids=["loose", "strict", "aiohttp"] + ) + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + async def test_method_events(self, async_client: AsyncKernel) -> None: + watch_stream = await async_client.browsers.fs.watch.events( + watch_id="watch_id", + id="id", + ) + await watch_stream.response.aclose() + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + async def test_raw_response_events(self, async_client: AsyncKernel) -> None: + response = await async_client.browsers.fs.watch.with_raw_response.events( + watch_id="watch_id", + id="id", + ) + + assert response.http_request.headers.get("X-Stainless-Lang") == "python" + stream = await response.parse() + await stream.close() + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + async def test_streaming_response_events(self, async_client: AsyncKernel) -> None: + async with async_client.browsers.fs.watch.with_streaming_response.events( + watch_id="watch_id", + id="id", + ) 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 + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + async def test_path_params_events(self, async_client: AsyncKernel) -> None: + with pytest.raises(ValueError, match=r"Expected a non-empty value for `id` but received ''"): + await async_client.browsers.fs.watch.with_raw_response.events( + watch_id="watch_id", + id="", + ) + + with pytest.raises(ValueError, match=r"Expected a non-empty value for `watch_id` but received ''"): + await async_client.browsers.fs.watch.with_raw_response.events( + watch_id="", + id="id", + ) + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + async def test_method_start(self, async_client: AsyncKernel) -> None: + watch = await async_client.browsers.fs.watch.start( + id="id", + path="path", + ) + assert_matches_type(WatchStartResponse, watch, path=["response"]) + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + async def test_method_start_with_all_params(self, async_client: AsyncKernel) -> None: + watch = await async_client.browsers.fs.watch.start( + id="id", + path="path", + recursive=True, + ) + assert_matches_type(WatchStartResponse, watch, path=["response"]) + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + async def test_raw_response_start(self, async_client: AsyncKernel) -> None: + response = await async_client.browsers.fs.watch.with_raw_response.start( + id="id", + path="path", + ) + + assert response.is_closed is True + assert response.http_request.headers.get("X-Stainless-Lang") == "python" + watch = await response.parse() + assert_matches_type(WatchStartResponse, watch, path=["response"]) + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + async def test_streaming_response_start(self, async_client: AsyncKernel) -> None: + async with async_client.browsers.fs.watch.with_streaming_response.start( + id="id", + path="path", + ) as response: + assert not response.is_closed + assert response.http_request.headers.get("X-Stainless-Lang") == "python" + + watch = await response.parse() + assert_matches_type(WatchStartResponse, watch, path=["response"]) + + assert cast(Any, response.is_closed) is True + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + async def test_path_params_start(self, async_client: AsyncKernel) -> None: + with pytest.raises(ValueError, match=r"Expected a non-empty value for `id` but received ''"): + await async_client.browsers.fs.watch.with_raw_response.start( + id="", + path="path", + ) + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + async def test_method_stop(self, async_client: AsyncKernel) -> None: + watch = await async_client.browsers.fs.watch.stop( + watch_id="watch_id", + id="id", + ) + assert watch is None + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + async def test_raw_response_stop(self, async_client: AsyncKernel) -> None: + response = await async_client.browsers.fs.watch.with_raw_response.stop( + watch_id="watch_id", + id="id", + ) + + assert response.is_closed is True + assert response.http_request.headers.get("X-Stainless-Lang") == "python" + watch = await response.parse() + assert watch is None + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + async def test_streaming_response_stop(self, async_client: AsyncKernel) -> None: + async with async_client.browsers.fs.watch.with_streaming_response.stop( + watch_id="watch_id", + id="id", + ) as response: + assert not response.is_closed + assert response.http_request.headers.get("X-Stainless-Lang") == "python" + + watch = await response.parse() + assert watch is None + + assert cast(Any, response.is_closed) is True + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + async def test_path_params_stop(self, async_client: AsyncKernel) -> None: + with pytest.raises(ValueError, match=r"Expected a non-empty value for `id` but received ''"): + await async_client.browsers.fs.watch.with_raw_response.stop( + watch_id="watch_id", + id="", + ) + + with pytest.raises(ValueError, match=r"Expected a non-empty value for `watch_id` but received ''"): + await async_client.browsers.fs.watch.with_raw_response.stop( + watch_id="", + id="id", + ) diff --git a/tests/api_resources/browsers/test_computer.py b/tests/api_resources/browsers/test_computer.py new file mode 100644 index 00000000..31974d5b --- /dev/null +++ b/tests/api_resources/browsers/test_computer.py @@ -0,0 +1,1350 @@ +# 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 httpx +import pytest +from respx import MockRouter + +from kernel import Kernel, AsyncKernel +from tests.utils import assert_matches_type +from kernel._response import ( + BinaryAPIResponse, + AsyncBinaryAPIResponse, + StreamedBinaryAPIResponse, + AsyncStreamedBinaryAPIResponse, +) +from kernel.types.browsers import ( + ComputerReadClipboardResponse, + ComputerGetMousePositionResponse, + ComputerSetCursorVisibilityResponse, +) + +base_url = os.environ.get("TEST_API_BASE_URL", "http://127.0.0.1:4010") + + +class TestComputer: + parametrize = pytest.mark.parametrize("client", [False, True], indirect=True, ids=["loose", "strict"]) + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + def test_method_batch(self, client: Kernel) -> None: + computer = client.browsers.computer.batch( + id="id", + actions=[{"type": "click_mouse"}], + ) + assert computer is None + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + def test_raw_response_batch(self, client: Kernel) -> None: + response = client.browsers.computer.with_raw_response.batch( + id="id", + actions=[{"type": "click_mouse"}], + ) + + assert response.is_closed is True + assert response.http_request.headers.get("X-Stainless-Lang") == "python" + computer = response.parse() + assert computer is None + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + def test_streaming_response_batch(self, client: Kernel) -> None: + with client.browsers.computer.with_streaming_response.batch( + id="id", + actions=[{"type": "click_mouse"}], + ) as response: + assert not response.is_closed + assert response.http_request.headers.get("X-Stainless-Lang") == "python" + + computer = response.parse() + assert computer is None + + assert cast(Any, response.is_closed) is True + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + def test_path_params_batch(self, client: Kernel) -> None: + with pytest.raises(ValueError, match=r"Expected a non-empty value for `id` but received ''"): + client.browsers.computer.with_raw_response.batch( + id="", + actions=[{"type": "click_mouse"}], + ) + + @parametrize + @pytest.mark.respx(base_url=base_url) + def test_method_capture_screenshot(self, client: Kernel, respx_mock: MockRouter) -> None: + respx_mock.post("/browsers/id/computer/screenshot").mock(return_value=httpx.Response(200, json={"foo": "bar"})) + computer = client.browsers.computer.capture_screenshot( + id="id", + ) + assert computer.is_closed + assert computer.json() == {"foo": "bar"} + assert cast(Any, computer.is_closed) is True + assert isinstance(computer, BinaryAPIResponse) + + @parametrize + @pytest.mark.respx(base_url=base_url) + def test_method_capture_screenshot_with_all_params(self, client: Kernel, respx_mock: MockRouter) -> None: + respx_mock.post("/browsers/id/computer/screenshot").mock(return_value=httpx.Response(200, json={"foo": "bar"})) + computer = client.browsers.computer.capture_screenshot( + id="id", + region={ + "height": 0, + "width": 0, + "x": 0, + "y": 0, + }, + ) + assert computer.is_closed + assert computer.json() == {"foo": "bar"} + assert cast(Any, computer.is_closed) is True + assert isinstance(computer, BinaryAPIResponse) + + @parametrize + @pytest.mark.respx(base_url=base_url) + def test_raw_response_capture_screenshot(self, client: Kernel, respx_mock: MockRouter) -> None: + respx_mock.post("/browsers/id/computer/screenshot").mock(return_value=httpx.Response(200, json={"foo": "bar"})) + + computer = client.browsers.computer.with_raw_response.capture_screenshot( + id="id", + ) + + assert computer.is_closed is True + assert computer.http_request.headers.get("X-Stainless-Lang") == "python" + assert computer.json() == {"foo": "bar"} + assert isinstance(computer, BinaryAPIResponse) + + @parametrize + @pytest.mark.respx(base_url=base_url) + def test_streaming_response_capture_screenshot(self, client: Kernel, respx_mock: MockRouter) -> None: + respx_mock.post("/browsers/id/computer/screenshot").mock(return_value=httpx.Response(200, json={"foo": "bar"})) + with client.browsers.computer.with_streaming_response.capture_screenshot( + id="id", + ) as computer: + assert not computer.is_closed + assert computer.http_request.headers.get("X-Stainless-Lang") == "python" + + assert computer.json() == {"foo": "bar"} + assert cast(Any, computer.is_closed) is True + assert isinstance(computer, StreamedBinaryAPIResponse) + + assert cast(Any, computer.is_closed) is True + + @parametrize + @pytest.mark.respx(base_url=base_url) + def test_path_params_capture_screenshot(self, client: Kernel) -> None: + with pytest.raises(ValueError, match=r"Expected a non-empty value for `id` but received ''"): + client.browsers.computer.with_raw_response.capture_screenshot( + id="", + ) + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + def test_method_click_mouse(self, client: Kernel) -> None: + computer = client.browsers.computer.click_mouse( + id="id", + x=0, + y=0, + ) + assert computer is None + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + def test_method_click_mouse_with_all_params(self, client: Kernel) -> None: + computer = client.browsers.computer.click_mouse( + id="id", + x=0, + y=0, + button="left", + click_type="down", + hold_keys=["string"], + num_clicks=0, + ) + assert computer is None + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + def test_raw_response_click_mouse(self, client: Kernel) -> None: + response = client.browsers.computer.with_raw_response.click_mouse( + id="id", + x=0, + y=0, + ) + + assert response.is_closed is True + assert response.http_request.headers.get("X-Stainless-Lang") == "python" + computer = response.parse() + assert computer is None + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + def test_streaming_response_click_mouse(self, client: Kernel) -> None: + with client.browsers.computer.with_streaming_response.click_mouse( + id="id", + x=0, + y=0, + ) as response: + assert not response.is_closed + assert response.http_request.headers.get("X-Stainless-Lang") == "python" + + computer = response.parse() + assert computer is None + + assert cast(Any, response.is_closed) is True + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + def test_path_params_click_mouse(self, client: Kernel) -> None: + with pytest.raises(ValueError, match=r"Expected a non-empty value for `id` but received ''"): + client.browsers.computer.with_raw_response.click_mouse( + id="", + x=0, + y=0, + ) + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + def test_method_drag_mouse(self, client: Kernel) -> None: + computer = client.browsers.computer.drag_mouse( + id="id", + path=[[0, 0], [0, 0]], + ) + assert computer is None + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + def test_method_drag_mouse_with_all_params(self, client: Kernel) -> None: + computer = client.browsers.computer.drag_mouse( + id="id", + path=[[0, 0], [0, 0]], + button="left", + delay=0, + duration_ms=50, + hold_keys=["string"], + smooth=True, + step_delay_ms=0, + steps_per_segment=1, + ) + assert computer is None + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + def test_raw_response_drag_mouse(self, client: Kernel) -> None: + response = client.browsers.computer.with_raw_response.drag_mouse( + id="id", + path=[[0, 0], [0, 0]], + ) + + assert response.is_closed is True + assert response.http_request.headers.get("X-Stainless-Lang") == "python" + computer = response.parse() + assert computer is None + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + def test_streaming_response_drag_mouse(self, client: Kernel) -> None: + with client.browsers.computer.with_streaming_response.drag_mouse( + id="id", + path=[[0, 0], [0, 0]], + ) as response: + assert not response.is_closed + assert response.http_request.headers.get("X-Stainless-Lang") == "python" + + computer = response.parse() + assert computer is None + + assert cast(Any, response.is_closed) is True + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + def test_path_params_drag_mouse(self, client: Kernel) -> None: + with pytest.raises(ValueError, match=r"Expected a non-empty value for `id` but received ''"): + client.browsers.computer.with_raw_response.drag_mouse( + id="", + path=[[0, 0], [0, 0]], + ) + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + def test_method_get_mouse_position(self, client: Kernel) -> None: + computer = client.browsers.computer.get_mouse_position( + "id", + ) + assert_matches_type(ComputerGetMousePositionResponse, computer, path=["response"]) + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + def test_raw_response_get_mouse_position(self, client: Kernel) -> None: + response = client.browsers.computer.with_raw_response.get_mouse_position( + "id", + ) + + assert response.is_closed is True + assert response.http_request.headers.get("X-Stainless-Lang") == "python" + computer = response.parse() + assert_matches_type(ComputerGetMousePositionResponse, computer, path=["response"]) + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + def test_streaming_response_get_mouse_position(self, client: Kernel) -> None: + with client.browsers.computer.with_streaming_response.get_mouse_position( + "id", + ) as response: + assert not response.is_closed + assert response.http_request.headers.get("X-Stainless-Lang") == "python" + + computer = response.parse() + assert_matches_type(ComputerGetMousePositionResponse, computer, path=["response"]) + + assert cast(Any, response.is_closed) is True + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + def test_path_params_get_mouse_position(self, client: Kernel) -> None: + with pytest.raises(ValueError, match=r"Expected a non-empty value for `id` but received ''"): + client.browsers.computer.with_raw_response.get_mouse_position( + "", + ) + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + def test_method_move_mouse(self, client: Kernel) -> None: + computer = client.browsers.computer.move_mouse( + id="id", + x=0, + y=0, + ) + assert computer is None + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + def test_method_move_mouse_with_all_params(self, client: Kernel) -> None: + computer = client.browsers.computer.move_mouse( + id="id", + x=0, + y=0, + duration_ms=50, + hold_keys=["string"], + smooth=True, + ) + assert computer is None + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + def test_raw_response_move_mouse(self, client: Kernel) -> None: + response = client.browsers.computer.with_raw_response.move_mouse( + id="id", + x=0, + y=0, + ) + + assert response.is_closed is True + assert response.http_request.headers.get("X-Stainless-Lang") == "python" + computer = response.parse() + assert computer is None + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + def test_streaming_response_move_mouse(self, client: Kernel) -> None: + with client.browsers.computer.with_streaming_response.move_mouse( + id="id", + x=0, + y=0, + ) as response: + assert not response.is_closed + assert response.http_request.headers.get("X-Stainless-Lang") == "python" + + computer = response.parse() + assert computer is None + + assert cast(Any, response.is_closed) is True + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + def test_path_params_move_mouse(self, client: Kernel) -> None: + with pytest.raises(ValueError, match=r"Expected a non-empty value for `id` but received ''"): + client.browsers.computer.with_raw_response.move_mouse( + id="", + x=0, + y=0, + ) + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + def test_method_press_key(self, client: Kernel) -> None: + computer = client.browsers.computer.press_key( + id="id", + keys=["string"], + ) + assert computer is None + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + def test_method_press_key_with_all_params(self, client: Kernel) -> None: + computer = client.browsers.computer.press_key( + id="id", + keys=["string"], + duration=0, + hold_keys=["string"], + ) + assert computer is None + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + def test_raw_response_press_key(self, client: Kernel) -> None: + response = client.browsers.computer.with_raw_response.press_key( + id="id", + keys=["string"], + ) + + assert response.is_closed is True + assert response.http_request.headers.get("X-Stainless-Lang") == "python" + computer = response.parse() + assert computer is None + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + def test_streaming_response_press_key(self, client: Kernel) -> None: + with client.browsers.computer.with_streaming_response.press_key( + id="id", + keys=["string"], + ) as response: + assert not response.is_closed + assert response.http_request.headers.get("X-Stainless-Lang") == "python" + + computer = response.parse() + assert computer is None + + assert cast(Any, response.is_closed) is True + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + def test_path_params_press_key(self, client: Kernel) -> None: + with pytest.raises(ValueError, match=r"Expected a non-empty value for `id` but received ''"): + client.browsers.computer.with_raw_response.press_key( + id="", + keys=["string"], + ) + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + def test_method_read_clipboard(self, client: Kernel) -> None: + computer = client.browsers.computer.read_clipboard( + "id", + ) + assert_matches_type(ComputerReadClipboardResponse, computer, path=["response"]) + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + def test_raw_response_read_clipboard(self, client: Kernel) -> None: + response = client.browsers.computer.with_raw_response.read_clipboard( + "id", + ) + + assert response.is_closed is True + assert response.http_request.headers.get("X-Stainless-Lang") == "python" + computer = response.parse() + assert_matches_type(ComputerReadClipboardResponse, computer, path=["response"]) + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + def test_streaming_response_read_clipboard(self, client: Kernel) -> None: + with client.browsers.computer.with_streaming_response.read_clipboard( + "id", + ) as response: + assert not response.is_closed + assert response.http_request.headers.get("X-Stainless-Lang") == "python" + + computer = response.parse() + assert_matches_type(ComputerReadClipboardResponse, computer, path=["response"]) + + assert cast(Any, response.is_closed) is True + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + def test_path_params_read_clipboard(self, client: Kernel) -> None: + with pytest.raises(ValueError, match=r"Expected a non-empty value for `id` but received ''"): + client.browsers.computer.with_raw_response.read_clipboard( + "", + ) + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + def test_method_scroll(self, client: Kernel) -> None: + computer = client.browsers.computer.scroll( + id="id", + x=0, + y=0, + ) + assert computer is None + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + def test_method_scroll_with_all_params(self, client: Kernel) -> None: + computer = client.browsers.computer.scroll( + id="id", + x=0, + y=0, + delta_x=0, + delta_y=0, + hold_keys=["string"], + ) + assert computer is None + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + def test_raw_response_scroll(self, client: Kernel) -> None: + response = client.browsers.computer.with_raw_response.scroll( + id="id", + x=0, + y=0, + ) + + assert response.is_closed is True + assert response.http_request.headers.get("X-Stainless-Lang") == "python" + computer = response.parse() + assert computer is None + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + def test_streaming_response_scroll(self, client: Kernel) -> None: + with client.browsers.computer.with_streaming_response.scroll( + id="id", + x=0, + y=0, + ) as response: + assert not response.is_closed + assert response.http_request.headers.get("X-Stainless-Lang") == "python" + + computer = response.parse() + assert computer is None + + assert cast(Any, response.is_closed) is True + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + def test_path_params_scroll(self, client: Kernel) -> None: + with pytest.raises(ValueError, match=r"Expected a non-empty value for `id` but received ''"): + client.browsers.computer.with_raw_response.scroll( + id="", + x=0, + y=0, + ) + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + def test_method_set_cursor_visibility(self, client: Kernel) -> None: + computer = client.browsers.computer.set_cursor_visibility( + id="id", + hidden=True, + ) + assert_matches_type(ComputerSetCursorVisibilityResponse, computer, path=["response"]) + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + def test_raw_response_set_cursor_visibility(self, client: Kernel) -> None: + response = client.browsers.computer.with_raw_response.set_cursor_visibility( + id="id", + hidden=True, + ) + + assert response.is_closed is True + assert response.http_request.headers.get("X-Stainless-Lang") == "python" + computer = response.parse() + assert_matches_type(ComputerSetCursorVisibilityResponse, computer, path=["response"]) + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + def test_streaming_response_set_cursor_visibility(self, client: Kernel) -> None: + with client.browsers.computer.with_streaming_response.set_cursor_visibility( + id="id", + hidden=True, + ) as response: + assert not response.is_closed + assert response.http_request.headers.get("X-Stainless-Lang") == "python" + + computer = response.parse() + assert_matches_type(ComputerSetCursorVisibilityResponse, computer, path=["response"]) + + assert cast(Any, response.is_closed) is True + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + def test_path_params_set_cursor_visibility(self, client: Kernel) -> None: + with pytest.raises(ValueError, match=r"Expected a non-empty value for `id` but received ''"): + client.browsers.computer.with_raw_response.set_cursor_visibility( + id="", + hidden=True, + ) + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + def test_method_type_text(self, client: Kernel) -> None: + computer = client.browsers.computer.type_text( + id="id", + text="text", + ) + assert computer is None + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + def test_method_type_text_with_all_params(self, client: Kernel) -> None: + computer = client.browsers.computer.type_text( + id="id", + text="text", + delay=0, + ) + assert computer is None + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + def test_raw_response_type_text(self, client: Kernel) -> None: + response = client.browsers.computer.with_raw_response.type_text( + id="id", + text="text", + ) + + assert response.is_closed is True + assert response.http_request.headers.get("X-Stainless-Lang") == "python" + computer = response.parse() + assert computer is None + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + def test_streaming_response_type_text(self, client: Kernel) -> None: + with client.browsers.computer.with_streaming_response.type_text( + id="id", + text="text", + ) as response: + assert not response.is_closed + assert response.http_request.headers.get("X-Stainless-Lang") == "python" + + computer = response.parse() + assert computer is None + + assert cast(Any, response.is_closed) is True + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + def test_path_params_type_text(self, client: Kernel) -> None: + with pytest.raises(ValueError, match=r"Expected a non-empty value for `id` but received ''"): + client.browsers.computer.with_raw_response.type_text( + id="", + text="text", + ) + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + def test_method_write_clipboard(self, client: Kernel) -> None: + computer = client.browsers.computer.write_clipboard( + id="id", + text="text", + ) + assert computer is None + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + def test_raw_response_write_clipboard(self, client: Kernel) -> None: + response = client.browsers.computer.with_raw_response.write_clipboard( + id="id", + text="text", + ) + + assert response.is_closed is True + assert response.http_request.headers.get("X-Stainless-Lang") == "python" + computer = response.parse() + assert computer is None + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + def test_streaming_response_write_clipboard(self, client: Kernel) -> None: + with client.browsers.computer.with_streaming_response.write_clipboard( + id="id", + text="text", + ) as response: + assert not response.is_closed + assert response.http_request.headers.get("X-Stainless-Lang") == "python" + + computer = response.parse() + assert computer is None + + assert cast(Any, response.is_closed) is True + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + def test_path_params_write_clipboard(self, client: Kernel) -> None: + with pytest.raises(ValueError, match=r"Expected a non-empty value for `id` but received ''"): + client.browsers.computer.with_raw_response.write_clipboard( + id="", + text="text", + ) + + +class TestAsyncComputer: + parametrize = pytest.mark.parametrize( + "async_client", [False, True, {"http_client": "aiohttp"}], indirect=True, ids=["loose", "strict", "aiohttp"] + ) + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + async def test_method_batch(self, async_client: AsyncKernel) -> None: + computer = await async_client.browsers.computer.batch( + id="id", + actions=[{"type": "click_mouse"}], + ) + assert computer is None + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + async def test_raw_response_batch(self, async_client: AsyncKernel) -> None: + response = await async_client.browsers.computer.with_raw_response.batch( + id="id", + actions=[{"type": "click_mouse"}], + ) + + assert response.is_closed is True + assert response.http_request.headers.get("X-Stainless-Lang") == "python" + computer = await response.parse() + assert computer is None + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + async def test_streaming_response_batch(self, async_client: AsyncKernel) -> None: + async with async_client.browsers.computer.with_streaming_response.batch( + id="id", + actions=[{"type": "click_mouse"}], + ) as response: + assert not response.is_closed + assert response.http_request.headers.get("X-Stainless-Lang") == "python" + + computer = await response.parse() + assert computer is None + + assert cast(Any, response.is_closed) is True + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + async def test_path_params_batch(self, async_client: AsyncKernel) -> None: + with pytest.raises(ValueError, match=r"Expected a non-empty value for `id` but received ''"): + await async_client.browsers.computer.with_raw_response.batch( + id="", + actions=[{"type": "click_mouse"}], + ) + + @parametrize + @pytest.mark.respx(base_url=base_url) + async def test_method_capture_screenshot(self, async_client: AsyncKernel, respx_mock: MockRouter) -> None: + respx_mock.post("/browsers/id/computer/screenshot").mock(return_value=httpx.Response(200, json={"foo": "bar"})) + computer = await async_client.browsers.computer.capture_screenshot( + id="id", + ) + assert computer.is_closed + assert await computer.json() == {"foo": "bar"} + assert cast(Any, computer.is_closed) is True + assert isinstance(computer, AsyncBinaryAPIResponse) + + @parametrize + @pytest.mark.respx(base_url=base_url) + async def test_method_capture_screenshot_with_all_params( + self, async_client: AsyncKernel, respx_mock: MockRouter + ) -> None: + respx_mock.post("/browsers/id/computer/screenshot").mock(return_value=httpx.Response(200, json={"foo": "bar"})) + computer = await async_client.browsers.computer.capture_screenshot( + id="id", + region={ + "height": 0, + "width": 0, + "x": 0, + "y": 0, + }, + ) + assert computer.is_closed + assert await computer.json() == {"foo": "bar"} + assert cast(Any, computer.is_closed) is True + assert isinstance(computer, AsyncBinaryAPIResponse) + + @parametrize + @pytest.mark.respx(base_url=base_url) + async def test_raw_response_capture_screenshot(self, async_client: AsyncKernel, respx_mock: MockRouter) -> None: + respx_mock.post("/browsers/id/computer/screenshot").mock(return_value=httpx.Response(200, json={"foo": "bar"})) + + computer = await async_client.browsers.computer.with_raw_response.capture_screenshot( + id="id", + ) + + assert computer.is_closed is True + assert computer.http_request.headers.get("X-Stainless-Lang") == "python" + assert await computer.json() == {"foo": "bar"} + assert isinstance(computer, AsyncBinaryAPIResponse) + + @parametrize + @pytest.mark.respx(base_url=base_url) + async def test_streaming_response_capture_screenshot( + self, async_client: AsyncKernel, respx_mock: MockRouter + ) -> None: + respx_mock.post("/browsers/id/computer/screenshot").mock(return_value=httpx.Response(200, json={"foo": "bar"})) + async with async_client.browsers.computer.with_streaming_response.capture_screenshot( + id="id", + ) as computer: + assert not computer.is_closed + assert computer.http_request.headers.get("X-Stainless-Lang") == "python" + + assert await computer.json() == {"foo": "bar"} + assert cast(Any, computer.is_closed) is True + assert isinstance(computer, AsyncStreamedBinaryAPIResponse) + + assert cast(Any, computer.is_closed) is True + + @parametrize + @pytest.mark.respx(base_url=base_url) + async def test_path_params_capture_screenshot(self, async_client: AsyncKernel) -> None: + with pytest.raises(ValueError, match=r"Expected a non-empty value for `id` but received ''"): + await async_client.browsers.computer.with_raw_response.capture_screenshot( + id="", + ) + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + async def test_method_click_mouse(self, async_client: AsyncKernel) -> None: + computer = await async_client.browsers.computer.click_mouse( + id="id", + x=0, + y=0, + ) + assert computer is None + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + async def test_method_click_mouse_with_all_params(self, async_client: AsyncKernel) -> None: + computer = await async_client.browsers.computer.click_mouse( + id="id", + x=0, + y=0, + button="left", + click_type="down", + hold_keys=["string"], + num_clicks=0, + ) + assert computer is None + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + async def test_raw_response_click_mouse(self, async_client: AsyncKernel) -> None: + response = await async_client.browsers.computer.with_raw_response.click_mouse( + id="id", + x=0, + y=0, + ) + + assert response.is_closed is True + assert response.http_request.headers.get("X-Stainless-Lang") == "python" + computer = await response.parse() + assert computer is None + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + async def test_streaming_response_click_mouse(self, async_client: AsyncKernel) -> None: + async with async_client.browsers.computer.with_streaming_response.click_mouse( + id="id", + x=0, + y=0, + ) as response: + assert not response.is_closed + assert response.http_request.headers.get("X-Stainless-Lang") == "python" + + computer = await response.parse() + assert computer is None + + assert cast(Any, response.is_closed) is True + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + async def test_path_params_click_mouse(self, async_client: AsyncKernel) -> None: + with pytest.raises(ValueError, match=r"Expected a non-empty value for `id` but received ''"): + await async_client.browsers.computer.with_raw_response.click_mouse( + id="", + x=0, + y=0, + ) + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + async def test_method_drag_mouse(self, async_client: AsyncKernel) -> None: + computer = await async_client.browsers.computer.drag_mouse( + id="id", + path=[[0, 0], [0, 0]], + ) + assert computer is None + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + async def test_method_drag_mouse_with_all_params(self, async_client: AsyncKernel) -> None: + computer = await async_client.browsers.computer.drag_mouse( + id="id", + path=[[0, 0], [0, 0]], + button="left", + delay=0, + duration_ms=50, + hold_keys=["string"], + smooth=True, + step_delay_ms=0, + steps_per_segment=1, + ) + assert computer is None + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + async def test_raw_response_drag_mouse(self, async_client: AsyncKernel) -> None: + response = await async_client.browsers.computer.with_raw_response.drag_mouse( + id="id", + path=[[0, 0], [0, 0]], + ) + + assert response.is_closed is True + assert response.http_request.headers.get("X-Stainless-Lang") == "python" + computer = await response.parse() + assert computer is None + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + async def test_streaming_response_drag_mouse(self, async_client: AsyncKernel) -> None: + async with async_client.browsers.computer.with_streaming_response.drag_mouse( + id="id", + path=[[0, 0], [0, 0]], + ) as response: + assert not response.is_closed + assert response.http_request.headers.get("X-Stainless-Lang") == "python" + + computer = await response.parse() + assert computer is None + + assert cast(Any, response.is_closed) is True + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + async def test_path_params_drag_mouse(self, async_client: AsyncKernel) -> None: + with pytest.raises(ValueError, match=r"Expected a non-empty value for `id` but received ''"): + await async_client.browsers.computer.with_raw_response.drag_mouse( + id="", + path=[[0, 0], [0, 0]], + ) + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + async def test_method_get_mouse_position(self, async_client: AsyncKernel) -> None: + computer = await async_client.browsers.computer.get_mouse_position( + "id", + ) + assert_matches_type(ComputerGetMousePositionResponse, computer, path=["response"]) + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + async def test_raw_response_get_mouse_position(self, async_client: AsyncKernel) -> None: + response = await async_client.browsers.computer.with_raw_response.get_mouse_position( + "id", + ) + + assert response.is_closed is True + assert response.http_request.headers.get("X-Stainless-Lang") == "python" + computer = await response.parse() + assert_matches_type(ComputerGetMousePositionResponse, computer, path=["response"]) + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + async def test_streaming_response_get_mouse_position(self, async_client: AsyncKernel) -> None: + async with async_client.browsers.computer.with_streaming_response.get_mouse_position( + "id", + ) as response: + assert not response.is_closed + assert response.http_request.headers.get("X-Stainless-Lang") == "python" + + computer = await response.parse() + assert_matches_type(ComputerGetMousePositionResponse, computer, path=["response"]) + + assert cast(Any, response.is_closed) is True + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + async def test_path_params_get_mouse_position(self, async_client: AsyncKernel) -> None: + with pytest.raises(ValueError, match=r"Expected a non-empty value for `id` but received ''"): + await async_client.browsers.computer.with_raw_response.get_mouse_position( + "", + ) + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + async def test_method_move_mouse(self, async_client: AsyncKernel) -> None: + computer = await async_client.browsers.computer.move_mouse( + id="id", + x=0, + y=0, + ) + assert computer is None + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + async def test_method_move_mouse_with_all_params(self, async_client: AsyncKernel) -> None: + computer = await async_client.browsers.computer.move_mouse( + id="id", + x=0, + y=0, + duration_ms=50, + hold_keys=["string"], + smooth=True, + ) + assert computer is None + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + async def test_raw_response_move_mouse(self, async_client: AsyncKernel) -> None: + response = await async_client.browsers.computer.with_raw_response.move_mouse( + id="id", + x=0, + y=0, + ) + + assert response.is_closed is True + assert response.http_request.headers.get("X-Stainless-Lang") == "python" + computer = await response.parse() + assert computer is None + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + async def test_streaming_response_move_mouse(self, async_client: AsyncKernel) -> None: + async with async_client.browsers.computer.with_streaming_response.move_mouse( + id="id", + x=0, + y=0, + ) as response: + assert not response.is_closed + assert response.http_request.headers.get("X-Stainless-Lang") == "python" + + computer = await response.parse() + assert computer is None + + assert cast(Any, response.is_closed) is True + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + async def test_path_params_move_mouse(self, async_client: AsyncKernel) -> None: + with pytest.raises(ValueError, match=r"Expected a non-empty value for `id` but received ''"): + await async_client.browsers.computer.with_raw_response.move_mouse( + id="", + x=0, + y=0, + ) + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + async def test_method_press_key(self, async_client: AsyncKernel) -> None: + computer = await async_client.browsers.computer.press_key( + id="id", + keys=["string"], + ) + assert computer is None + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + async def test_method_press_key_with_all_params(self, async_client: AsyncKernel) -> None: + computer = await async_client.browsers.computer.press_key( + id="id", + keys=["string"], + duration=0, + hold_keys=["string"], + ) + assert computer is None + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + async def test_raw_response_press_key(self, async_client: AsyncKernel) -> None: + response = await async_client.browsers.computer.with_raw_response.press_key( + id="id", + keys=["string"], + ) + + assert response.is_closed is True + assert response.http_request.headers.get("X-Stainless-Lang") == "python" + computer = await response.parse() + assert computer is None + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + async def test_streaming_response_press_key(self, async_client: AsyncKernel) -> None: + async with async_client.browsers.computer.with_streaming_response.press_key( + id="id", + keys=["string"], + ) as response: + assert not response.is_closed + assert response.http_request.headers.get("X-Stainless-Lang") == "python" + + computer = await response.parse() + assert computer is None + + assert cast(Any, response.is_closed) is True + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + async def test_path_params_press_key(self, async_client: AsyncKernel) -> None: + with pytest.raises(ValueError, match=r"Expected a non-empty value for `id` but received ''"): + await async_client.browsers.computer.with_raw_response.press_key( + id="", + keys=["string"], + ) + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + async def test_method_read_clipboard(self, async_client: AsyncKernel) -> None: + computer = await async_client.browsers.computer.read_clipboard( + "id", + ) + assert_matches_type(ComputerReadClipboardResponse, computer, path=["response"]) + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + async def test_raw_response_read_clipboard(self, async_client: AsyncKernel) -> None: + response = await async_client.browsers.computer.with_raw_response.read_clipboard( + "id", + ) + + assert response.is_closed is True + assert response.http_request.headers.get("X-Stainless-Lang") == "python" + computer = await response.parse() + assert_matches_type(ComputerReadClipboardResponse, computer, path=["response"]) + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + async def test_streaming_response_read_clipboard(self, async_client: AsyncKernel) -> None: + async with async_client.browsers.computer.with_streaming_response.read_clipboard( + "id", + ) as response: + assert not response.is_closed + assert response.http_request.headers.get("X-Stainless-Lang") == "python" + + computer = await response.parse() + assert_matches_type(ComputerReadClipboardResponse, computer, path=["response"]) + + assert cast(Any, response.is_closed) is True + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + async def test_path_params_read_clipboard(self, async_client: AsyncKernel) -> None: + with pytest.raises(ValueError, match=r"Expected a non-empty value for `id` but received ''"): + await async_client.browsers.computer.with_raw_response.read_clipboard( + "", + ) + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + async def test_method_scroll(self, async_client: AsyncKernel) -> None: + computer = await async_client.browsers.computer.scroll( + id="id", + x=0, + y=0, + ) + assert computer is None + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + async def test_method_scroll_with_all_params(self, async_client: AsyncKernel) -> None: + computer = await async_client.browsers.computer.scroll( + id="id", + x=0, + y=0, + delta_x=0, + delta_y=0, + hold_keys=["string"], + ) + assert computer is None + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + async def test_raw_response_scroll(self, async_client: AsyncKernel) -> None: + response = await async_client.browsers.computer.with_raw_response.scroll( + id="id", + x=0, + y=0, + ) + + assert response.is_closed is True + assert response.http_request.headers.get("X-Stainless-Lang") == "python" + computer = await response.parse() + assert computer is None + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + async def test_streaming_response_scroll(self, async_client: AsyncKernel) -> None: + async with async_client.browsers.computer.with_streaming_response.scroll( + id="id", + x=0, + y=0, + ) as response: + assert not response.is_closed + assert response.http_request.headers.get("X-Stainless-Lang") == "python" + + computer = await response.parse() + assert computer is None + + assert cast(Any, response.is_closed) is True + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + async def test_path_params_scroll(self, async_client: AsyncKernel) -> None: + with pytest.raises(ValueError, match=r"Expected a non-empty value for `id` but received ''"): + await async_client.browsers.computer.with_raw_response.scroll( + id="", + x=0, + y=0, + ) + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + async def test_method_set_cursor_visibility(self, async_client: AsyncKernel) -> None: + computer = await async_client.browsers.computer.set_cursor_visibility( + id="id", + hidden=True, + ) + assert_matches_type(ComputerSetCursorVisibilityResponse, computer, path=["response"]) + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + async def test_raw_response_set_cursor_visibility(self, async_client: AsyncKernel) -> None: + response = await async_client.browsers.computer.with_raw_response.set_cursor_visibility( + id="id", + hidden=True, + ) + + assert response.is_closed is True + assert response.http_request.headers.get("X-Stainless-Lang") == "python" + computer = await response.parse() + assert_matches_type(ComputerSetCursorVisibilityResponse, computer, path=["response"]) + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + async def test_streaming_response_set_cursor_visibility(self, async_client: AsyncKernel) -> None: + async with async_client.browsers.computer.with_streaming_response.set_cursor_visibility( + id="id", + hidden=True, + ) as response: + assert not response.is_closed + assert response.http_request.headers.get("X-Stainless-Lang") == "python" + + computer = await response.parse() + assert_matches_type(ComputerSetCursorVisibilityResponse, computer, path=["response"]) + + assert cast(Any, response.is_closed) is True + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + async def test_path_params_set_cursor_visibility(self, async_client: AsyncKernel) -> None: + with pytest.raises(ValueError, match=r"Expected a non-empty value for `id` but received ''"): + await async_client.browsers.computer.with_raw_response.set_cursor_visibility( + id="", + hidden=True, + ) + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + async def test_method_type_text(self, async_client: AsyncKernel) -> None: + computer = await async_client.browsers.computer.type_text( + id="id", + text="text", + ) + assert computer is None + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + async def test_method_type_text_with_all_params(self, async_client: AsyncKernel) -> None: + computer = await async_client.browsers.computer.type_text( + id="id", + text="text", + delay=0, + ) + assert computer is None + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + async def test_raw_response_type_text(self, async_client: AsyncKernel) -> None: + response = await async_client.browsers.computer.with_raw_response.type_text( + id="id", + text="text", + ) + + assert response.is_closed is True + assert response.http_request.headers.get("X-Stainless-Lang") == "python" + computer = await response.parse() + assert computer is None + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + async def test_streaming_response_type_text(self, async_client: AsyncKernel) -> None: + async with async_client.browsers.computer.with_streaming_response.type_text( + id="id", + text="text", + ) as response: + assert not response.is_closed + assert response.http_request.headers.get("X-Stainless-Lang") == "python" + + computer = await response.parse() + assert computer is None + + assert cast(Any, response.is_closed) is True + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + async def test_path_params_type_text(self, async_client: AsyncKernel) -> None: + with pytest.raises(ValueError, match=r"Expected a non-empty value for `id` but received ''"): + await async_client.browsers.computer.with_raw_response.type_text( + id="", + text="text", + ) + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + async def test_method_write_clipboard(self, async_client: AsyncKernel) -> None: + computer = await async_client.browsers.computer.write_clipboard( + id="id", + text="text", + ) + assert computer is None + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + async def test_raw_response_write_clipboard(self, async_client: AsyncKernel) -> None: + response = await async_client.browsers.computer.with_raw_response.write_clipboard( + id="id", + text="text", + ) + + assert response.is_closed is True + assert response.http_request.headers.get("X-Stainless-Lang") == "python" + computer = await response.parse() + assert computer is None + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + async def test_streaming_response_write_clipboard(self, async_client: AsyncKernel) -> None: + async with async_client.browsers.computer.with_streaming_response.write_clipboard( + id="id", + text="text", + ) as response: + assert not response.is_closed + assert response.http_request.headers.get("X-Stainless-Lang") == "python" + + computer = await response.parse() + assert computer is None + + assert cast(Any, response.is_closed) is True + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + async def test_path_params_write_clipboard(self, async_client: AsyncKernel) -> None: + with pytest.raises(ValueError, match=r"Expected a non-empty value for `id` but received ''"): + await async_client.browsers.computer.with_raw_response.write_clipboard( + id="", + text="text", + ) diff --git a/tests/api_resources/browsers/test_fs.py b/tests/api_resources/browsers/test_fs.py new file mode 100644 index 00000000..ceb179a0 --- /dev/null +++ b/tests/api_resources/browsers/test_fs.py @@ -0,0 +1,1309 @@ +# 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 httpx +import pytest +from respx import MockRouter + +from kernel import Kernel, AsyncKernel +from tests.utils import assert_matches_type +from kernel._response import ( + BinaryAPIResponse, + AsyncBinaryAPIResponse, + StreamedBinaryAPIResponse, + AsyncStreamedBinaryAPIResponse, +) +from kernel.types.browsers import ( + FFileInfoResponse, + FListFilesResponse, +) + +base_url = os.environ.get("TEST_API_BASE_URL", "http://127.0.0.1:4010") + + +class TestFs: + parametrize = pytest.mark.parametrize("client", [False, True], indirect=True, ids=["loose", "strict"]) + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + def test_method_create_directory(self, client: Kernel) -> None: + f = client.browsers.fs.create_directory( + id="id", + path="/J!", + ) + assert f is None + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + def test_method_create_directory_with_all_params(self, client: Kernel) -> None: + f = client.browsers.fs.create_directory( + id="id", + path="/J!", + mode="0611", + ) + assert f is None + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + def test_raw_response_create_directory(self, client: Kernel) -> None: + response = client.browsers.fs.with_raw_response.create_directory( + id="id", + path="/J!", + ) + + assert response.is_closed is True + assert response.http_request.headers.get("X-Stainless-Lang") == "python" + f = response.parse() + assert f is None + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + def test_streaming_response_create_directory(self, client: Kernel) -> None: + with client.browsers.fs.with_streaming_response.create_directory( + id="id", + path="/J!", + ) as response: + assert not response.is_closed + assert response.http_request.headers.get("X-Stainless-Lang") == "python" + + f = response.parse() + assert f is None + + assert cast(Any, response.is_closed) is True + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + def test_path_params_create_directory(self, client: Kernel) -> None: + with pytest.raises(ValueError, match=r"Expected a non-empty value for `id` but received ''"): + client.browsers.fs.with_raw_response.create_directory( + id="", + path="/J!", + ) + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + def test_method_delete_directory(self, client: Kernel) -> None: + f = client.browsers.fs.delete_directory( + id="id", + path="/J!", + ) + assert f is None + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + def test_raw_response_delete_directory(self, client: Kernel) -> None: + response = client.browsers.fs.with_raw_response.delete_directory( + id="id", + path="/J!", + ) + + assert response.is_closed is True + assert response.http_request.headers.get("X-Stainless-Lang") == "python" + f = response.parse() + assert f is None + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + def test_streaming_response_delete_directory(self, client: Kernel) -> None: + with client.browsers.fs.with_streaming_response.delete_directory( + id="id", + path="/J!", + ) as response: + assert not response.is_closed + assert response.http_request.headers.get("X-Stainless-Lang") == "python" + + f = response.parse() + assert f is None + + assert cast(Any, response.is_closed) is True + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + def test_path_params_delete_directory(self, client: Kernel) -> None: + with pytest.raises(ValueError, match=r"Expected a non-empty value for `id` but received ''"): + client.browsers.fs.with_raw_response.delete_directory( + id="", + path="/J!", + ) + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + def test_method_delete_file(self, client: Kernel) -> None: + f = client.browsers.fs.delete_file( + id="id", + path="/J!", + ) + assert f is None + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + def test_raw_response_delete_file(self, client: Kernel) -> None: + response = client.browsers.fs.with_raw_response.delete_file( + id="id", + path="/J!", + ) + + assert response.is_closed is True + assert response.http_request.headers.get("X-Stainless-Lang") == "python" + f = response.parse() + assert f is None + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + def test_streaming_response_delete_file(self, client: Kernel) -> None: + with client.browsers.fs.with_streaming_response.delete_file( + id="id", + path="/J!", + ) as response: + assert not response.is_closed + assert response.http_request.headers.get("X-Stainless-Lang") == "python" + + f = response.parse() + assert f is None + + assert cast(Any, response.is_closed) is True + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + def test_path_params_delete_file(self, client: Kernel) -> None: + with pytest.raises(ValueError, match=r"Expected a non-empty value for `id` but received ''"): + client.browsers.fs.with_raw_response.delete_file( + id="", + path="/J!", + ) + + @parametrize + @pytest.mark.respx(base_url=base_url) + def test_method_download_dir_zip(self, client: Kernel, respx_mock: MockRouter) -> None: + respx_mock.get("/browsers/id/fs/download_dir_zip").mock(return_value=httpx.Response(200, json={"foo": "bar"})) + f = client.browsers.fs.download_dir_zip( + id="id", + path="/J!", + ) + assert f.is_closed + assert f.json() == {"foo": "bar"} + assert cast(Any, f.is_closed) is True + assert isinstance(f, BinaryAPIResponse) + + @parametrize + @pytest.mark.respx(base_url=base_url) + def test_raw_response_download_dir_zip(self, client: Kernel, respx_mock: MockRouter) -> None: + respx_mock.get("/browsers/id/fs/download_dir_zip").mock(return_value=httpx.Response(200, json={"foo": "bar"})) + + f = client.browsers.fs.with_raw_response.download_dir_zip( + id="id", + path="/J!", + ) + + assert f.is_closed is True + assert f.http_request.headers.get("X-Stainless-Lang") == "python" + assert f.json() == {"foo": "bar"} + assert isinstance(f, BinaryAPIResponse) + + @parametrize + @pytest.mark.respx(base_url=base_url) + def test_streaming_response_download_dir_zip(self, client: Kernel, respx_mock: MockRouter) -> None: + respx_mock.get("/browsers/id/fs/download_dir_zip").mock(return_value=httpx.Response(200, json={"foo": "bar"})) + with client.browsers.fs.with_streaming_response.download_dir_zip( + id="id", + path="/J!", + ) as f: + assert not f.is_closed + assert f.http_request.headers.get("X-Stainless-Lang") == "python" + + assert f.json() == {"foo": "bar"} + assert cast(Any, f.is_closed) is True + assert isinstance(f, StreamedBinaryAPIResponse) + + assert cast(Any, f.is_closed) is True + + @parametrize + @pytest.mark.respx(base_url=base_url) + def test_path_params_download_dir_zip(self, client: Kernel) -> None: + with pytest.raises(ValueError, match=r"Expected a non-empty value for `id` but received ''"): + client.browsers.fs.with_raw_response.download_dir_zip( + id="", + path="/J!", + ) + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + def test_method_file_info(self, client: Kernel) -> None: + f = client.browsers.fs.file_info( + id="id", + path="/J!", + ) + assert_matches_type(FFileInfoResponse, f, path=["response"]) + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + def test_raw_response_file_info(self, client: Kernel) -> None: + response = client.browsers.fs.with_raw_response.file_info( + id="id", + path="/J!", + ) + + assert response.is_closed is True + assert response.http_request.headers.get("X-Stainless-Lang") == "python" + f = response.parse() + assert_matches_type(FFileInfoResponse, f, path=["response"]) + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + def test_streaming_response_file_info(self, client: Kernel) -> None: + with client.browsers.fs.with_streaming_response.file_info( + id="id", + path="/J!", + ) as response: + assert not response.is_closed + assert response.http_request.headers.get("X-Stainless-Lang") == "python" + + f = response.parse() + assert_matches_type(FFileInfoResponse, f, path=["response"]) + + assert cast(Any, response.is_closed) is True + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + def test_path_params_file_info(self, client: Kernel) -> None: + with pytest.raises(ValueError, match=r"Expected a non-empty value for `id` but received ''"): + client.browsers.fs.with_raw_response.file_info( + id="", + path="/J!", + ) + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + def test_method_list_files(self, client: Kernel) -> None: + f = client.browsers.fs.list_files( + id="id", + path="/J!", + ) + assert_matches_type(FListFilesResponse, f, path=["response"]) + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + def test_raw_response_list_files(self, client: Kernel) -> None: + response = client.browsers.fs.with_raw_response.list_files( + id="id", + path="/J!", + ) + + assert response.is_closed is True + assert response.http_request.headers.get("X-Stainless-Lang") == "python" + f = response.parse() + assert_matches_type(FListFilesResponse, f, path=["response"]) + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + def test_streaming_response_list_files(self, client: Kernel) -> None: + with client.browsers.fs.with_streaming_response.list_files( + id="id", + path="/J!", + ) as response: + assert not response.is_closed + assert response.http_request.headers.get("X-Stainless-Lang") == "python" + + f = response.parse() + assert_matches_type(FListFilesResponse, f, path=["response"]) + + assert cast(Any, response.is_closed) is True + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + def test_path_params_list_files(self, client: Kernel) -> None: + with pytest.raises(ValueError, match=r"Expected a non-empty value for `id` but received ''"): + client.browsers.fs.with_raw_response.list_files( + id="", + path="/J!", + ) + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + def test_method_move(self, client: Kernel) -> None: + f = client.browsers.fs.move( + id="id", + dest_path="/J!", + src_path="/J!", + ) + assert f is None + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + def test_raw_response_move(self, client: Kernel) -> None: + response = client.browsers.fs.with_raw_response.move( + id="id", + dest_path="/J!", + src_path="/J!", + ) + + assert response.is_closed is True + assert response.http_request.headers.get("X-Stainless-Lang") == "python" + f = response.parse() + assert f is None + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + def test_streaming_response_move(self, client: Kernel) -> None: + with client.browsers.fs.with_streaming_response.move( + id="id", + dest_path="/J!", + src_path="/J!", + ) as response: + assert not response.is_closed + assert response.http_request.headers.get("X-Stainless-Lang") == "python" + + f = response.parse() + assert f is None + + assert cast(Any, response.is_closed) is True + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + def test_path_params_move(self, client: Kernel) -> None: + with pytest.raises(ValueError, match=r"Expected a non-empty value for `id` but received ''"): + client.browsers.fs.with_raw_response.move( + id="", + dest_path="/J!", + src_path="/J!", + ) + + @parametrize + @pytest.mark.respx(base_url=base_url) + def test_method_read_file(self, client: Kernel, respx_mock: MockRouter) -> None: + respx_mock.get("/browsers/id/fs/read_file").mock(return_value=httpx.Response(200, json={"foo": "bar"})) + f = client.browsers.fs.read_file( + id="id", + path="/J!", + ) + assert f.is_closed + assert f.json() == {"foo": "bar"} + assert cast(Any, f.is_closed) is True + assert isinstance(f, BinaryAPIResponse) + + @parametrize + @pytest.mark.respx(base_url=base_url) + def test_raw_response_read_file(self, client: Kernel, respx_mock: MockRouter) -> None: + respx_mock.get("/browsers/id/fs/read_file").mock(return_value=httpx.Response(200, json={"foo": "bar"})) + + f = client.browsers.fs.with_raw_response.read_file( + id="id", + path="/J!", + ) + + assert f.is_closed is True + assert f.http_request.headers.get("X-Stainless-Lang") == "python" + assert f.json() == {"foo": "bar"} + assert isinstance(f, BinaryAPIResponse) + + @parametrize + @pytest.mark.respx(base_url=base_url) + def test_streaming_response_read_file(self, client: Kernel, respx_mock: MockRouter) -> None: + respx_mock.get("/browsers/id/fs/read_file").mock(return_value=httpx.Response(200, json={"foo": "bar"})) + with client.browsers.fs.with_streaming_response.read_file( + id="id", + path="/J!", + ) as f: + assert not f.is_closed + assert f.http_request.headers.get("X-Stainless-Lang") == "python" + + assert f.json() == {"foo": "bar"} + assert cast(Any, f.is_closed) is True + assert isinstance(f, StreamedBinaryAPIResponse) + + assert cast(Any, f.is_closed) is True + + @parametrize + @pytest.mark.respx(base_url=base_url) + def test_path_params_read_file(self, client: Kernel) -> None: + with pytest.raises(ValueError, match=r"Expected a non-empty value for `id` but received ''"): + client.browsers.fs.with_raw_response.read_file( + id="", + path="/J!", + ) + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + def test_method_set_file_permissions(self, client: Kernel) -> None: + f = client.browsers.fs.set_file_permissions( + id="id", + mode="0611", + path="/J!", + ) + assert f is None + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + def test_method_set_file_permissions_with_all_params(self, client: Kernel) -> None: + f = client.browsers.fs.set_file_permissions( + id="id", + mode="0611", + path="/J!", + group="group", + owner="owner", + ) + assert f is None + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + def test_raw_response_set_file_permissions(self, client: Kernel) -> None: + response = client.browsers.fs.with_raw_response.set_file_permissions( + id="id", + mode="0611", + path="/J!", + ) + + assert response.is_closed is True + assert response.http_request.headers.get("X-Stainless-Lang") == "python" + f = response.parse() + assert f is None + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + def test_streaming_response_set_file_permissions(self, client: Kernel) -> None: + with client.browsers.fs.with_streaming_response.set_file_permissions( + id="id", + mode="0611", + path="/J!", + ) as response: + assert not response.is_closed + assert response.http_request.headers.get("X-Stainless-Lang") == "python" + + f = response.parse() + assert f is None + + assert cast(Any, response.is_closed) is True + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + def test_path_params_set_file_permissions(self, client: Kernel) -> None: + with pytest.raises(ValueError, match=r"Expected a non-empty value for `id` but received ''"): + client.browsers.fs.with_raw_response.set_file_permissions( + id="", + mode="0611", + path="/J!", + ) + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + def test_method_upload(self, client: Kernel) -> None: + f = client.browsers.fs.upload( + id="id", + files=[ + { + "dest_path": "/J!", + "file": b"Example data", + } + ], + ) + assert f is None + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + def test_raw_response_upload(self, client: Kernel) -> None: + response = client.browsers.fs.with_raw_response.upload( + id="id", + files=[ + { + "dest_path": "/J!", + "file": b"Example data", + } + ], + ) + + assert response.is_closed is True + assert response.http_request.headers.get("X-Stainless-Lang") == "python" + f = response.parse() + assert f is None + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + def test_streaming_response_upload(self, client: Kernel) -> None: + with client.browsers.fs.with_streaming_response.upload( + id="id", + files=[ + { + "dest_path": "/J!", + "file": b"Example data", + } + ], + ) as response: + assert not response.is_closed + assert response.http_request.headers.get("X-Stainless-Lang") == "python" + + f = response.parse() + assert f is None + + assert cast(Any, response.is_closed) is True + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + def test_path_params_upload(self, client: Kernel) -> None: + with pytest.raises(ValueError, match=r"Expected a non-empty value for `id` but received ''"): + client.browsers.fs.with_raw_response.upload( + id="", + files=[ + { + "dest_path": "/J!", + "file": b"Example data", + } + ], + ) + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + def test_method_upload_zip(self, client: Kernel) -> None: + f = client.browsers.fs.upload_zip( + id="id", + dest_path="/J!", + zip_file=b"Example data", + ) + assert f is None + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + def test_raw_response_upload_zip(self, client: Kernel) -> None: + response = client.browsers.fs.with_raw_response.upload_zip( + id="id", + dest_path="/J!", + zip_file=b"Example data", + ) + + assert response.is_closed is True + assert response.http_request.headers.get("X-Stainless-Lang") == "python" + f = response.parse() + assert f is None + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + def test_streaming_response_upload_zip(self, client: Kernel) -> None: + with client.browsers.fs.with_streaming_response.upload_zip( + id="id", + dest_path="/J!", + zip_file=b"Example data", + ) as response: + assert not response.is_closed + assert response.http_request.headers.get("X-Stainless-Lang") == "python" + + f = response.parse() + assert f is None + + assert cast(Any, response.is_closed) is True + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + def test_path_params_upload_zip(self, client: Kernel) -> None: + with pytest.raises(ValueError, match=r"Expected a non-empty value for `id` but received ''"): + client.browsers.fs.with_raw_response.upload_zip( + id="", + dest_path="/J!", + zip_file=b"Example data", + ) + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + def test_method_write_file(self, client: Kernel) -> None: + f = client.browsers.fs.write_file( + id="id", + contents=b"Example data", + path="/J!", + ) + assert f is None + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + def test_method_write_file_with_all_params(self, client: Kernel) -> None: + f = client.browsers.fs.write_file( + id="id", + contents=b"Example data", + path="/J!", + mode="0611", + ) + assert f is None + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + def test_raw_response_write_file(self, client: Kernel) -> None: + response = client.browsers.fs.with_raw_response.write_file( + id="id", + contents=b"Example data", + path="/J!", + ) + + assert response.is_closed is True + assert response.http_request.headers.get("X-Stainless-Lang") == "python" + f = response.parse() + assert f is None + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + def test_streaming_response_write_file(self, client: Kernel) -> None: + with client.browsers.fs.with_streaming_response.write_file( + id="id", + contents=b"Example data", + path="/J!", + ) as response: + assert not response.is_closed + assert response.http_request.headers.get("X-Stainless-Lang") == "python" + + f = response.parse() + assert f is None + + assert cast(Any, response.is_closed) is True + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + def test_path_params_write_file(self, client: Kernel) -> None: + with pytest.raises(ValueError, match=r"Expected a non-empty value for `id` but received ''"): + client.browsers.fs.with_raw_response.write_file( + id="", + contents=b"Example data", + path="/J!", + ) + + +class TestAsyncFs: + parametrize = pytest.mark.parametrize( + "async_client", [False, True, {"http_client": "aiohttp"}], indirect=True, ids=["loose", "strict", "aiohttp"] + ) + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + async def test_method_create_directory(self, async_client: AsyncKernel) -> None: + f = await async_client.browsers.fs.create_directory( + id="id", + path="/J!", + ) + assert f is None + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + async def test_method_create_directory_with_all_params(self, async_client: AsyncKernel) -> None: + f = await async_client.browsers.fs.create_directory( + id="id", + path="/J!", + mode="0611", + ) + assert f is None + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + async def test_raw_response_create_directory(self, async_client: AsyncKernel) -> None: + response = await async_client.browsers.fs.with_raw_response.create_directory( + id="id", + path="/J!", + ) + + assert response.is_closed is True + assert response.http_request.headers.get("X-Stainless-Lang") == "python" + f = await response.parse() + assert f is None + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + async def test_streaming_response_create_directory(self, async_client: AsyncKernel) -> None: + async with async_client.browsers.fs.with_streaming_response.create_directory( + id="id", + path="/J!", + ) as response: + assert not response.is_closed + assert response.http_request.headers.get("X-Stainless-Lang") == "python" + + f = await response.parse() + assert f is None + + assert cast(Any, response.is_closed) is True + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + async def test_path_params_create_directory(self, async_client: AsyncKernel) -> None: + with pytest.raises(ValueError, match=r"Expected a non-empty value for `id` but received ''"): + await async_client.browsers.fs.with_raw_response.create_directory( + id="", + path="/J!", + ) + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + async def test_method_delete_directory(self, async_client: AsyncKernel) -> None: + f = await async_client.browsers.fs.delete_directory( + id="id", + path="/J!", + ) + assert f is None + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + async def test_raw_response_delete_directory(self, async_client: AsyncKernel) -> None: + response = await async_client.browsers.fs.with_raw_response.delete_directory( + id="id", + path="/J!", + ) + + assert response.is_closed is True + assert response.http_request.headers.get("X-Stainless-Lang") == "python" + f = await response.parse() + assert f is None + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + async def test_streaming_response_delete_directory(self, async_client: AsyncKernel) -> None: + async with async_client.browsers.fs.with_streaming_response.delete_directory( + id="id", + path="/J!", + ) as response: + assert not response.is_closed + assert response.http_request.headers.get("X-Stainless-Lang") == "python" + + f = await response.parse() + assert f is None + + assert cast(Any, response.is_closed) is True + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + async def test_path_params_delete_directory(self, async_client: AsyncKernel) -> None: + with pytest.raises(ValueError, match=r"Expected a non-empty value for `id` but received ''"): + await async_client.browsers.fs.with_raw_response.delete_directory( + id="", + path="/J!", + ) + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + async def test_method_delete_file(self, async_client: AsyncKernel) -> None: + f = await async_client.browsers.fs.delete_file( + id="id", + path="/J!", + ) + assert f is None + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + async def test_raw_response_delete_file(self, async_client: AsyncKernel) -> None: + response = await async_client.browsers.fs.with_raw_response.delete_file( + id="id", + path="/J!", + ) + + assert response.is_closed is True + assert response.http_request.headers.get("X-Stainless-Lang") == "python" + f = await response.parse() + assert f is None + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + async def test_streaming_response_delete_file(self, async_client: AsyncKernel) -> None: + async with async_client.browsers.fs.with_streaming_response.delete_file( + id="id", + path="/J!", + ) as response: + assert not response.is_closed + assert response.http_request.headers.get("X-Stainless-Lang") == "python" + + f = await response.parse() + assert f is None + + assert cast(Any, response.is_closed) is True + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + async def test_path_params_delete_file(self, async_client: AsyncKernel) -> None: + with pytest.raises(ValueError, match=r"Expected a non-empty value for `id` but received ''"): + await async_client.browsers.fs.with_raw_response.delete_file( + id="", + path="/J!", + ) + + @parametrize + @pytest.mark.respx(base_url=base_url) + async def test_method_download_dir_zip(self, async_client: AsyncKernel, respx_mock: MockRouter) -> None: + respx_mock.get("/browsers/id/fs/download_dir_zip").mock(return_value=httpx.Response(200, json={"foo": "bar"})) + f = await async_client.browsers.fs.download_dir_zip( + id="id", + path="/J!", + ) + assert f.is_closed + assert await f.json() == {"foo": "bar"} + assert cast(Any, f.is_closed) is True + assert isinstance(f, AsyncBinaryAPIResponse) + + @parametrize + @pytest.mark.respx(base_url=base_url) + async def test_raw_response_download_dir_zip(self, async_client: AsyncKernel, respx_mock: MockRouter) -> None: + respx_mock.get("/browsers/id/fs/download_dir_zip").mock(return_value=httpx.Response(200, json={"foo": "bar"})) + + f = await async_client.browsers.fs.with_raw_response.download_dir_zip( + id="id", + path="/J!", + ) + + assert f.is_closed is True + assert f.http_request.headers.get("X-Stainless-Lang") == "python" + assert await f.json() == {"foo": "bar"} + assert isinstance(f, AsyncBinaryAPIResponse) + + @parametrize + @pytest.mark.respx(base_url=base_url) + async def test_streaming_response_download_dir_zip(self, async_client: AsyncKernel, respx_mock: MockRouter) -> None: + respx_mock.get("/browsers/id/fs/download_dir_zip").mock(return_value=httpx.Response(200, json={"foo": "bar"})) + async with async_client.browsers.fs.with_streaming_response.download_dir_zip( + id="id", + path="/J!", + ) as f: + assert not f.is_closed + assert f.http_request.headers.get("X-Stainless-Lang") == "python" + + assert await f.json() == {"foo": "bar"} + assert cast(Any, f.is_closed) is True + assert isinstance(f, AsyncStreamedBinaryAPIResponse) + + assert cast(Any, f.is_closed) is True + + @parametrize + @pytest.mark.respx(base_url=base_url) + async def test_path_params_download_dir_zip(self, async_client: AsyncKernel) -> None: + with pytest.raises(ValueError, match=r"Expected a non-empty value for `id` but received ''"): + await async_client.browsers.fs.with_raw_response.download_dir_zip( + id="", + path="/J!", + ) + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + async def test_method_file_info(self, async_client: AsyncKernel) -> None: + f = await async_client.browsers.fs.file_info( + id="id", + path="/J!", + ) + assert_matches_type(FFileInfoResponse, f, path=["response"]) + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + async def test_raw_response_file_info(self, async_client: AsyncKernel) -> None: + response = await async_client.browsers.fs.with_raw_response.file_info( + id="id", + path="/J!", + ) + + assert response.is_closed is True + assert response.http_request.headers.get("X-Stainless-Lang") == "python" + f = await response.parse() + assert_matches_type(FFileInfoResponse, f, path=["response"]) + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + async def test_streaming_response_file_info(self, async_client: AsyncKernel) -> None: + async with async_client.browsers.fs.with_streaming_response.file_info( + id="id", + path="/J!", + ) as response: + assert not response.is_closed + assert response.http_request.headers.get("X-Stainless-Lang") == "python" + + f = await response.parse() + assert_matches_type(FFileInfoResponse, f, path=["response"]) + + assert cast(Any, response.is_closed) is True + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + async def test_path_params_file_info(self, async_client: AsyncKernel) -> None: + with pytest.raises(ValueError, match=r"Expected a non-empty value for `id` but received ''"): + await async_client.browsers.fs.with_raw_response.file_info( + id="", + path="/J!", + ) + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + async def test_method_list_files(self, async_client: AsyncKernel) -> None: + f = await async_client.browsers.fs.list_files( + id="id", + path="/J!", + ) + assert_matches_type(FListFilesResponse, f, path=["response"]) + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + async def test_raw_response_list_files(self, async_client: AsyncKernel) -> None: + response = await async_client.browsers.fs.with_raw_response.list_files( + id="id", + path="/J!", + ) + + assert response.is_closed is True + assert response.http_request.headers.get("X-Stainless-Lang") == "python" + f = await response.parse() + assert_matches_type(FListFilesResponse, f, path=["response"]) + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + async def test_streaming_response_list_files(self, async_client: AsyncKernel) -> None: + async with async_client.browsers.fs.with_streaming_response.list_files( + id="id", + path="/J!", + ) as response: + assert not response.is_closed + assert response.http_request.headers.get("X-Stainless-Lang") == "python" + + f = await response.parse() + assert_matches_type(FListFilesResponse, f, path=["response"]) + + assert cast(Any, response.is_closed) is True + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + async def test_path_params_list_files(self, async_client: AsyncKernel) -> None: + with pytest.raises(ValueError, match=r"Expected a non-empty value for `id` but received ''"): + await async_client.browsers.fs.with_raw_response.list_files( + id="", + path="/J!", + ) + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + async def test_method_move(self, async_client: AsyncKernel) -> None: + f = await async_client.browsers.fs.move( + id="id", + dest_path="/J!", + src_path="/J!", + ) + assert f is None + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + async def test_raw_response_move(self, async_client: AsyncKernel) -> None: + response = await async_client.browsers.fs.with_raw_response.move( + id="id", + dest_path="/J!", + src_path="/J!", + ) + + assert response.is_closed is True + assert response.http_request.headers.get("X-Stainless-Lang") == "python" + f = await response.parse() + assert f is None + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + async def test_streaming_response_move(self, async_client: AsyncKernel) -> None: + async with async_client.browsers.fs.with_streaming_response.move( + id="id", + dest_path="/J!", + src_path="/J!", + ) as response: + assert not response.is_closed + assert response.http_request.headers.get("X-Stainless-Lang") == "python" + + f = await response.parse() + assert f is None + + assert cast(Any, response.is_closed) is True + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + async def test_path_params_move(self, async_client: AsyncKernel) -> None: + with pytest.raises(ValueError, match=r"Expected a non-empty value for `id` but received ''"): + await async_client.browsers.fs.with_raw_response.move( + id="", + dest_path="/J!", + src_path="/J!", + ) + + @parametrize + @pytest.mark.respx(base_url=base_url) + async def test_method_read_file(self, async_client: AsyncKernel, respx_mock: MockRouter) -> None: + respx_mock.get("/browsers/id/fs/read_file").mock(return_value=httpx.Response(200, json={"foo": "bar"})) + f = await async_client.browsers.fs.read_file( + id="id", + path="/J!", + ) + assert f.is_closed + assert await f.json() == {"foo": "bar"} + assert cast(Any, f.is_closed) is True + assert isinstance(f, AsyncBinaryAPIResponse) + + @parametrize + @pytest.mark.respx(base_url=base_url) + async def test_raw_response_read_file(self, async_client: AsyncKernel, respx_mock: MockRouter) -> None: + respx_mock.get("/browsers/id/fs/read_file").mock(return_value=httpx.Response(200, json={"foo": "bar"})) + + f = await async_client.browsers.fs.with_raw_response.read_file( + id="id", + path="/J!", + ) + + assert f.is_closed is True + assert f.http_request.headers.get("X-Stainless-Lang") == "python" + assert await f.json() == {"foo": "bar"} + assert isinstance(f, AsyncBinaryAPIResponse) + + @parametrize + @pytest.mark.respx(base_url=base_url) + async def test_streaming_response_read_file(self, async_client: AsyncKernel, respx_mock: MockRouter) -> None: + respx_mock.get("/browsers/id/fs/read_file").mock(return_value=httpx.Response(200, json={"foo": "bar"})) + async with async_client.browsers.fs.with_streaming_response.read_file( + id="id", + path="/J!", + ) as f: + assert not f.is_closed + assert f.http_request.headers.get("X-Stainless-Lang") == "python" + + assert await f.json() == {"foo": "bar"} + assert cast(Any, f.is_closed) is True + assert isinstance(f, AsyncStreamedBinaryAPIResponse) + + assert cast(Any, f.is_closed) is True + + @parametrize + @pytest.mark.respx(base_url=base_url) + async def test_path_params_read_file(self, async_client: AsyncKernel) -> None: + with pytest.raises(ValueError, match=r"Expected a non-empty value for `id` but received ''"): + await async_client.browsers.fs.with_raw_response.read_file( + id="", + path="/J!", + ) + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + async def test_method_set_file_permissions(self, async_client: AsyncKernel) -> None: + f = await async_client.browsers.fs.set_file_permissions( + id="id", + mode="0611", + path="/J!", + ) + assert f is None + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + async def test_method_set_file_permissions_with_all_params(self, async_client: AsyncKernel) -> None: + f = await async_client.browsers.fs.set_file_permissions( + id="id", + mode="0611", + path="/J!", + group="group", + owner="owner", + ) + assert f is None + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + async def test_raw_response_set_file_permissions(self, async_client: AsyncKernel) -> None: + response = await async_client.browsers.fs.with_raw_response.set_file_permissions( + id="id", + mode="0611", + path="/J!", + ) + + assert response.is_closed is True + assert response.http_request.headers.get("X-Stainless-Lang") == "python" + f = await response.parse() + assert f is None + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + async def test_streaming_response_set_file_permissions(self, async_client: AsyncKernel) -> None: + async with async_client.browsers.fs.with_streaming_response.set_file_permissions( + id="id", + mode="0611", + path="/J!", + ) as response: + assert not response.is_closed + assert response.http_request.headers.get("X-Stainless-Lang") == "python" + + f = await response.parse() + assert f is None + + assert cast(Any, response.is_closed) is True + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + async def test_path_params_set_file_permissions(self, async_client: AsyncKernel) -> None: + with pytest.raises(ValueError, match=r"Expected a non-empty value for `id` but received ''"): + await async_client.browsers.fs.with_raw_response.set_file_permissions( + id="", + mode="0611", + path="/J!", + ) + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + async def test_method_upload(self, async_client: AsyncKernel) -> None: + f = await async_client.browsers.fs.upload( + id="id", + files=[ + { + "dest_path": "/J!", + "file": b"Example data", + } + ], + ) + assert f is None + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + async def test_raw_response_upload(self, async_client: AsyncKernel) -> None: + response = await async_client.browsers.fs.with_raw_response.upload( + id="id", + files=[ + { + "dest_path": "/J!", + "file": b"Example data", + } + ], + ) + + assert response.is_closed is True + assert response.http_request.headers.get("X-Stainless-Lang") == "python" + f = await response.parse() + assert f is None + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + async def test_streaming_response_upload(self, async_client: AsyncKernel) -> None: + async with async_client.browsers.fs.with_streaming_response.upload( + id="id", + files=[ + { + "dest_path": "/J!", + "file": b"Example data", + } + ], + ) as response: + assert not response.is_closed + assert response.http_request.headers.get("X-Stainless-Lang") == "python" + + f = await response.parse() + assert f is None + + assert cast(Any, response.is_closed) is True + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + async def test_path_params_upload(self, async_client: AsyncKernel) -> None: + with pytest.raises(ValueError, match=r"Expected a non-empty value for `id` but received ''"): + await async_client.browsers.fs.with_raw_response.upload( + id="", + files=[ + { + "dest_path": "/J!", + "file": b"Example data", + } + ], + ) + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + async def test_method_upload_zip(self, async_client: AsyncKernel) -> None: + f = await async_client.browsers.fs.upload_zip( + id="id", + dest_path="/J!", + zip_file=b"Example data", + ) + assert f is None + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + async def test_raw_response_upload_zip(self, async_client: AsyncKernel) -> None: + response = await async_client.browsers.fs.with_raw_response.upload_zip( + id="id", + dest_path="/J!", + zip_file=b"Example data", + ) + + assert response.is_closed is True + assert response.http_request.headers.get("X-Stainless-Lang") == "python" + f = await response.parse() + assert f is None + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + async def test_streaming_response_upload_zip(self, async_client: AsyncKernel) -> None: + async with async_client.browsers.fs.with_streaming_response.upload_zip( + id="id", + dest_path="/J!", + zip_file=b"Example data", + ) as response: + assert not response.is_closed + assert response.http_request.headers.get("X-Stainless-Lang") == "python" + + f = await response.parse() + assert f is None + + assert cast(Any, response.is_closed) is True + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + async def test_path_params_upload_zip(self, async_client: AsyncKernel) -> None: + with pytest.raises(ValueError, match=r"Expected a non-empty value for `id` but received ''"): + await async_client.browsers.fs.with_raw_response.upload_zip( + id="", + dest_path="/J!", + zip_file=b"Example data", + ) + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + async def test_method_write_file(self, async_client: AsyncKernel) -> None: + f = await async_client.browsers.fs.write_file( + id="id", + contents=b"Example data", + path="/J!", + ) + assert f is None + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + async def test_method_write_file_with_all_params(self, async_client: AsyncKernel) -> None: + f = await async_client.browsers.fs.write_file( + id="id", + contents=b"Example data", + path="/J!", + mode="0611", + ) + assert f is None + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + async def test_raw_response_write_file(self, async_client: AsyncKernel) -> None: + response = await async_client.browsers.fs.with_raw_response.write_file( + id="id", + contents=b"Example data", + path="/J!", + ) + + assert response.is_closed is True + assert response.http_request.headers.get("X-Stainless-Lang") == "python" + f = await response.parse() + assert f is None + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + async def test_streaming_response_write_file(self, async_client: AsyncKernel) -> None: + async with async_client.browsers.fs.with_streaming_response.write_file( + id="id", + contents=b"Example data", + path="/J!", + ) as response: + assert not response.is_closed + assert response.http_request.headers.get("X-Stainless-Lang") == "python" + + f = await response.parse() + assert f is None + + assert cast(Any, response.is_closed) is True + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + async def test_path_params_write_file(self, async_client: AsyncKernel) -> None: + with pytest.raises(ValueError, match=r"Expected a non-empty value for `id` but received ''"): + await async_client.browsers.fs.with_raw_response.write_file( + id="", + contents=b"Example data", + path="/J!", + ) diff --git a/tests/api_resources/browsers/test_logs.py b/tests/api_resources/browsers/test_logs.py new file mode 100644 index 00000000..27268b82 --- /dev/null +++ b/tests/api_resources/browsers/test_logs.py @@ -0,0 +1,136 @@ +# 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 kernel import Kernel, AsyncKernel + +base_url = os.environ.get("TEST_API_BASE_URL", "http://127.0.0.1:4010") + + +class TestLogs: + parametrize = pytest.mark.parametrize("client", [False, True], indirect=True, ids=["loose", "strict"]) + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + def test_method_stream(self, client: Kernel) -> None: + log_stream = client.browsers.logs.stream( + id="id", + source="path", + ) + log_stream.response.close() + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + def test_method_stream_with_all_params(self, client: Kernel) -> None: + log_stream = client.browsers.logs.stream( + id="id", + source="path", + follow=True, + path="path", + supervisor_process="supervisor_process", + ) + log_stream.response.close() + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + def test_raw_response_stream(self, client: Kernel) -> None: + response = client.browsers.logs.with_raw_response.stream( + id="id", + source="path", + ) + + assert response.http_request.headers.get("X-Stainless-Lang") == "python" + stream = response.parse() + stream.close() + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + def test_streaming_response_stream(self, client: Kernel) -> None: + with client.browsers.logs.with_streaming_response.stream( + id="id", + source="path", + ) 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 + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + def test_path_params_stream(self, client: Kernel) -> None: + with pytest.raises(ValueError, match=r"Expected a non-empty value for `id` but received ''"): + client.browsers.logs.with_raw_response.stream( + id="", + source="path", + ) + + +class TestAsyncLogs: + parametrize = pytest.mark.parametrize( + "async_client", [False, True, {"http_client": "aiohttp"}], indirect=True, ids=["loose", "strict", "aiohttp"] + ) + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + async def test_method_stream(self, async_client: AsyncKernel) -> None: + log_stream = await async_client.browsers.logs.stream( + id="id", + source="path", + ) + await log_stream.response.aclose() + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + async def test_method_stream_with_all_params(self, async_client: AsyncKernel) -> None: + log_stream = await async_client.browsers.logs.stream( + id="id", + source="path", + follow=True, + path="path", + supervisor_process="supervisor_process", + ) + await log_stream.response.aclose() + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + async def test_raw_response_stream(self, async_client: AsyncKernel) -> None: + response = await async_client.browsers.logs.with_raw_response.stream( + id="id", + source="path", + ) + + assert response.http_request.headers.get("X-Stainless-Lang") == "python" + stream = await response.parse() + await stream.close() + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + async def test_streaming_response_stream(self, async_client: AsyncKernel) -> None: + async with async_client.browsers.logs.with_streaming_response.stream( + id="id", + source="path", + ) 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 + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + async def test_path_params_stream(self, async_client: AsyncKernel) -> None: + with pytest.raises(ValueError, match=r"Expected a non-empty value for `id` but received ''"): + await async_client.browsers.logs.with_raw_response.stream( + id="", + source="path", + ) diff --git a/tests/api_resources/browsers/test_playwright.py b/tests/api_resources/browsers/test_playwright.py new file mode 100644 index 00000000..fd91b832 --- /dev/null +++ b/tests/api_resources/browsers/test_playwright.py @@ -0,0 +1,136 @@ +# 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 kernel import Kernel, AsyncKernel +from tests.utils import assert_matches_type +from kernel.types.browsers import PlaywrightExecuteResponse + +base_url = os.environ.get("TEST_API_BASE_URL", "http://127.0.0.1:4010") + + +class TestPlaywright: + parametrize = pytest.mark.parametrize("client", [False, True], indirect=True, ids=["loose", "strict"]) + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + def test_method_execute(self, client: Kernel) -> None: + playwright = client.browsers.playwright.execute( + id="id", + code="code", + ) + assert_matches_type(PlaywrightExecuteResponse, playwright, path=["response"]) + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + def test_method_execute_with_all_params(self, client: Kernel) -> None: + playwright = client.browsers.playwright.execute( + id="id", + code="code", + timeout_sec=1, + ) + assert_matches_type(PlaywrightExecuteResponse, playwright, path=["response"]) + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + def test_raw_response_execute(self, client: Kernel) -> None: + response = client.browsers.playwright.with_raw_response.execute( + id="id", + code="code", + ) + + assert response.is_closed is True + assert response.http_request.headers.get("X-Stainless-Lang") == "python" + playwright = response.parse() + assert_matches_type(PlaywrightExecuteResponse, playwright, path=["response"]) + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + def test_streaming_response_execute(self, client: Kernel) -> None: + with client.browsers.playwright.with_streaming_response.execute( + id="id", + code="code", + ) as response: + assert not response.is_closed + assert response.http_request.headers.get("X-Stainless-Lang") == "python" + + playwright = response.parse() + assert_matches_type(PlaywrightExecuteResponse, playwright, path=["response"]) + + assert cast(Any, response.is_closed) is True + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + def test_path_params_execute(self, client: Kernel) -> None: + with pytest.raises(ValueError, match=r"Expected a non-empty value for `id` but received ''"): + client.browsers.playwright.with_raw_response.execute( + id="", + code="code", + ) + + +class TestAsyncPlaywright: + parametrize = pytest.mark.parametrize( + "async_client", [False, True, {"http_client": "aiohttp"}], indirect=True, ids=["loose", "strict", "aiohttp"] + ) + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + async def test_method_execute(self, async_client: AsyncKernel) -> None: + playwright = await async_client.browsers.playwright.execute( + id="id", + code="code", + ) + assert_matches_type(PlaywrightExecuteResponse, playwright, path=["response"]) + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + async def test_method_execute_with_all_params(self, async_client: AsyncKernel) -> None: + playwright = await async_client.browsers.playwright.execute( + id="id", + code="code", + timeout_sec=1, + ) + assert_matches_type(PlaywrightExecuteResponse, playwright, path=["response"]) + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + async def test_raw_response_execute(self, async_client: AsyncKernel) -> None: + response = await async_client.browsers.playwright.with_raw_response.execute( + id="id", + code="code", + ) + + assert response.is_closed is True + assert response.http_request.headers.get("X-Stainless-Lang") == "python" + playwright = await response.parse() + assert_matches_type(PlaywrightExecuteResponse, playwright, path=["response"]) + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + async def test_streaming_response_execute(self, async_client: AsyncKernel) -> None: + async with async_client.browsers.playwright.with_streaming_response.execute( + id="id", + code="code", + ) as response: + assert not response.is_closed + assert response.http_request.headers.get("X-Stainless-Lang") == "python" + + playwright = await response.parse() + assert_matches_type(PlaywrightExecuteResponse, playwright, path=["response"]) + + assert cast(Any, response.is_closed) is True + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + async def test_path_params_execute(self, async_client: AsyncKernel) -> None: + with pytest.raises(ValueError, match=r"Expected a non-empty value for `id` but received ''"): + await async_client.browsers.playwright.with_raw_response.execute( + id="", + code="code", + ) diff --git a/tests/api_resources/browsers/test_process.py b/tests/api_resources/browsers/test_process.py new file mode 100644 index 00000000..0418d219 --- /dev/null +++ b/tests/api_resources/browsers/test_process.py @@ -0,0 +1,839 @@ +# 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 kernel import Kernel, AsyncKernel +from tests.utils import assert_matches_type +from kernel.types.browsers import ( + ProcessExecResponse, + ProcessKillResponse, + ProcessSpawnResponse, + ProcessStdinResponse, + ProcessResizeResponse, + ProcessStatusResponse, +) + +base_url = os.environ.get("TEST_API_BASE_URL", "http://127.0.0.1:4010") + + +class TestProcess: + parametrize = pytest.mark.parametrize("client", [False, True], indirect=True, ids=["loose", "strict"]) + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + def test_method_exec(self, client: Kernel) -> None: + process = client.browsers.process.exec( + id="id", + command="command", + ) + assert_matches_type(ProcessExecResponse, process, path=["response"]) + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + def test_method_exec_with_all_params(self, client: Kernel) -> None: + process = client.browsers.process.exec( + id="id", + command="command", + args=["string"], + as_root=True, + as_user="as_user", + cwd="/J!", + env={"foo": "string"}, + timeout_sec=0, + ) + assert_matches_type(ProcessExecResponse, process, path=["response"]) + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + def test_raw_response_exec(self, client: Kernel) -> None: + response = client.browsers.process.with_raw_response.exec( + id="id", + command="command", + ) + + assert response.is_closed is True + assert response.http_request.headers.get("X-Stainless-Lang") == "python" + process = response.parse() + assert_matches_type(ProcessExecResponse, process, path=["response"]) + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + def test_streaming_response_exec(self, client: Kernel) -> None: + with client.browsers.process.with_streaming_response.exec( + id="id", + command="command", + ) as response: + assert not response.is_closed + assert response.http_request.headers.get("X-Stainless-Lang") == "python" + + process = response.parse() + assert_matches_type(ProcessExecResponse, process, path=["response"]) + + assert cast(Any, response.is_closed) is True + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + def test_path_params_exec(self, client: Kernel) -> None: + with pytest.raises(ValueError, match=r"Expected a non-empty value for `id` but received ''"): + client.browsers.process.with_raw_response.exec( + id="", + command="command", + ) + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + def test_method_kill(self, client: Kernel) -> None: + process = client.browsers.process.kill( + process_id="182bd5e5-6e1a-4fe4-a799-aa6d9a6ab26e", + id="id", + signal="TERM", + ) + assert_matches_type(ProcessKillResponse, process, path=["response"]) + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + def test_raw_response_kill(self, client: Kernel) -> None: + response = client.browsers.process.with_raw_response.kill( + process_id="182bd5e5-6e1a-4fe4-a799-aa6d9a6ab26e", + id="id", + signal="TERM", + ) + + assert response.is_closed is True + assert response.http_request.headers.get("X-Stainless-Lang") == "python" + process = response.parse() + assert_matches_type(ProcessKillResponse, process, path=["response"]) + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + def test_streaming_response_kill(self, client: Kernel) -> None: + with client.browsers.process.with_streaming_response.kill( + process_id="182bd5e5-6e1a-4fe4-a799-aa6d9a6ab26e", + id="id", + signal="TERM", + ) as response: + assert not response.is_closed + assert response.http_request.headers.get("X-Stainless-Lang") == "python" + + process = response.parse() + assert_matches_type(ProcessKillResponse, process, path=["response"]) + + assert cast(Any, response.is_closed) is True + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + def test_path_params_kill(self, client: Kernel) -> None: + with pytest.raises(ValueError, match=r"Expected a non-empty value for `id` but received ''"): + client.browsers.process.with_raw_response.kill( + process_id="182bd5e5-6e1a-4fe4-a799-aa6d9a6ab26e", + id="", + signal="TERM", + ) + + with pytest.raises(ValueError, match=r"Expected a non-empty value for `process_id` but received ''"): + client.browsers.process.with_raw_response.kill( + process_id="", + id="id", + signal="TERM", + ) + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + def test_method_resize(self, client: Kernel) -> None: + process = client.browsers.process.resize( + process_id="182bd5e5-6e1a-4fe4-a799-aa6d9a6ab26e", + id="id", + cols=1, + rows=1, + ) + assert_matches_type(ProcessResizeResponse, process, path=["response"]) + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + def test_raw_response_resize(self, client: Kernel) -> None: + response = client.browsers.process.with_raw_response.resize( + process_id="182bd5e5-6e1a-4fe4-a799-aa6d9a6ab26e", + id="id", + cols=1, + rows=1, + ) + + assert response.is_closed is True + assert response.http_request.headers.get("X-Stainless-Lang") == "python" + process = response.parse() + assert_matches_type(ProcessResizeResponse, process, path=["response"]) + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + def test_streaming_response_resize(self, client: Kernel) -> None: + with client.browsers.process.with_streaming_response.resize( + process_id="182bd5e5-6e1a-4fe4-a799-aa6d9a6ab26e", + id="id", + cols=1, + rows=1, + ) as response: + assert not response.is_closed + assert response.http_request.headers.get("X-Stainless-Lang") == "python" + + process = response.parse() + assert_matches_type(ProcessResizeResponse, process, path=["response"]) + + assert cast(Any, response.is_closed) is True + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + def test_path_params_resize(self, client: Kernel) -> None: + with pytest.raises(ValueError, match=r"Expected a non-empty value for `id` but received ''"): + client.browsers.process.with_raw_response.resize( + process_id="182bd5e5-6e1a-4fe4-a799-aa6d9a6ab26e", + id="", + cols=1, + rows=1, + ) + + with pytest.raises(ValueError, match=r"Expected a non-empty value for `process_id` but received ''"): + client.browsers.process.with_raw_response.resize( + process_id="", + id="id", + cols=1, + rows=1, + ) + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + def test_method_spawn(self, client: Kernel) -> None: + process = client.browsers.process.spawn( + id="id", + command="command", + ) + assert_matches_type(ProcessSpawnResponse, process, path=["response"]) + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + def test_method_spawn_with_all_params(self, client: Kernel) -> None: + process = client.browsers.process.spawn( + id="id", + command="command", + allocate_tty=True, + args=["string"], + as_root=True, + as_user="as_user", + cols=1, + cwd="/J!", + env={"foo": "string"}, + rows=1, + timeout_sec=0, + ) + assert_matches_type(ProcessSpawnResponse, process, path=["response"]) + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + def test_raw_response_spawn(self, client: Kernel) -> None: + response = client.browsers.process.with_raw_response.spawn( + id="id", + command="command", + ) + + assert response.is_closed is True + assert response.http_request.headers.get("X-Stainless-Lang") == "python" + process = response.parse() + assert_matches_type(ProcessSpawnResponse, process, path=["response"]) + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + def test_streaming_response_spawn(self, client: Kernel) -> None: + with client.browsers.process.with_streaming_response.spawn( + id="id", + command="command", + ) as response: + assert not response.is_closed + assert response.http_request.headers.get("X-Stainless-Lang") == "python" + + process = response.parse() + assert_matches_type(ProcessSpawnResponse, process, path=["response"]) + + assert cast(Any, response.is_closed) is True + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + def test_path_params_spawn(self, client: Kernel) -> None: + with pytest.raises(ValueError, match=r"Expected a non-empty value for `id` but received ''"): + client.browsers.process.with_raw_response.spawn( + id="", + command="command", + ) + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + def test_method_status(self, client: Kernel) -> None: + process = client.browsers.process.status( + process_id="182bd5e5-6e1a-4fe4-a799-aa6d9a6ab26e", + id="id", + ) + assert_matches_type(ProcessStatusResponse, process, path=["response"]) + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + def test_raw_response_status(self, client: Kernel) -> None: + response = client.browsers.process.with_raw_response.status( + process_id="182bd5e5-6e1a-4fe4-a799-aa6d9a6ab26e", + id="id", + ) + + assert response.is_closed is True + assert response.http_request.headers.get("X-Stainless-Lang") == "python" + process = response.parse() + assert_matches_type(ProcessStatusResponse, process, path=["response"]) + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + def test_streaming_response_status(self, client: Kernel) -> None: + with client.browsers.process.with_streaming_response.status( + process_id="182bd5e5-6e1a-4fe4-a799-aa6d9a6ab26e", + id="id", + ) as response: + assert not response.is_closed + assert response.http_request.headers.get("X-Stainless-Lang") == "python" + + process = response.parse() + assert_matches_type(ProcessStatusResponse, process, path=["response"]) + + assert cast(Any, response.is_closed) is True + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + def test_path_params_status(self, client: Kernel) -> None: + with pytest.raises(ValueError, match=r"Expected a non-empty value for `id` but received ''"): + client.browsers.process.with_raw_response.status( + process_id="182bd5e5-6e1a-4fe4-a799-aa6d9a6ab26e", + id="", + ) + + with pytest.raises(ValueError, match=r"Expected a non-empty value for `process_id` but received ''"): + client.browsers.process.with_raw_response.status( + process_id="", + id="id", + ) + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + def test_method_stdin(self, client: Kernel) -> None: + process = client.browsers.process.stdin( + process_id="182bd5e5-6e1a-4fe4-a799-aa6d9a6ab26e", + id="id", + data_b64="data_b64", + ) + assert_matches_type(ProcessStdinResponse, process, path=["response"]) + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + def test_raw_response_stdin(self, client: Kernel) -> None: + response = client.browsers.process.with_raw_response.stdin( + process_id="182bd5e5-6e1a-4fe4-a799-aa6d9a6ab26e", + id="id", + data_b64="data_b64", + ) + + assert response.is_closed is True + assert response.http_request.headers.get("X-Stainless-Lang") == "python" + process = response.parse() + assert_matches_type(ProcessStdinResponse, process, path=["response"]) + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + def test_streaming_response_stdin(self, client: Kernel) -> None: + with client.browsers.process.with_streaming_response.stdin( + process_id="182bd5e5-6e1a-4fe4-a799-aa6d9a6ab26e", + id="id", + data_b64="data_b64", + ) as response: + assert not response.is_closed + assert response.http_request.headers.get("X-Stainless-Lang") == "python" + + process = response.parse() + assert_matches_type(ProcessStdinResponse, process, path=["response"]) + + assert cast(Any, response.is_closed) is True + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + def test_path_params_stdin(self, client: Kernel) -> None: + with pytest.raises(ValueError, match=r"Expected a non-empty value for `id` but received ''"): + client.browsers.process.with_raw_response.stdin( + process_id="182bd5e5-6e1a-4fe4-a799-aa6d9a6ab26e", + id="", + data_b64="data_b64", + ) + + with pytest.raises(ValueError, match=r"Expected a non-empty value for `process_id` but received ''"): + client.browsers.process.with_raw_response.stdin( + process_id="", + id="id", + data_b64="data_b64", + ) + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + def test_method_stdout_stream(self, client: Kernel) -> None: + process_stream = client.browsers.process.stdout_stream( + process_id="182bd5e5-6e1a-4fe4-a799-aa6d9a6ab26e", + id="id", + ) + process_stream.response.close() + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + def test_raw_response_stdout_stream(self, client: Kernel) -> None: + response = client.browsers.process.with_raw_response.stdout_stream( + process_id="182bd5e5-6e1a-4fe4-a799-aa6d9a6ab26e", + id="id", + ) + + assert response.http_request.headers.get("X-Stainless-Lang") == "python" + stream = response.parse() + stream.close() + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + def test_streaming_response_stdout_stream(self, client: Kernel) -> None: + with client.browsers.process.with_streaming_response.stdout_stream( + process_id="182bd5e5-6e1a-4fe4-a799-aa6d9a6ab26e", + id="id", + ) 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 + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + def test_path_params_stdout_stream(self, client: Kernel) -> None: + with pytest.raises(ValueError, match=r"Expected a non-empty value for `id` but received ''"): + client.browsers.process.with_raw_response.stdout_stream( + process_id="182bd5e5-6e1a-4fe4-a799-aa6d9a6ab26e", + id="", + ) + + with pytest.raises(ValueError, match=r"Expected a non-empty value for `process_id` but received ''"): + client.browsers.process.with_raw_response.stdout_stream( + process_id="", + id="id", + ) + + +class TestAsyncProcess: + parametrize = pytest.mark.parametrize( + "async_client", [False, True, {"http_client": "aiohttp"}], indirect=True, ids=["loose", "strict", "aiohttp"] + ) + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + async def test_method_exec(self, async_client: AsyncKernel) -> None: + process = await async_client.browsers.process.exec( + id="id", + command="command", + ) + assert_matches_type(ProcessExecResponse, process, path=["response"]) + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + async def test_method_exec_with_all_params(self, async_client: AsyncKernel) -> None: + process = await async_client.browsers.process.exec( + id="id", + command="command", + args=["string"], + as_root=True, + as_user="as_user", + cwd="/J!", + env={"foo": "string"}, + timeout_sec=0, + ) + assert_matches_type(ProcessExecResponse, process, path=["response"]) + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + async def test_raw_response_exec(self, async_client: AsyncKernel) -> None: + response = await async_client.browsers.process.with_raw_response.exec( + id="id", + command="command", + ) + + assert response.is_closed is True + assert response.http_request.headers.get("X-Stainless-Lang") == "python" + process = await response.parse() + assert_matches_type(ProcessExecResponse, process, path=["response"]) + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + async def test_streaming_response_exec(self, async_client: AsyncKernel) -> None: + async with async_client.browsers.process.with_streaming_response.exec( + id="id", + command="command", + ) as response: + assert not response.is_closed + assert response.http_request.headers.get("X-Stainless-Lang") == "python" + + process = await response.parse() + assert_matches_type(ProcessExecResponse, process, path=["response"]) + + assert cast(Any, response.is_closed) is True + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + async def test_path_params_exec(self, async_client: AsyncKernel) -> None: + with pytest.raises(ValueError, match=r"Expected a non-empty value for `id` but received ''"): + await async_client.browsers.process.with_raw_response.exec( + id="", + command="command", + ) + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + async def test_method_kill(self, async_client: AsyncKernel) -> None: + process = await async_client.browsers.process.kill( + process_id="182bd5e5-6e1a-4fe4-a799-aa6d9a6ab26e", + id="id", + signal="TERM", + ) + assert_matches_type(ProcessKillResponse, process, path=["response"]) + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + async def test_raw_response_kill(self, async_client: AsyncKernel) -> None: + response = await async_client.browsers.process.with_raw_response.kill( + process_id="182bd5e5-6e1a-4fe4-a799-aa6d9a6ab26e", + id="id", + signal="TERM", + ) + + assert response.is_closed is True + assert response.http_request.headers.get("X-Stainless-Lang") == "python" + process = await response.parse() + assert_matches_type(ProcessKillResponse, process, path=["response"]) + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + async def test_streaming_response_kill(self, async_client: AsyncKernel) -> None: + async with async_client.browsers.process.with_streaming_response.kill( + process_id="182bd5e5-6e1a-4fe4-a799-aa6d9a6ab26e", + id="id", + signal="TERM", + ) as response: + assert not response.is_closed + assert response.http_request.headers.get("X-Stainless-Lang") == "python" + + process = await response.parse() + assert_matches_type(ProcessKillResponse, process, path=["response"]) + + assert cast(Any, response.is_closed) is True + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + async def test_path_params_kill(self, async_client: AsyncKernel) -> None: + with pytest.raises(ValueError, match=r"Expected a non-empty value for `id` but received ''"): + await async_client.browsers.process.with_raw_response.kill( + process_id="182bd5e5-6e1a-4fe4-a799-aa6d9a6ab26e", + id="", + signal="TERM", + ) + + with pytest.raises(ValueError, match=r"Expected a non-empty value for `process_id` but received ''"): + await async_client.browsers.process.with_raw_response.kill( + process_id="", + id="id", + signal="TERM", + ) + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + async def test_method_resize(self, async_client: AsyncKernel) -> None: + process = await async_client.browsers.process.resize( + process_id="182bd5e5-6e1a-4fe4-a799-aa6d9a6ab26e", + id="id", + cols=1, + rows=1, + ) + assert_matches_type(ProcessResizeResponse, process, path=["response"]) + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + async def test_raw_response_resize(self, async_client: AsyncKernel) -> None: + response = await async_client.browsers.process.with_raw_response.resize( + process_id="182bd5e5-6e1a-4fe4-a799-aa6d9a6ab26e", + id="id", + cols=1, + rows=1, + ) + + assert response.is_closed is True + assert response.http_request.headers.get("X-Stainless-Lang") == "python" + process = await response.parse() + assert_matches_type(ProcessResizeResponse, process, path=["response"]) + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + async def test_streaming_response_resize(self, async_client: AsyncKernel) -> None: + async with async_client.browsers.process.with_streaming_response.resize( + process_id="182bd5e5-6e1a-4fe4-a799-aa6d9a6ab26e", + id="id", + cols=1, + rows=1, + ) as response: + assert not response.is_closed + assert response.http_request.headers.get("X-Stainless-Lang") == "python" + + process = await response.parse() + assert_matches_type(ProcessResizeResponse, process, path=["response"]) + + assert cast(Any, response.is_closed) is True + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + async def test_path_params_resize(self, async_client: AsyncKernel) -> None: + with pytest.raises(ValueError, match=r"Expected a non-empty value for `id` but received ''"): + await async_client.browsers.process.with_raw_response.resize( + process_id="182bd5e5-6e1a-4fe4-a799-aa6d9a6ab26e", + id="", + cols=1, + rows=1, + ) + + with pytest.raises(ValueError, match=r"Expected a non-empty value for `process_id` but received ''"): + await async_client.browsers.process.with_raw_response.resize( + process_id="", + id="id", + cols=1, + rows=1, + ) + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + async def test_method_spawn(self, async_client: AsyncKernel) -> None: + process = await async_client.browsers.process.spawn( + id="id", + command="command", + ) + assert_matches_type(ProcessSpawnResponse, process, path=["response"]) + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + async def test_method_spawn_with_all_params(self, async_client: AsyncKernel) -> None: + process = await async_client.browsers.process.spawn( + id="id", + command="command", + allocate_tty=True, + args=["string"], + as_root=True, + as_user="as_user", + cols=1, + cwd="/J!", + env={"foo": "string"}, + rows=1, + timeout_sec=0, + ) + assert_matches_type(ProcessSpawnResponse, process, path=["response"]) + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + async def test_raw_response_spawn(self, async_client: AsyncKernel) -> None: + response = await async_client.browsers.process.with_raw_response.spawn( + id="id", + command="command", + ) + + assert response.is_closed is True + assert response.http_request.headers.get("X-Stainless-Lang") == "python" + process = await response.parse() + assert_matches_type(ProcessSpawnResponse, process, path=["response"]) + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + async def test_streaming_response_spawn(self, async_client: AsyncKernel) -> None: + async with async_client.browsers.process.with_streaming_response.spawn( + id="id", + command="command", + ) as response: + assert not response.is_closed + assert response.http_request.headers.get("X-Stainless-Lang") == "python" + + process = await response.parse() + assert_matches_type(ProcessSpawnResponse, process, path=["response"]) + + assert cast(Any, response.is_closed) is True + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + async def test_path_params_spawn(self, async_client: AsyncKernel) -> None: + with pytest.raises(ValueError, match=r"Expected a non-empty value for `id` but received ''"): + await async_client.browsers.process.with_raw_response.spawn( + id="", + command="command", + ) + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + async def test_method_status(self, async_client: AsyncKernel) -> None: + process = await async_client.browsers.process.status( + process_id="182bd5e5-6e1a-4fe4-a799-aa6d9a6ab26e", + id="id", + ) + assert_matches_type(ProcessStatusResponse, process, path=["response"]) + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + async def test_raw_response_status(self, async_client: AsyncKernel) -> None: + response = await async_client.browsers.process.with_raw_response.status( + process_id="182bd5e5-6e1a-4fe4-a799-aa6d9a6ab26e", + id="id", + ) + + assert response.is_closed is True + assert response.http_request.headers.get("X-Stainless-Lang") == "python" + process = await response.parse() + assert_matches_type(ProcessStatusResponse, process, path=["response"]) + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + async def test_streaming_response_status(self, async_client: AsyncKernel) -> None: + async with async_client.browsers.process.with_streaming_response.status( + process_id="182bd5e5-6e1a-4fe4-a799-aa6d9a6ab26e", + id="id", + ) as response: + assert not response.is_closed + assert response.http_request.headers.get("X-Stainless-Lang") == "python" + + process = await response.parse() + assert_matches_type(ProcessStatusResponse, process, path=["response"]) + + assert cast(Any, response.is_closed) is True + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + async def test_path_params_status(self, async_client: AsyncKernel) -> None: + with pytest.raises(ValueError, match=r"Expected a non-empty value for `id` but received ''"): + await async_client.browsers.process.with_raw_response.status( + process_id="182bd5e5-6e1a-4fe4-a799-aa6d9a6ab26e", + id="", + ) + + with pytest.raises(ValueError, match=r"Expected a non-empty value for `process_id` but received ''"): + await async_client.browsers.process.with_raw_response.status( + process_id="", + id="id", + ) + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + async def test_method_stdin(self, async_client: AsyncKernel) -> None: + process = await async_client.browsers.process.stdin( + process_id="182bd5e5-6e1a-4fe4-a799-aa6d9a6ab26e", + id="id", + data_b64="data_b64", + ) + assert_matches_type(ProcessStdinResponse, process, path=["response"]) + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + async def test_raw_response_stdin(self, async_client: AsyncKernel) -> None: + response = await async_client.browsers.process.with_raw_response.stdin( + process_id="182bd5e5-6e1a-4fe4-a799-aa6d9a6ab26e", + id="id", + data_b64="data_b64", + ) + + assert response.is_closed is True + assert response.http_request.headers.get("X-Stainless-Lang") == "python" + process = await response.parse() + assert_matches_type(ProcessStdinResponse, process, path=["response"]) + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + async def test_streaming_response_stdin(self, async_client: AsyncKernel) -> None: + async with async_client.browsers.process.with_streaming_response.stdin( + process_id="182bd5e5-6e1a-4fe4-a799-aa6d9a6ab26e", + id="id", + data_b64="data_b64", + ) as response: + assert not response.is_closed + assert response.http_request.headers.get("X-Stainless-Lang") == "python" + + process = await response.parse() + assert_matches_type(ProcessStdinResponse, process, path=["response"]) + + assert cast(Any, response.is_closed) is True + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + async def test_path_params_stdin(self, async_client: AsyncKernel) -> None: + with pytest.raises(ValueError, match=r"Expected a non-empty value for `id` but received ''"): + await async_client.browsers.process.with_raw_response.stdin( + process_id="182bd5e5-6e1a-4fe4-a799-aa6d9a6ab26e", + id="", + data_b64="data_b64", + ) + + with pytest.raises(ValueError, match=r"Expected a non-empty value for `process_id` but received ''"): + await async_client.browsers.process.with_raw_response.stdin( + process_id="", + id="id", + data_b64="data_b64", + ) + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + async def test_method_stdout_stream(self, async_client: AsyncKernel) -> None: + process_stream = await async_client.browsers.process.stdout_stream( + process_id="182bd5e5-6e1a-4fe4-a799-aa6d9a6ab26e", + id="id", + ) + await process_stream.response.aclose() + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + async def test_raw_response_stdout_stream(self, async_client: AsyncKernel) -> None: + response = await async_client.browsers.process.with_raw_response.stdout_stream( + process_id="182bd5e5-6e1a-4fe4-a799-aa6d9a6ab26e", + id="id", + ) + + assert response.http_request.headers.get("X-Stainless-Lang") == "python" + stream = await response.parse() + await stream.close() + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + async def test_streaming_response_stdout_stream(self, async_client: AsyncKernel) -> None: + async with async_client.browsers.process.with_streaming_response.stdout_stream( + process_id="182bd5e5-6e1a-4fe4-a799-aa6d9a6ab26e", + id="id", + ) 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 + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + async def test_path_params_stdout_stream(self, async_client: AsyncKernel) -> None: + with pytest.raises(ValueError, match=r"Expected a non-empty value for `id` but received ''"): + await async_client.browsers.process.with_raw_response.stdout_stream( + process_id="182bd5e5-6e1a-4fe4-a799-aa6d9a6ab26e", + id="", + ) + + with pytest.raises(ValueError, match=r"Expected a non-empty value for `process_id` but received ''"): + await async_client.browsers.process.with_raw_response.stdout_stream( + process_id="", + id="id", + ) diff --git a/tests/api_resources/browsers/test_replays.py b/tests/api_resources/browsers/test_replays.py new file mode 100644 index 00000000..0b450ff8 --- /dev/null +++ b/tests/api_resources/browsers/test_replays.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, cast + +import httpx +import pytest +from respx import MockRouter + +from kernel import Kernel, AsyncKernel +from tests.utils import assert_matches_type +from kernel._response import ( + BinaryAPIResponse, + AsyncBinaryAPIResponse, + StreamedBinaryAPIResponse, + AsyncStreamedBinaryAPIResponse, +) +from kernel.types.browsers import ReplayListResponse, ReplayStartResponse + +base_url = os.environ.get("TEST_API_BASE_URL", "http://127.0.0.1:4010") + + +class TestReplays: + parametrize = pytest.mark.parametrize("client", [False, True], indirect=True, ids=["loose", "strict"]) + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + def test_method_list(self, client: Kernel) -> None: + replay = client.browsers.replays.list( + "id", + ) + assert_matches_type(ReplayListResponse, replay, path=["response"]) + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + def test_raw_response_list(self, client: Kernel) -> None: + response = client.browsers.replays.with_raw_response.list( + "id", + ) + + assert response.is_closed is True + assert response.http_request.headers.get("X-Stainless-Lang") == "python" + replay = response.parse() + assert_matches_type(ReplayListResponse, replay, path=["response"]) + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + def test_streaming_response_list(self, client: Kernel) -> None: + with client.browsers.replays.with_streaming_response.list( + "id", + ) as response: + assert not response.is_closed + assert response.http_request.headers.get("X-Stainless-Lang") == "python" + + replay = response.parse() + assert_matches_type(ReplayListResponse, replay, path=["response"]) + + assert cast(Any, response.is_closed) is True + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + def test_path_params_list(self, client: Kernel) -> None: + with pytest.raises(ValueError, match=r"Expected a non-empty value for `id` but received ''"): + client.browsers.replays.with_raw_response.list( + "", + ) + + @parametrize + @pytest.mark.respx(base_url=base_url) + def test_method_download(self, client: Kernel, respx_mock: MockRouter) -> None: + respx_mock.get("/browsers/id/replays/replay_id").mock(return_value=httpx.Response(200, json={"foo": "bar"})) + replay = client.browsers.replays.download( + replay_id="replay_id", + id="id", + ) + assert replay.is_closed + assert replay.json() == {"foo": "bar"} + assert cast(Any, replay.is_closed) is True + assert isinstance(replay, BinaryAPIResponse) + + @parametrize + @pytest.mark.respx(base_url=base_url) + def test_raw_response_download(self, client: Kernel, respx_mock: MockRouter) -> None: + respx_mock.get("/browsers/id/replays/replay_id").mock(return_value=httpx.Response(200, json={"foo": "bar"})) + + replay = client.browsers.replays.with_raw_response.download( + replay_id="replay_id", + id="id", + ) + + assert replay.is_closed is True + assert replay.http_request.headers.get("X-Stainless-Lang") == "python" + assert replay.json() == {"foo": "bar"} + assert isinstance(replay, BinaryAPIResponse) + + @parametrize + @pytest.mark.respx(base_url=base_url) + def test_streaming_response_download(self, client: Kernel, respx_mock: MockRouter) -> None: + respx_mock.get("/browsers/id/replays/replay_id").mock(return_value=httpx.Response(200, json={"foo": "bar"})) + with client.browsers.replays.with_streaming_response.download( + replay_id="replay_id", + id="id", + ) as replay: + assert not replay.is_closed + assert replay.http_request.headers.get("X-Stainless-Lang") == "python" + + assert replay.json() == {"foo": "bar"} + assert cast(Any, replay.is_closed) is True + assert isinstance(replay, StreamedBinaryAPIResponse) + + assert cast(Any, replay.is_closed) is True + + @parametrize + @pytest.mark.respx(base_url=base_url) + def test_path_params_download(self, client: Kernel) -> None: + with pytest.raises(ValueError, match=r"Expected a non-empty value for `id` but received ''"): + client.browsers.replays.with_raw_response.download( + replay_id="replay_id", + id="", + ) + + with pytest.raises(ValueError, match=r"Expected a non-empty value for `replay_id` but received ''"): + client.browsers.replays.with_raw_response.download( + replay_id="", + id="id", + ) + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + def test_method_start(self, client: Kernel) -> None: + replay = client.browsers.replays.start( + id="id", + ) + assert_matches_type(ReplayStartResponse, replay, path=["response"]) + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + def test_method_start_with_all_params(self, client: Kernel) -> None: + replay = client.browsers.replays.start( + id="id", + framerate=1, + max_duration_in_seconds=1, + record_audio=True, + ) + assert_matches_type(ReplayStartResponse, replay, path=["response"]) + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + def test_raw_response_start(self, client: Kernel) -> None: + response = client.browsers.replays.with_raw_response.start( + id="id", + ) + + assert response.is_closed is True + assert response.http_request.headers.get("X-Stainless-Lang") == "python" + replay = response.parse() + assert_matches_type(ReplayStartResponse, replay, path=["response"]) + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + def test_streaming_response_start(self, client: Kernel) -> None: + with client.browsers.replays.with_streaming_response.start( + id="id", + ) as response: + assert not response.is_closed + assert response.http_request.headers.get("X-Stainless-Lang") == "python" + + replay = response.parse() + assert_matches_type(ReplayStartResponse, replay, path=["response"]) + + assert cast(Any, response.is_closed) is True + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + def test_path_params_start(self, client: Kernel) -> None: + with pytest.raises(ValueError, match=r"Expected a non-empty value for `id` but received ''"): + client.browsers.replays.with_raw_response.start( + id="", + ) + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + def test_method_stop(self, client: Kernel) -> None: + replay = client.browsers.replays.stop( + replay_id="replay_id", + id="id", + ) + assert replay is None + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + def test_raw_response_stop(self, client: Kernel) -> None: + response = client.browsers.replays.with_raw_response.stop( + replay_id="replay_id", + id="id", + ) + + assert response.is_closed is True + assert response.http_request.headers.get("X-Stainless-Lang") == "python" + replay = response.parse() + assert replay is None + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + def test_streaming_response_stop(self, client: Kernel) -> None: + with client.browsers.replays.with_streaming_response.stop( + replay_id="replay_id", + id="id", + ) as response: + assert not response.is_closed + assert response.http_request.headers.get("X-Stainless-Lang") == "python" + + replay = response.parse() + assert replay is None + + assert cast(Any, response.is_closed) is True + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + def test_path_params_stop(self, client: Kernel) -> None: + with pytest.raises(ValueError, match=r"Expected a non-empty value for `id` but received ''"): + client.browsers.replays.with_raw_response.stop( + replay_id="replay_id", + id="", + ) + + with pytest.raises(ValueError, match=r"Expected a non-empty value for `replay_id` but received ''"): + client.browsers.replays.with_raw_response.stop( + replay_id="", + id="id", + ) + + +class TestAsyncReplays: + parametrize = pytest.mark.parametrize( + "async_client", [False, True, {"http_client": "aiohttp"}], indirect=True, ids=["loose", "strict", "aiohttp"] + ) + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + async def test_method_list(self, async_client: AsyncKernel) -> None: + replay = await async_client.browsers.replays.list( + "id", + ) + assert_matches_type(ReplayListResponse, replay, path=["response"]) + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + async def test_raw_response_list(self, async_client: AsyncKernel) -> None: + response = await async_client.browsers.replays.with_raw_response.list( + "id", + ) + + assert response.is_closed is True + assert response.http_request.headers.get("X-Stainless-Lang") == "python" + replay = await response.parse() + assert_matches_type(ReplayListResponse, replay, path=["response"]) + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + async def test_streaming_response_list(self, async_client: AsyncKernel) -> None: + async with async_client.browsers.replays.with_streaming_response.list( + "id", + ) as response: + assert not response.is_closed + assert response.http_request.headers.get("X-Stainless-Lang") == "python" + + replay = await response.parse() + assert_matches_type(ReplayListResponse, replay, path=["response"]) + + assert cast(Any, response.is_closed) is True + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + async def test_path_params_list(self, async_client: AsyncKernel) -> None: + with pytest.raises(ValueError, match=r"Expected a non-empty value for `id` but received ''"): + await async_client.browsers.replays.with_raw_response.list( + "", + ) + + @parametrize + @pytest.mark.respx(base_url=base_url) + async def test_method_download(self, async_client: AsyncKernel, respx_mock: MockRouter) -> None: + respx_mock.get("/browsers/id/replays/replay_id").mock(return_value=httpx.Response(200, json={"foo": "bar"})) + replay = await async_client.browsers.replays.download( + replay_id="replay_id", + id="id", + ) + assert replay.is_closed + assert await replay.json() == {"foo": "bar"} + assert cast(Any, replay.is_closed) is True + assert isinstance(replay, AsyncBinaryAPIResponse) + + @parametrize + @pytest.mark.respx(base_url=base_url) + async def test_raw_response_download(self, async_client: AsyncKernel, respx_mock: MockRouter) -> None: + respx_mock.get("/browsers/id/replays/replay_id").mock(return_value=httpx.Response(200, json={"foo": "bar"})) + + replay = await async_client.browsers.replays.with_raw_response.download( + replay_id="replay_id", + id="id", + ) + + assert replay.is_closed is True + assert replay.http_request.headers.get("X-Stainless-Lang") == "python" + assert await replay.json() == {"foo": "bar"} + assert isinstance(replay, AsyncBinaryAPIResponse) + + @parametrize + @pytest.mark.respx(base_url=base_url) + async def test_streaming_response_download(self, async_client: AsyncKernel, respx_mock: MockRouter) -> None: + respx_mock.get("/browsers/id/replays/replay_id").mock(return_value=httpx.Response(200, json={"foo": "bar"})) + async with async_client.browsers.replays.with_streaming_response.download( + replay_id="replay_id", + id="id", + ) as replay: + assert not replay.is_closed + assert replay.http_request.headers.get("X-Stainless-Lang") == "python" + + assert await replay.json() == {"foo": "bar"} + assert cast(Any, replay.is_closed) is True + assert isinstance(replay, AsyncStreamedBinaryAPIResponse) + + assert cast(Any, replay.is_closed) is True + + @parametrize + @pytest.mark.respx(base_url=base_url) + async def test_path_params_download(self, async_client: AsyncKernel) -> None: + with pytest.raises(ValueError, match=r"Expected a non-empty value for `id` but received ''"): + await async_client.browsers.replays.with_raw_response.download( + replay_id="replay_id", + id="", + ) + + with pytest.raises(ValueError, match=r"Expected a non-empty value for `replay_id` but received ''"): + await async_client.browsers.replays.with_raw_response.download( + replay_id="", + id="id", + ) + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + async def test_method_start(self, async_client: AsyncKernel) -> None: + replay = await async_client.browsers.replays.start( + id="id", + ) + assert_matches_type(ReplayStartResponse, replay, path=["response"]) + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + async def test_method_start_with_all_params(self, async_client: AsyncKernel) -> None: + replay = await async_client.browsers.replays.start( + id="id", + framerate=1, + max_duration_in_seconds=1, + record_audio=True, + ) + assert_matches_type(ReplayStartResponse, replay, path=["response"]) + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + async def test_raw_response_start(self, async_client: AsyncKernel) -> None: + response = await async_client.browsers.replays.with_raw_response.start( + id="id", + ) + + assert response.is_closed is True + assert response.http_request.headers.get("X-Stainless-Lang") == "python" + replay = await response.parse() + assert_matches_type(ReplayStartResponse, replay, path=["response"]) + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + async def test_streaming_response_start(self, async_client: AsyncKernel) -> None: + async with async_client.browsers.replays.with_streaming_response.start( + id="id", + ) as response: + assert not response.is_closed + assert response.http_request.headers.get("X-Stainless-Lang") == "python" + + replay = await response.parse() + assert_matches_type(ReplayStartResponse, replay, path=["response"]) + + assert cast(Any, response.is_closed) is True + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + async def test_path_params_start(self, async_client: AsyncKernel) -> None: + with pytest.raises(ValueError, match=r"Expected a non-empty value for `id` but received ''"): + await async_client.browsers.replays.with_raw_response.start( + id="", + ) + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + async def test_method_stop(self, async_client: AsyncKernel) -> None: + replay = await async_client.browsers.replays.stop( + replay_id="replay_id", + id="id", + ) + assert replay is None + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + async def test_raw_response_stop(self, async_client: AsyncKernel) -> None: + response = await async_client.browsers.replays.with_raw_response.stop( + replay_id="replay_id", + id="id", + ) + + assert response.is_closed is True + assert response.http_request.headers.get("X-Stainless-Lang") == "python" + replay = await response.parse() + assert replay is None + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + async def test_streaming_response_stop(self, async_client: AsyncKernel) -> None: + async with async_client.browsers.replays.with_streaming_response.stop( + replay_id="replay_id", + id="id", + ) as response: + assert not response.is_closed + assert response.http_request.headers.get("X-Stainless-Lang") == "python" + + replay = await response.parse() + assert replay is None + + assert cast(Any, response.is_closed) is True + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + async def test_path_params_stop(self, async_client: AsyncKernel) -> None: + with pytest.raises(ValueError, match=r"Expected a non-empty value for `id` but received ''"): + await async_client.browsers.replays.with_raw_response.stop( + replay_id="replay_id", + id="", + ) + + with pytest.raises(ValueError, match=r"Expected a non-empty value for `replay_id` but received ''"): + await async_client.browsers.replays.with_raw_response.stop( + replay_id="", + id="id", + ) diff --git a/tests/api_resources/browsers/test_telemetry.py b/tests/api_resources/browsers/test_telemetry.py new file mode 100644 index 00000000..3c5dad44 --- /dev/null +++ b/tests/api_resources/browsers/test_telemetry.py @@ -0,0 +1,239 @@ +# 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 kernel import Kernel, AsyncKernel +from tests.utils import assert_matches_type +from kernel.pagination import SyncOffsetPagination, AsyncOffsetPagination +from kernel.types.browsers import TelemetryEventsResponse + +base_url = os.environ.get("TEST_API_BASE_URL", "http://127.0.0.1:4010") + + +class TestTelemetry: + parametrize = pytest.mark.parametrize("client", [False, True], indirect=True, ids=["loose", "strict"]) + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + def test_method_events(self, client: Kernel) -> None: + telemetry = client.browsers.telemetry.events( + id="id", + ) + assert_matches_type(SyncOffsetPagination[TelemetryEventsResponse], telemetry, path=["response"]) + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + def test_method_events_with_all_params(self, client: Kernel) -> None: + telemetry = client.browsers.telemetry.events( + id="id", + category=["console"], + limit=1, + offset=0, + order="order", + since="since", + until="until", + ) + assert_matches_type(SyncOffsetPagination[TelemetryEventsResponse], telemetry, path=["response"]) + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + def test_raw_response_events(self, client: Kernel) -> None: + response = client.browsers.telemetry.with_raw_response.events( + id="id", + ) + + assert response.is_closed is True + assert response.http_request.headers.get("X-Stainless-Lang") == "python" + telemetry = response.parse() + assert_matches_type(SyncOffsetPagination[TelemetryEventsResponse], telemetry, path=["response"]) + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + def test_streaming_response_events(self, client: Kernel) -> None: + with client.browsers.telemetry.with_streaming_response.events( + id="id", + ) as response: + assert not response.is_closed + assert response.http_request.headers.get("X-Stainless-Lang") == "python" + + telemetry = response.parse() + assert_matches_type(SyncOffsetPagination[TelemetryEventsResponse], telemetry, path=["response"]) + + assert cast(Any, response.is_closed) is True + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + def test_path_params_events(self, client: Kernel) -> None: + with pytest.raises(ValueError, match=r"Expected a non-empty value for `id` but received ''"): + client.browsers.telemetry.with_raw_response.events( + id="", + ) + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + def test_method_stream(self, client: Kernel) -> None: + telemetry_stream = client.browsers.telemetry.stream( + id="id", + ) + telemetry_stream.response.close() + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + def test_method_stream_with_all_params(self, client: Kernel) -> None: + telemetry_stream = client.browsers.telemetry.stream( + id="id", + replay="replay", + last_event_id="Last-Event-ID", + ) + telemetry_stream.response.close() + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + def test_raw_response_stream(self, client: Kernel) -> None: + response = client.browsers.telemetry.with_raw_response.stream( + id="id", + ) + + assert response.http_request.headers.get("X-Stainless-Lang") == "python" + stream = response.parse() + stream.close() + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + def test_streaming_response_stream(self, client: Kernel) -> None: + with client.browsers.telemetry.with_streaming_response.stream( + id="id", + ) 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 + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + def test_path_params_stream(self, client: Kernel) -> None: + with pytest.raises(ValueError, match=r"Expected a non-empty value for `id` but received ''"): + client.browsers.telemetry.with_raw_response.stream( + id="", + ) + + +class TestAsyncTelemetry: + parametrize = pytest.mark.parametrize( + "async_client", [False, True, {"http_client": "aiohttp"}], indirect=True, ids=["loose", "strict", "aiohttp"] + ) + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + async def test_method_events(self, async_client: AsyncKernel) -> None: + telemetry = await async_client.browsers.telemetry.events( + id="id", + ) + assert_matches_type(AsyncOffsetPagination[TelemetryEventsResponse], telemetry, path=["response"]) + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + async def test_method_events_with_all_params(self, async_client: AsyncKernel) -> None: + telemetry = await async_client.browsers.telemetry.events( + id="id", + category=["console"], + limit=1, + offset=0, + order="order", + since="since", + until="until", + ) + assert_matches_type(AsyncOffsetPagination[TelemetryEventsResponse], telemetry, path=["response"]) + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + async def test_raw_response_events(self, async_client: AsyncKernel) -> None: + response = await async_client.browsers.telemetry.with_raw_response.events( + id="id", + ) + + assert response.is_closed is True + assert response.http_request.headers.get("X-Stainless-Lang") == "python" + telemetry = await response.parse() + assert_matches_type(AsyncOffsetPagination[TelemetryEventsResponse], telemetry, path=["response"]) + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + async def test_streaming_response_events(self, async_client: AsyncKernel) -> None: + async with async_client.browsers.telemetry.with_streaming_response.events( + id="id", + ) as response: + assert not response.is_closed + assert response.http_request.headers.get("X-Stainless-Lang") == "python" + + telemetry = await response.parse() + assert_matches_type(AsyncOffsetPagination[TelemetryEventsResponse], telemetry, path=["response"]) + + assert cast(Any, response.is_closed) is True + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + async def test_path_params_events(self, async_client: AsyncKernel) -> None: + with pytest.raises(ValueError, match=r"Expected a non-empty value for `id` but received ''"): + await async_client.browsers.telemetry.with_raw_response.events( + id="", + ) + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + async def test_method_stream(self, async_client: AsyncKernel) -> None: + telemetry_stream = await async_client.browsers.telemetry.stream( + id="id", + ) + await telemetry_stream.response.aclose() + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + async def test_method_stream_with_all_params(self, async_client: AsyncKernel) -> None: + telemetry_stream = await async_client.browsers.telemetry.stream( + id="id", + replay="replay", + last_event_id="Last-Event-ID", + ) + await telemetry_stream.response.aclose() + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + async def test_raw_response_stream(self, async_client: AsyncKernel) -> None: + response = await async_client.browsers.telemetry.with_raw_response.stream( + id="id", + ) + + assert response.http_request.headers.get("X-Stainless-Lang") == "python" + stream = await response.parse() + await stream.close() + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + async def test_streaming_response_stream(self, async_client: AsyncKernel) -> None: + async with async_client.browsers.telemetry.with_streaming_response.stream( + id="id", + ) 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 + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + async def test_path_params_stream(self, async_client: AsyncKernel) -> None: + with pytest.raises(ValueError, match=r"Expected a non-empty value for `id` but received ''"): + await async_client.browsers.telemetry.with_raw_response.stream( + id="", + ) diff --git a/tests/api_resources/organization/__init__.py b/tests/api_resources/organization/__init__.py new file mode 100644 index 00000000..fd8019a9 --- /dev/null +++ b/tests/api_resources/organization/__init__.py @@ -0,0 +1 @@ +# File generated from our OpenAPI spec by Stainless. See CONTRIBUTING.md for details. diff --git a/tests/api_resources/organization/test_limits.py b/tests/api_resources/organization/test_limits.py new file mode 100644 index 00000000..a9dca6d0 --- /dev/null +++ b/tests/api_resources/organization/test_limits.py @@ -0,0 +1,152 @@ +# 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 kernel import Kernel, AsyncKernel +from tests.utils import assert_matches_type +from kernel.types.organization import OrgLimits + +base_url = os.environ.get("TEST_API_BASE_URL", "http://127.0.0.1:4010") + + +class TestLimits: + parametrize = pytest.mark.parametrize("client", [False, True], indirect=True, ids=["loose", "strict"]) + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + def test_method_retrieve(self, client: Kernel) -> None: + limit = client.organization.limits.retrieve() + assert_matches_type(OrgLimits, limit, path=["response"]) + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + def test_raw_response_retrieve(self, client: Kernel) -> None: + response = client.organization.limits.with_raw_response.retrieve() + + assert response.is_closed is True + assert response.http_request.headers.get("X-Stainless-Lang") == "python" + limit = response.parse() + assert_matches_type(OrgLimits, limit, path=["response"]) + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + def test_streaming_response_retrieve(self, client: Kernel) -> None: + with client.organization.limits.with_streaming_response.retrieve() as response: + assert not response.is_closed + assert response.http_request.headers.get("X-Stainless-Lang") == "python" + + limit = response.parse() + assert_matches_type(OrgLimits, limit, path=["response"]) + + assert cast(Any, response.is_closed) is True + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + def test_method_update(self, client: Kernel) -> None: + limit = client.organization.limits.update() + assert_matches_type(OrgLimits, limit, path=["response"]) + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + def test_method_update_with_all_params(self, client: Kernel) -> None: + limit = client.organization.limits.update( + default_project_max_concurrent_sessions=0, + ) + assert_matches_type(OrgLimits, limit, path=["response"]) + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + def test_raw_response_update(self, client: Kernel) -> None: + response = client.organization.limits.with_raw_response.update() + + assert response.is_closed is True + assert response.http_request.headers.get("X-Stainless-Lang") == "python" + limit = response.parse() + assert_matches_type(OrgLimits, limit, path=["response"]) + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + def test_streaming_response_update(self, client: Kernel) -> None: + with client.organization.limits.with_streaming_response.update() as response: + assert not response.is_closed + assert response.http_request.headers.get("X-Stainless-Lang") == "python" + + limit = response.parse() + assert_matches_type(OrgLimits, limit, path=["response"]) + + assert cast(Any, response.is_closed) is True + + +class TestAsyncLimits: + parametrize = pytest.mark.parametrize( + "async_client", [False, True, {"http_client": "aiohttp"}], indirect=True, ids=["loose", "strict", "aiohttp"] + ) + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + async def test_method_retrieve(self, async_client: AsyncKernel) -> None: + limit = await async_client.organization.limits.retrieve() + assert_matches_type(OrgLimits, limit, path=["response"]) + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + async def test_raw_response_retrieve(self, async_client: AsyncKernel) -> None: + response = await async_client.organization.limits.with_raw_response.retrieve() + + assert response.is_closed is True + assert response.http_request.headers.get("X-Stainless-Lang") == "python" + limit = await response.parse() + assert_matches_type(OrgLimits, limit, path=["response"]) + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + async def test_streaming_response_retrieve(self, async_client: AsyncKernel) -> None: + async with async_client.organization.limits.with_streaming_response.retrieve() as response: + assert not response.is_closed + assert response.http_request.headers.get("X-Stainless-Lang") == "python" + + limit = await response.parse() + assert_matches_type(OrgLimits, limit, path=["response"]) + + assert cast(Any, response.is_closed) is True + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + async def test_method_update(self, async_client: AsyncKernel) -> None: + limit = await async_client.organization.limits.update() + assert_matches_type(OrgLimits, limit, path=["response"]) + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + async def test_method_update_with_all_params(self, async_client: AsyncKernel) -> None: + limit = await async_client.organization.limits.update( + default_project_max_concurrent_sessions=0, + ) + assert_matches_type(OrgLimits, limit, path=["response"]) + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + async def test_raw_response_update(self, async_client: AsyncKernel) -> None: + response = await async_client.organization.limits.with_raw_response.update() + + assert response.is_closed is True + assert response.http_request.headers.get("X-Stainless-Lang") == "python" + limit = await response.parse() + assert_matches_type(OrgLimits, limit, path=["response"]) + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + async def test_streaming_response_update(self, async_client: AsyncKernel) -> None: + async with async_client.organization.limits.with_streaming_response.update() as response: + assert not response.is_closed + assert response.http_request.headers.get("X-Stainless-Lang") == "python" + + limit = await response.parse() + assert_matches_type(OrgLimits, limit, path=["response"]) + + assert cast(Any, response.is_closed) is True diff --git a/tests/api_resources/projects/__init__.py b/tests/api_resources/projects/__init__.py new file mode 100644 index 00000000..fd8019a9 --- /dev/null +++ b/tests/api_resources/projects/__init__.py @@ -0,0 +1 @@ +# File generated from our OpenAPI spec by Stainless. See CONTRIBUTING.md for details. diff --git a/tests/api_resources/projects/test_limits.py b/tests/api_resources/projects/test_limits.py new file mode 100644 index 00000000..a42a0bde --- /dev/null +++ b/tests/api_resources/projects/test_limits.py @@ -0,0 +1,214 @@ +# 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 kernel import Kernel, AsyncKernel +from tests.utils import assert_matches_type +from kernel.types.projects import ProjectLimits + +base_url = os.environ.get("TEST_API_BASE_URL", "http://127.0.0.1:4010") + + +class TestLimits: + parametrize = pytest.mark.parametrize("client", [False, True], indirect=True, ids=["loose", "strict"]) + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + def test_method_retrieve(self, client: Kernel) -> None: + limit = client.projects.limits.retrieve( + "id", + ) + assert_matches_type(ProjectLimits, limit, path=["response"]) + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + def test_raw_response_retrieve(self, client: Kernel) -> None: + response = client.projects.limits.with_raw_response.retrieve( + "id", + ) + + assert response.is_closed is True + assert response.http_request.headers.get("X-Stainless-Lang") == "python" + limit = response.parse() + assert_matches_type(ProjectLimits, limit, path=["response"]) + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + def test_streaming_response_retrieve(self, client: Kernel) -> None: + with client.projects.limits.with_streaming_response.retrieve( + "id", + ) as response: + assert not response.is_closed + assert response.http_request.headers.get("X-Stainless-Lang") == "python" + + limit = response.parse() + assert_matches_type(ProjectLimits, limit, path=["response"]) + + assert cast(Any, response.is_closed) is True + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + def test_path_params_retrieve(self, client: Kernel) -> None: + with pytest.raises(ValueError, match=r"Expected a non-empty value for `id` but received ''"): + client.projects.limits.with_raw_response.retrieve( + "", + ) + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + def test_method_update(self, client: Kernel) -> None: + limit = client.projects.limits.update( + id="id", + ) + assert_matches_type(ProjectLimits, limit, path=["response"]) + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + def test_method_update_with_all_params(self, client: Kernel) -> None: + limit = client.projects.limits.update( + id="id", + max_concurrent_invocations=0, + max_concurrent_sessions=0, + max_pooled_sessions=0, + ) + assert_matches_type(ProjectLimits, limit, path=["response"]) + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + def test_raw_response_update(self, client: Kernel) -> None: + response = client.projects.limits.with_raw_response.update( + id="id", + ) + + assert response.is_closed is True + assert response.http_request.headers.get("X-Stainless-Lang") == "python" + limit = response.parse() + assert_matches_type(ProjectLimits, limit, path=["response"]) + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + def test_streaming_response_update(self, client: Kernel) -> None: + with client.projects.limits.with_streaming_response.update( + id="id", + ) as response: + assert not response.is_closed + assert response.http_request.headers.get("X-Stainless-Lang") == "python" + + limit = response.parse() + assert_matches_type(ProjectLimits, limit, path=["response"]) + + assert cast(Any, response.is_closed) is True + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + def test_path_params_update(self, client: Kernel) -> None: + with pytest.raises(ValueError, match=r"Expected a non-empty value for `id` but received ''"): + client.projects.limits.with_raw_response.update( + id="", + ) + + +class TestAsyncLimits: + parametrize = pytest.mark.parametrize( + "async_client", [False, True, {"http_client": "aiohttp"}], indirect=True, ids=["loose", "strict", "aiohttp"] + ) + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + async def test_method_retrieve(self, async_client: AsyncKernel) -> None: + limit = await async_client.projects.limits.retrieve( + "id", + ) + assert_matches_type(ProjectLimits, limit, path=["response"]) + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + async def test_raw_response_retrieve(self, async_client: AsyncKernel) -> None: + response = await async_client.projects.limits.with_raw_response.retrieve( + "id", + ) + + assert response.is_closed is True + assert response.http_request.headers.get("X-Stainless-Lang") == "python" + limit = await response.parse() + assert_matches_type(ProjectLimits, limit, path=["response"]) + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + async def test_streaming_response_retrieve(self, async_client: AsyncKernel) -> None: + async with async_client.projects.limits.with_streaming_response.retrieve( + "id", + ) as response: + assert not response.is_closed + assert response.http_request.headers.get("X-Stainless-Lang") == "python" + + limit = await response.parse() + assert_matches_type(ProjectLimits, limit, path=["response"]) + + assert cast(Any, response.is_closed) is True + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + async def test_path_params_retrieve(self, async_client: AsyncKernel) -> None: + with pytest.raises(ValueError, match=r"Expected a non-empty value for `id` but received ''"): + await async_client.projects.limits.with_raw_response.retrieve( + "", + ) + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + async def test_method_update(self, async_client: AsyncKernel) -> None: + limit = await async_client.projects.limits.update( + id="id", + ) + assert_matches_type(ProjectLimits, limit, path=["response"]) + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + async def test_method_update_with_all_params(self, async_client: AsyncKernel) -> None: + limit = await async_client.projects.limits.update( + id="id", + max_concurrent_invocations=0, + max_concurrent_sessions=0, + max_pooled_sessions=0, + ) + assert_matches_type(ProjectLimits, limit, path=["response"]) + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + async def test_raw_response_update(self, async_client: AsyncKernel) -> None: + response = await async_client.projects.limits.with_raw_response.update( + id="id", + ) + + assert response.is_closed is True + assert response.http_request.headers.get("X-Stainless-Lang") == "python" + limit = await response.parse() + assert_matches_type(ProjectLimits, limit, path=["response"]) + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + async def test_streaming_response_update(self, async_client: AsyncKernel) -> None: + async with async_client.projects.limits.with_streaming_response.update( + id="id", + ) as response: + assert not response.is_closed + assert response.http_request.headers.get("X-Stainless-Lang") == "python" + + limit = await response.parse() + assert_matches_type(ProjectLimits, limit, path=["response"]) + + assert cast(Any, response.is_closed) is True + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + async def test_path_params_update(self, async_client: AsyncKernel) -> None: + with pytest.raises(ValueError, match=r"Expected a non-empty value for `id` but received ''"): + await async_client.projects.limits.with_raw_response.update( + id="", + ) diff --git a/tests/api_resources/test_api_keys.py b/tests/api_resources/test_api_keys.py new file mode 100644 index 00000000..03d1ed8e --- /dev/null +++ b/tests/api_resources/test_api_keys.py @@ -0,0 +1,584 @@ +# 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 kernel import Kernel, AsyncKernel +from tests.utils import assert_matches_type +from kernel.types import ( + APIKey, + CreatedAPIKey, +) +from kernel.pagination import SyncOffsetPagination, AsyncOffsetPagination + +base_url = os.environ.get("TEST_API_BASE_URL", "http://127.0.0.1:4010") + + +class TestAPIKeys: + parametrize = pytest.mark.parametrize("client", [False, True], indirect=True, ids=["loose", "strict"]) + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + def test_method_create(self, client: Kernel) -> None: + api_key = client.api_keys.create( + name="staging", + ) + assert_matches_type(CreatedAPIKey, api_key, path=["response"]) + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + def test_method_create_with_all_params(self, client: Kernel) -> None: + api_key = client.api_keys.create( + name="staging", + days_to_expire=30, + project_id="proj_abc123", + ) + assert_matches_type(CreatedAPIKey, api_key, path=["response"]) + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + def test_raw_response_create(self, client: Kernel) -> None: + response = client.api_keys.with_raw_response.create( + name="staging", + ) + + assert response.is_closed is True + assert response.http_request.headers.get("X-Stainless-Lang") == "python" + api_key = response.parse() + assert_matches_type(CreatedAPIKey, api_key, path=["response"]) + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + def test_streaming_response_create(self, client: Kernel) -> None: + with client.api_keys.with_streaming_response.create( + name="staging", + ) as response: + assert not response.is_closed + assert response.http_request.headers.get("X-Stainless-Lang") == "python" + + api_key = response.parse() + assert_matches_type(CreatedAPIKey, api_key, path=["response"]) + + assert cast(Any, response.is_closed) is True + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + def test_method_retrieve(self, client: Kernel) -> None: + api_key = client.api_keys.retrieve( + id="id", + ) + assert_matches_type(APIKey, api_key, path=["response"]) + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + def test_method_retrieve_with_all_params(self, client: Kernel) -> None: + api_key = client.api_keys.retrieve( + id="id", + include_deleted=True, + ) + assert_matches_type(APIKey, api_key, path=["response"]) + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + def test_raw_response_retrieve(self, client: Kernel) -> None: + response = client.api_keys.with_raw_response.retrieve( + id="id", + ) + + assert response.is_closed is True + assert response.http_request.headers.get("X-Stainless-Lang") == "python" + api_key = response.parse() + assert_matches_type(APIKey, api_key, path=["response"]) + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + def test_streaming_response_retrieve(self, client: Kernel) -> None: + with client.api_keys.with_streaming_response.retrieve( + id="id", + ) as response: + assert not response.is_closed + assert response.http_request.headers.get("X-Stainless-Lang") == "python" + + api_key = response.parse() + assert_matches_type(APIKey, api_key, path=["response"]) + + assert cast(Any, response.is_closed) is True + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + def test_path_params_retrieve(self, client: Kernel) -> None: + with pytest.raises(ValueError, match=r"Expected a non-empty value for `id` but received ''"): + client.api_keys.with_raw_response.retrieve( + id="", + ) + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + def test_method_update(self, client: Kernel) -> None: + api_key = client.api_keys.update( + id="id", + name="new-api-name", + ) + assert_matches_type(APIKey, api_key, path=["response"]) + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + def test_raw_response_update(self, client: Kernel) -> None: + response = client.api_keys.with_raw_response.update( + id="id", + name="new-api-name", + ) + + assert response.is_closed is True + assert response.http_request.headers.get("X-Stainless-Lang") == "python" + api_key = response.parse() + assert_matches_type(APIKey, api_key, path=["response"]) + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + def test_streaming_response_update(self, client: Kernel) -> None: + with client.api_keys.with_streaming_response.update( + id="id", + name="new-api-name", + ) as response: + assert not response.is_closed + assert response.http_request.headers.get("X-Stainless-Lang") == "python" + + api_key = response.parse() + assert_matches_type(APIKey, api_key, path=["response"]) + + assert cast(Any, response.is_closed) is True + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + def test_path_params_update(self, client: Kernel) -> None: + with pytest.raises(ValueError, match=r"Expected a non-empty value for `id` but received ''"): + client.api_keys.with_raw_response.update( + id="", + name="new-api-name", + ) + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + def test_method_list(self, client: Kernel) -> None: + api_key = client.api_keys.list() + assert_matches_type(SyncOffsetPagination[APIKey], api_key, path=["response"]) + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + def test_method_list_with_all_params(self, client: Kernel) -> None: + api_key = client.api_keys.list( + include_deleted=True, + limit=100, + name="name", + offset=0, + query="query", + sort_by="created_at", + sort_direction="asc", + status="active", + ) + assert_matches_type(SyncOffsetPagination[APIKey], api_key, path=["response"]) + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + def test_raw_response_list(self, client: Kernel) -> None: + response = client.api_keys.with_raw_response.list() + + assert response.is_closed is True + assert response.http_request.headers.get("X-Stainless-Lang") == "python" + api_key = response.parse() + assert_matches_type(SyncOffsetPagination[APIKey], api_key, path=["response"]) + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + def test_streaming_response_list(self, client: Kernel) -> None: + with client.api_keys.with_streaming_response.list() as response: + assert not response.is_closed + assert response.http_request.headers.get("X-Stainless-Lang") == "python" + + api_key = response.parse() + assert_matches_type(SyncOffsetPagination[APIKey], api_key, path=["response"]) + + assert cast(Any, response.is_closed) is True + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + def test_method_delete(self, client: Kernel) -> None: + api_key = client.api_keys.delete( + "id", + ) + assert api_key is None + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + def test_raw_response_delete(self, client: Kernel) -> None: + response = client.api_keys.with_raw_response.delete( + "id", + ) + + assert response.is_closed is True + assert response.http_request.headers.get("X-Stainless-Lang") == "python" + api_key = response.parse() + assert api_key is None + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + def test_streaming_response_delete(self, client: Kernel) -> None: + with client.api_keys.with_streaming_response.delete( + "id", + ) as response: + assert not response.is_closed + assert response.http_request.headers.get("X-Stainless-Lang") == "python" + + api_key = response.parse() + assert api_key is None + + assert cast(Any, response.is_closed) is True + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + def test_path_params_delete(self, client: Kernel) -> None: + with pytest.raises(ValueError, match=r"Expected a non-empty value for `id` but received ''"): + client.api_keys.with_raw_response.delete( + "", + ) + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + def test_method_rotate(self, client: Kernel) -> None: + api_key = client.api_keys.rotate( + id="id", + ) + assert_matches_type(CreatedAPIKey, api_key, path=["response"]) + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + def test_method_rotate_with_all_params(self, client: Kernel) -> None: + api_key = client.api_keys.rotate( + id="id", + days_to_expire=30, + expire_in_days=7, + ) + assert_matches_type(CreatedAPIKey, api_key, path=["response"]) + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + def test_raw_response_rotate(self, client: Kernel) -> None: + response = client.api_keys.with_raw_response.rotate( + id="id", + ) + + assert response.is_closed is True + assert response.http_request.headers.get("X-Stainless-Lang") == "python" + api_key = response.parse() + assert_matches_type(CreatedAPIKey, api_key, path=["response"]) + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + def test_streaming_response_rotate(self, client: Kernel) -> None: + with client.api_keys.with_streaming_response.rotate( + id="id", + ) as response: + assert not response.is_closed + assert response.http_request.headers.get("X-Stainless-Lang") == "python" + + api_key = response.parse() + assert_matches_type(CreatedAPIKey, api_key, path=["response"]) + + assert cast(Any, response.is_closed) is True + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + def test_path_params_rotate(self, client: Kernel) -> None: + with pytest.raises(ValueError, match=r"Expected a non-empty value for `id` but received ''"): + client.api_keys.with_raw_response.rotate( + id="", + ) + + +class TestAsyncAPIKeys: + parametrize = pytest.mark.parametrize( + "async_client", [False, True, {"http_client": "aiohttp"}], indirect=True, ids=["loose", "strict", "aiohttp"] + ) + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + async def test_method_create(self, async_client: AsyncKernel) -> None: + api_key = await async_client.api_keys.create( + name="staging", + ) + assert_matches_type(CreatedAPIKey, api_key, path=["response"]) + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + async def test_method_create_with_all_params(self, async_client: AsyncKernel) -> None: + api_key = await async_client.api_keys.create( + name="staging", + days_to_expire=30, + project_id="proj_abc123", + ) + assert_matches_type(CreatedAPIKey, api_key, path=["response"]) + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + async def test_raw_response_create(self, async_client: AsyncKernel) -> None: + response = await async_client.api_keys.with_raw_response.create( + name="staging", + ) + + assert response.is_closed is True + assert response.http_request.headers.get("X-Stainless-Lang") == "python" + api_key = await response.parse() + assert_matches_type(CreatedAPIKey, api_key, path=["response"]) + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + async def test_streaming_response_create(self, async_client: AsyncKernel) -> None: + async with async_client.api_keys.with_streaming_response.create( + name="staging", + ) as response: + assert not response.is_closed + assert response.http_request.headers.get("X-Stainless-Lang") == "python" + + api_key = await response.parse() + assert_matches_type(CreatedAPIKey, api_key, path=["response"]) + + assert cast(Any, response.is_closed) is True + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + async def test_method_retrieve(self, async_client: AsyncKernel) -> None: + api_key = await async_client.api_keys.retrieve( + id="id", + ) + assert_matches_type(APIKey, api_key, path=["response"]) + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + async def test_method_retrieve_with_all_params(self, async_client: AsyncKernel) -> None: + api_key = await async_client.api_keys.retrieve( + id="id", + include_deleted=True, + ) + assert_matches_type(APIKey, api_key, path=["response"]) + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + async def test_raw_response_retrieve(self, async_client: AsyncKernel) -> None: + response = await async_client.api_keys.with_raw_response.retrieve( + id="id", + ) + + assert response.is_closed is True + assert response.http_request.headers.get("X-Stainless-Lang") == "python" + api_key = await response.parse() + assert_matches_type(APIKey, api_key, path=["response"]) + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + async def test_streaming_response_retrieve(self, async_client: AsyncKernel) -> None: + async with async_client.api_keys.with_streaming_response.retrieve( + id="id", + ) as response: + assert not response.is_closed + assert response.http_request.headers.get("X-Stainless-Lang") == "python" + + api_key = await response.parse() + assert_matches_type(APIKey, api_key, path=["response"]) + + assert cast(Any, response.is_closed) is True + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + async def test_path_params_retrieve(self, async_client: AsyncKernel) -> None: + with pytest.raises(ValueError, match=r"Expected a non-empty value for `id` but received ''"): + await async_client.api_keys.with_raw_response.retrieve( + id="", + ) + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + async def test_method_update(self, async_client: AsyncKernel) -> None: + api_key = await async_client.api_keys.update( + id="id", + name="new-api-name", + ) + assert_matches_type(APIKey, api_key, path=["response"]) + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + async def test_raw_response_update(self, async_client: AsyncKernel) -> None: + response = await async_client.api_keys.with_raw_response.update( + id="id", + name="new-api-name", + ) + + assert response.is_closed is True + assert response.http_request.headers.get("X-Stainless-Lang") == "python" + api_key = await response.parse() + assert_matches_type(APIKey, api_key, path=["response"]) + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + async def test_streaming_response_update(self, async_client: AsyncKernel) -> None: + async with async_client.api_keys.with_streaming_response.update( + id="id", + name="new-api-name", + ) as response: + assert not response.is_closed + assert response.http_request.headers.get("X-Stainless-Lang") == "python" + + api_key = await response.parse() + assert_matches_type(APIKey, api_key, path=["response"]) + + assert cast(Any, response.is_closed) is True + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + async def test_path_params_update(self, async_client: AsyncKernel) -> None: + with pytest.raises(ValueError, match=r"Expected a non-empty value for `id` but received ''"): + await async_client.api_keys.with_raw_response.update( + id="", + name="new-api-name", + ) + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + async def test_method_list(self, async_client: AsyncKernel) -> None: + api_key = await async_client.api_keys.list() + assert_matches_type(AsyncOffsetPagination[APIKey], api_key, path=["response"]) + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + async def test_method_list_with_all_params(self, async_client: AsyncKernel) -> None: + api_key = await async_client.api_keys.list( + include_deleted=True, + limit=100, + name="name", + offset=0, + query="query", + sort_by="created_at", + sort_direction="asc", + status="active", + ) + assert_matches_type(AsyncOffsetPagination[APIKey], api_key, path=["response"]) + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + async def test_raw_response_list(self, async_client: AsyncKernel) -> None: + response = await async_client.api_keys.with_raw_response.list() + + assert response.is_closed is True + assert response.http_request.headers.get("X-Stainless-Lang") == "python" + api_key = await response.parse() + assert_matches_type(AsyncOffsetPagination[APIKey], api_key, path=["response"]) + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + async def test_streaming_response_list(self, async_client: AsyncKernel) -> None: + async with async_client.api_keys.with_streaming_response.list() as response: + assert not response.is_closed + assert response.http_request.headers.get("X-Stainless-Lang") == "python" + + api_key = await response.parse() + assert_matches_type(AsyncOffsetPagination[APIKey], api_key, path=["response"]) + + assert cast(Any, response.is_closed) is True + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + async def test_method_delete(self, async_client: AsyncKernel) -> None: + api_key = await async_client.api_keys.delete( + "id", + ) + assert api_key is None + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + async def test_raw_response_delete(self, async_client: AsyncKernel) -> None: + response = await async_client.api_keys.with_raw_response.delete( + "id", + ) + + assert response.is_closed is True + assert response.http_request.headers.get("X-Stainless-Lang") == "python" + api_key = await response.parse() + assert api_key is None + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + async def test_streaming_response_delete(self, async_client: AsyncKernel) -> None: + async with async_client.api_keys.with_streaming_response.delete( + "id", + ) as response: + assert not response.is_closed + assert response.http_request.headers.get("X-Stainless-Lang") == "python" + + api_key = await response.parse() + assert api_key is None + + assert cast(Any, response.is_closed) is True + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + async def test_path_params_delete(self, async_client: AsyncKernel) -> None: + with pytest.raises(ValueError, match=r"Expected a non-empty value for `id` but received ''"): + await async_client.api_keys.with_raw_response.delete( + "", + ) + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + async def test_method_rotate(self, async_client: AsyncKernel) -> None: + api_key = await async_client.api_keys.rotate( + id="id", + ) + assert_matches_type(CreatedAPIKey, api_key, path=["response"]) + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + async def test_method_rotate_with_all_params(self, async_client: AsyncKernel) -> None: + api_key = await async_client.api_keys.rotate( + id="id", + days_to_expire=30, + expire_in_days=7, + ) + assert_matches_type(CreatedAPIKey, api_key, path=["response"]) + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + async def test_raw_response_rotate(self, async_client: AsyncKernel) -> None: + response = await async_client.api_keys.with_raw_response.rotate( + id="id", + ) + + assert response.is_closed is True + assert response.http_request.headers.get("X-Stainless-Lang") == "python" + api_key = await response.parse() + assert_matches_type(CreatedAPIKey, api_key, path=["response"]) + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + async def test_streaming_response_rotate(self, async_client: AsyncKernel) -> None: + async with async_client.api_keys.with_streaming_response.rotate( + id="id", + ) as response: + assert not response.is_closed + assert response.http_request.headers.get("X-Stainless-Lang") == "python" + + api_key = await response.parse() + assert_matches_type(CreatedAPIKey, api_key, path=["response"]) + + assert cast(Any, response.is_closed) is True + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + async def test_path_params_rotate(self, async_client: AsyncKernel) -> None: + with pytest.raises(ValueError, match=r"Expected a non-empty value for `id` but received ''"): + await async_client.api_keys.with_raw_response.rotate( + id="", + ) diff --git a/tests/api_resources/test_apps.py b/tests/api_resources/test_apps.py new file mode 100644 index 00000000..cab677b3 --- /dev/null +++ b/tests/api_resources/test_apps.py @@ -0,0 +1,105 @@ +# 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 kernel import Kernel, AsyncKernel +from tests.utils import assert_matches_type +from kernel.types import AppListResponse +from kernel.pagination import SyncOffsetPagination, AsyncOffsetPagination + +base_url = os.environ.get("TEST_API_BASE_URL", "http://127.0.0.1:4010") + + +class TestApps: + parametrize = pytest.mark.parametrize("client", [False, True], indirect=True, ids=["loose", "strict"]) + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + def test_method_list(self, client: Kernel) -> None: + app = client.apps.list() + assert_matches_type(SyncOffsetPagination[AppListResponse], app, path=["response"]) + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + def test_method_list_with_all_params(self, client: Kernel) -> None: + app = client.apps.list( + app_name="app_name", + limit=1, + offset=0, + query="query", + version="version", + ) + assert_matches_type(SyncOffsetPagination[AppListResponse], app, path=["response"]) + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + def test_raw_response_list(self, client: Kernel) -> None: + response = client.apps.with_raw_response.list() + + assert response.is_closed is True + assert response.http_request.headers.get("X-Stainless-Lang") == "python" + app = response.parse() + assert_matches_type(SyncOffsetPagination[AppListResponse], app, path=["response"]) + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + def test_streaming_response_list(self, client: Kernel) -> None: + with client.apps.with_streaming_response.list() as response: + assert not response.is_closed + assert response.http_request.headers.get("X-Stainless-Lang") == "python" + + app = response.parse() + assert_matches_type(SyncOffsetPagination[AppListResponse], app, path=["response"]) + + assert cast(Any, response.is_closed) is True + + +class TestAsyncApps: + parametrize = pytest.mark.parametrize( + "async_client", [False, True, {"http_client": "aiohttp"}], indirect=True, ids=["loose", "strict", "aiohttp"] + ) + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + async def test_method_list(self, async_client: AsyncKernel) -> None: + app = await async_client.apps.list() + assert_matches_type(AsyncOffsetPagination[AppListResponse], app, path=["response"]) + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + async def test_method_list_with_all_params(self, async_client: AsyncKernel) -> None: + app = await async_client.apps.list( + app_name="app_name", + limit=1, + offset=0, + query="query", + version="version", + ) + assert_matches_type(AsyncOffsetPagination[AppListResponse], app, path=["response"]) + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + async def test_raw_response_list(self, async_client: AsyncKernel) -> None: + response = await async_client.apps.with_raw_response.list() + + assert response.is_closed is True + assert response.http_request.headers.get("X-Stainless-Lang") == "python" + app = await response.parse() + assert_matches_type(AsyncOffsetPagination[AppListResponse], app, path=["response"]) + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + async def test_streaming_response_list(self, async_client: AsyncKernel) -> None: + async with async_client.apps.with_streaming_response.list() as response: + assert not response.is_closed + assert response.http_request.headers.get("X-Stainless-Lang") == "python" + + app = await response.parse() + assert_matches_type(AsyncOffsetPagination[AppListResponse], app, path=["response"]) + + assert cast(Any, response.is_closed) is True diff --git a/tests/api_resources/test_audit_logs.py b/tests/api_resources/test_audit_logs.py new file mode 100644 index 00000000..7a8ebe41 --- /dev/null +++ b/tests/api_resources/test_audit_logs.py @@ -0,0 +1,276 @@ +# 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 httpx +import pytest +from respx import MockRouter + +from kernel import Kernel, AsyncKernel +from tests.utils import assert_matches_type +from kernel.types import AuditLogEntry +from kernel._utils import parse_datetime +from kernel._response import ( + BinaryAPIResponse, + AsyncBinaryAPIResponse, + StreamedBinaryAPIResponse, + AsyncStreamedBinaryAPIResponse, +) +from kernel.pagination import SyncPageTokenPagination, AsyncPageTokenPagination + +base_url = os.environ.get("TEST_API_BASE_URL", "http://127.0.0.1:4010") + + +class TestAuditLogs: + parametrize = pytest.mark.parametrize("client", [False, True], indirect=True, ids=["loose", "strict"]) + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + def test_method_list(self, client: Kernel) -> None: + audit_log = client.audit_logs.list( + end=parse_datetime("2026-01-02T00:00:00Z"), + start=parse_datetime("2026-01-01T00:00:00Z"), + ) + assert_matches_type(SyncPageTokenPagination[AuditLogEntry], audit_log, path=["response"]) + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + def test_method_list_with_all_params(self, client: Kernel) -> None: + audit_log = client.audit_logs.list( + end=parse_datetime("2026-01-02T00:00:00Z"), + start=parse_datetime("2026-01-01T00:00:00Z"), + auth_strategy="auth_strategy", + exclude_method=["string"], + limit=1, + method="method", + page_token="page_token", + search="search", + search_user_id=["string"], + service="service", + ) + assert_matches_type(SyncPageTokenPagination[AuditLogEntry], audit_log, path=["response"]) + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + def test_raw_response_list(self, client: Kernel) -> None: + response = client.audit_logs.with_raw_response.list( + end=parse_datetime("2026-01-02T00:00:00Z"), + start=parse_datetime("2026-01-01T00:00:00Z"), + ) + + assert response.is_closed is True + assert response.http_request.headers.get("X-Stainless-Lang") == "python" + audit_log = response.parse() + assert_matches_type(SyncPageTokenPagination[AuditLogEntry], audit_log, path=["response"]) + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + def test_streaming_response_list(self, client: Kernel) -> None: + with client.audit_logs.with_streaming_response.list( + end=parse_datetime("2026-01-02T00:00:00Z"), + start=parse_datetime("2026-01-01T00:00:00Z"), + ) as response: + assert not response.is_closed + assert response.http_request.headers.get("X-Stainless-Lang") == "python" + + audit_log = response.parse() + assert_matches_type(SyncPageTokenPagination[AuditLogEntry], audit_log, path=["response"]) + + assert cast(Any, response.is_closed) is True + + @parametrize + @pytest.mark.respx(base_url=base_url) + def test_method_export_chunk(self, client: Kernel, respx_mock: MockRouter) -> None: + respx_mock.get("/audit-logs/export/chunk").mock(return_value=httpx.Response(200, json={"foo": "bar"})) + audit_log = client.audit_logs.export_chunk( + end=parse_datetime("2026-01-02T00:00:00Z"), + start=parse_datetime("2026-01-01T00:00:00Z"), + ) + assert audit_log.is_closed + assert audit_log.json() == {"foo": "bar"} + assert cast(Any, audit_log.is_closed) is True + assert isinstance(audit_log, BinaryAPIResponse) + + @parametrize + @pytest.mark.respx(base_url=base_url) + def test_method_export_chunk_with_all_params(self, client: Kernel, respx_mock: MockRouter) -> None: + respx_mock.get("/audit-logs/export/chunk").mock(return_value=httpx.Response(200, json={"foo": "bar"})) + audit_log = client.audit_logs.export_chunk( + end=parse_datetime("2026-01-02T00:00:00Z"), + start=parse_datetime("2026-01-01T00:00:00Z"), + auth_strategy="auth_strategy", + cursor="cursor", + exclude_method=["string"], + format="jsonl", + limit=1, + method="method", + search="search", + search_user_id=["string"], + service="service", + ) + assert audit_log.is_closed + assert audit_log.json() == {"foo": "bar"} + assert cast(Any, audit_log.is_closed) is True + assert isinstance(audit_log, BinaryAPIResponse) + + @parametrize + @pytest.mark.respx(base_url=base_url) + def test_raw_response_export_chunk(self, client: Kernel, respx_mock: MockRouter) -> None: + respx_mock.get("/audit-logs/export/chunk").mock(return_value=httpx.Response(200, json={"foo": "bar"})) + + audit_log = client.audit_logs.with_raw_response.export_chunk( + end=parse_datetime("2026-01-02T00:00:00Z"), + start=parse_datetime("2026-01-01T00:00:00Z"), + ) + + assert audit_log.is_closed is True + assert audit_log.http_request.headers.get("X-Stainless-Lang") == "python" + assert audit_log.json() == {"foo": "bar"} + assert isinstance(audit_log, BinaryAPIResponse) + + @parametrize + @pytest.mark.respx(base_url=base_url) + def test_streaming_response_export_chunk(self, client: Kernel, respx_mock: MockRouter) -> None: + respx_mock.get("/audit-logs/export/chunk").mock(return_value=httpx.Response(200, json={"foo": "bar"})) + with client.audit_logs.with_streaming_response.export_chunk( + end=parse_datetime("2026-01-02T00:00:00Z"), + start=parse_datetime("2026-01-01T00:00:00Z"), + ) as audit_log: + assert not audit_log.is_closed + assert audit_log.http_request.headers.get("X-Stainless-Lang") == "python" + + assert audit_log.json() == {"foo": "bar"} + assert cast(Any, audit_log.is_closed) is True + assert isinstance(audit_log, StreamedBinaryAPIResponse) + + assert cast(Any, audit_log.is_closed) is True + + +class TestAsyncAuditLogs: + parametrize = pytest.mark.parametrize( + "async_client", [False, True, {"http_client": "aiohttp"}], indirect=True, ids=["loose", "strict", "aiohttp"] + ) + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + async def test_method_list(self, async_client: AsyncKernel) -> None: + audit_log = await async_client.audit_logs.list( + end=parse_datetime("2026-01-02T00:00:00Z"), + start=parse_datetime("2026-01-01T00:00:00Z"), + ) + assert_matches_type(AsyncPageTokenPagination[AuditLogEntry], audit_log, path=["response"]) + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + async def test_method_list_with_all_params(self, async_client: AsyncKernel) -> None: + audit_log = await async_client.audit_logs.list( + end=parse_datetime("2026-01-02T00:00:00Z"), + start=parse_datetime("2026-01-01T00:00:00Z"), + auth_strategy="auth_strategy", + exclude_method=["string"], + limit=1, + method="method", + page_token="page_token", + search="search", + search_user_id=["string"], + service="service", + ) + assert_matches_type(AsyncPageTokenPagination[AuditLogEntry], audit_log, path=["response"]) + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + async def test_raw_response_list(self, async_client: AsyncKernel) -> None: + response = await async_client.audit_logs.with_raw_response.list( + end=parse_datetime("2026-01-02T00:00:00Z"), + start=parse_datetime("2026-01-01T00:00:00Z"), + ) + + assert response.is_closed is True + assert response.http_request.headers.get("X-Stainless-Lang") == "python" + audit_log = await response.parse() + assert_matches_type(AsyncPageTokenPagination[AuditLogEntry], audit_log, path=["response"]) + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + async def test_streaming_response_list(self, async_client: AsyncKernel) -> None: + async with async_client.audit_logs.with_streaming_response.list( + end=parse_datetime("2026-01-02T00:00:00Z"), + start=parse_datetime("2026-01-01T00:00:00Z"), + ) as response: + assert not response.is_closed + assert response.http_request.headers.get("X-Stainless-Lang") == "python" + + audit_log = await response.parse() + assert_matches_type(AsyncPageTokenPagination[AuditLogEntry], audit_log, path=["response"]) + + assert cast(Any, response.is_closed) is True + + @parametrize + @pytest.mark.respx(base_url=base_url) + async def test_method_export_chunk(self, async_client: AsyncKernel, respx_mock: MockRouter) -> None: + respx_mock.get("/audit-logs/export/chunk").mock(return_value=httpx.Response(200, json={"foo": "bar"})) + audit_log = await async_client.audit_logs.export_chunk( + end=parse_datetime("2026-01-02T00:00:00Z"), + start=parse_datetime("2026-01-01T00:00:00Z"), + ) + assert audit_log.is_closed + assert await audit_log.json() == {"foo": "bar"} + assert cast(Any, audit_log.is_closed) is True + assert isinstance(audit_log, AsyncBinaryAPIResponse) + + @parametrize + @pytest.mark.respx(base_url=base_url) + async def test_method_export_chunk_with_all_params(self, async_client: AsyncKernel, respx_mock: MockRouter) -> None: + respx_mock.get("/audit-logs/export/chunk").mock(return_value=httpx.Response(200, json={"foo": "bar"})) + audit_log = await async_client.audit_logs.export_chunk( + end=parse_datetime("2026-01-02T00:00:00Z"), + start=parse_datetime("2026-01-01T00:00:00Z"), + auth_strategy="auth_strategy", + cursor="cursor", + exclude_method=["string"], + format="jsonl", + limit=1, + method="method", + search="search", + search_user_id=["string"], + service="service", + ) + assert audit_log.is_closed + assert await audit_log.json() == {"foo": "bar"} + assert cast(Any, audit_log.is_closed) is True + assert isinstance(audit_log, AsyncBinaryAPIResponse) + + @parametrize + @pytest.mark.respx(base_url=base_url) + async def test_raw_response_export_chunk(self, async_client: AsyncKernel, respx_mock: MockRouter) -> None: + respx_mock.get("/audit-logs/export/chunk").mock(return_value=httpx.Response(200, json={"foo": "bar"})) + + audit_log = await async_client.audit_logs.with_raw_response.export_chunk( + end=parse_datetime("2026-01-02T00:00:00Z"), + start=parse_datetime("2026-01-01T00:00:00Z"), + ) + + assert audit_log.is_closed is True + assert audit_log.http_request.headers.get("X-Stainless-Lang") == "python" + assert await audit_log.json() == {"foo": "bar"} + assert isinstance(audit_log, AsyncBinaryAPIResponse) + + @parametrize + @pytest.mark.respx(base_url=base_url) + async def test_streaming_response_export_chunk(self, async_client: AsyncKernel, respx_mock: MockRouter) -> None: + respx_mock.get("/audit-logs/export/chunk").mock(return_value=httpx.Response(200, json={"foo": "bar"})) + async with async_client.audit_logs.with_streaming_response.export_chunk( + end=parse_datetime("2026-01-02T00:00:00Z"), + start=parse_datetime("2026-01-01T00:00:00Z"), + ) as audit_log: + assert not audit_log.is_closed + assert audit_log.http_request.headers.get("X-Stainless-Lang") == "python" + + assert await audit_log.json() == {"foo": "bar"} + assert cast(Any, audit_log.is_closed) is True + assert isinstance(audit_log, AsyncStreamedBinaryAPIResponse) + + assert cast(Any, audit_log.is_closed) is True diff --git a/tests/api_resources/test_browser_pools.py b/tests/api_resources/test_browser_pools.py new file mode 100644 index 00000000..31657789 --- /dev/null +++ b/tests/api_resources/test_browser_pools.py @@ -0,0 +1,974 @@ +# 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 kernel import Kernel, AsyncKernel +from tests.utils import assert_matches_type +from kernel.types import ( + BrowserPool, + BrowserPoolAcquireResponse, +) +from kernel.pagination import SyncOffsetPagination, AsyncOffsetPagination + +base_url = os.environ.get("TEST_API_BASE_URL", "http://127.0.0.1:4010") + + +class TestBrowserPools: + parametrize = pytest.mark.parametrize("client", [False, True], indirect=True, ids=["loose", "strict"]) + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + def test_method_create(self, client: Kernel) -> None: + browser_pool = client.browser_pools.create( + size=10, + ) + assert_matches_type(BrowserPool, browser_pool, path=["response"]) + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + def test_method_create_with_all_params(self, client: Kernel) -> None: + browser_pool = client.browser_pools.create( + size=10, + chrome_policy={"foo": "bar"}, + extensions=[ + { + "id": "id", + "name": "name", + } + ], + fill_rate_per_minute=0, + headless=False, + kiosk_mode=True, + name="my-pool", + profile={ + "id": "id", + "name": "name", + }, + proxy_id="proxy_id", + refresh_on_profile_update=True, + start_url="https://example.com", + stealth=True, + telemetry={ + "browser": { + "captcha": {"enabled": True}, + "connection": {"enabled": True}, + "console": {"enabled": True}, + "control": {"enabled": True}, + "interaction": {"enabled": True}, + "network": {"enabled": True}, + "page": {"enabled": True}, + "screenshot": {"enabled": True}, + "system": {"enabled": True}, + }, + "enabled": True, + }, + timeout_seconds=10, + viewport={ + "height": 800, + "width": 1280, + "refresh_rate": 60, + }, + ) + assert_matches_type(BrowserPool, browser_pool, path=["response"]) + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + def test_raw_response_create(self, client: Kernel) -> None: + response = client.browser_pools.with_raw_response.create( + size=10, + ) + + assert response.is_closed is True + assert response.http_request.headers.get("X-Stainless-Lang") == "python" + browser_pool = response.parse() + assert_matches_type(BrowserPool, browser_pool, path=["response"]) + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + def test_streaming_response_create(self, client: Kernel) -> None: + with client.browser_pools.with_streaming_response.create( + size=10, + ) as response: + assert not response.is_closed + assert response.http_request.headers.get("X-Stainless-Lang") == "python" + + browser_pool = response.parse() + assert_matches_type(BrowserPool, browser_pool, path=["response"]) + + assert cast(Any, response.is_closed) is True + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + def test_method_retrieve(self, client: Kernel) -> None: + browser_pool = client.browser_pools.retrieve( + "id_or_name", + ) + assert_matches_type(BrowserPool, browser_pool, path=["response"]) + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + def test_raw_response_retrieve(self, client: Kernel) -> None: + response = client.browser_pools.with_raw_response.retrieve( + "id_or_name", + ) + + assert response.is_closed is True + assert response.http_request.headers.get("X-Stainless-Lang") == "python" + browser_pool = response.parse() + assert_matches_type(BrowserPool, browser_pool, path=["response"]) + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + def test_streaming_response_retrieve(self, client: Kernel) -> None: + with client.browser_pools.with_streaming_response.retrieve( + "id_or_name", + ) as response: + assert not response.is_closed + assert response.http_request.headers.get("X-Stainless-Lang") == "python" + + browser_pool = response.parse() + assert_matches_type(BrowserPool, browser_pool, path=["response"]) + + assert cast(Any, response.is_closed) is True + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + def test_path_params_retrieve(self, client: Kernel) -> None: + with pytest.raises(ValueError, match=r"Expected a non-empty value for `id_or_name` but received ''"): + client.browser_pools.with_raw_response.retrieve( + "", + ) + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + def test_method_update(self, client: Kernel) -> None: + browser_pool = client.browser_pools.update( + id_or_name="id_or_name", + ) + assert_matches_type(BrowserPool, browser_pool, path=["response"]) + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + def test_method_update_with_all_params(self, client: Kernel) -> None: + browser_pool = client.browser_pools.update( + id_or_name="id_or_name", + chrome_policy={"foo": "bar"}, + discard_all_idle=False, + extensions=[ + { + "id": "id", + "name": "name", + } + ], + fill_rate_per_minute=0, + headless=False, + kiosk_mode=True, + name="my-pool", + profile={ + "id": "id", + "name": "name", + }, + proxy_id="proxy_id", + refresh_on_profile_update=True, + size=10, + start_url="https://example.com", + stealth=True, + telemetry={ + "browser": { + "captcha": {"enabled": True}, + "connection": {"enabled": True}, + "console": {"enabled": True}, + "control": {"enabled": True}, + "interaction": {"enabled": True}, + "network": {"enabled": True}, + "page": {"enabled": True}, + "screenshot": {"enabled": True}, + "system": {"enabled": True}, + }, + "enabled": True, + }, + timeout_seconds=10, + viewport={ + "height": 800, + "width": 1280, + "refresh_rate": 60, + }, + ) + assert_matches_type(BrowserPool, browser_pool, path=["response"]) + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + def test_raw_response_update(self, client: Kernel) -> None: + response = client.browser_pools.with_raw_response.update( + id_or_name="id_or_name", + ) + + assert response.is_closed is True + assert response.http_request.headers.get("X-Stainless-Lang") == "python" + browser_pool = response.parse() + assert_matches_type(BrowserPool, browser_pool, path=["response"]) + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + def test_streaming_response_update(self, client: Kernel) -> None: + with client.browser_pools.with_streaming_response.update( + id_or_name="id_or_name", + ) as response: + assert not response.is_closed + assert response.http_request.headers.get("X-Stainless-Lang") == "python" + + browser_pool = response.parse() + assert_matches_type(BrowserPool, browser_pool, path=["response"]) + + assert cast(Any, response.is_closed) is True + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + def test_path_params_update(self, client: Kernel) -> None: + with pytest.raises(ValueError, match=r"Expected a non-empty value for `id_or_name` but received ''"): + client.browser_pools.with_raw_response.update( + id_or_name="", + ) + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + def test_method_list(self, client: Kernel) -> None: + browser_pool = client.browser_pools.list() + assert_matches_type(SyncOffsetPagination[BrowserPool], browser_pool, path=["response"]) + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + def test_method_list_with_all_params(self, client: Kernel) -> None: + browser_pool = client.browser_pools.list( + limit=1, + name="name", + offset=0, + query="query", + ) + assert_matches_type(SyncOffsetPagination[BrowserPool], browser_pool, path=["response"]) + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + def test_raw_response_list(self, client: Kernel) -> None: + response = client.browser_pools.with_raw_response.list() + + assert response.is_closed is True + assert response.http_request.headers.get("X-Stainless-Lang") == "python" + browser_pool = response.parse() + assert_matches_type(SyncOffsetPagination[BrowserPool], browser_pool, path=["response"]) + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + def test_streaming_response_list(self, client: Kernel) -> None: + with client.browser_pools.with_streaming_response.list() as response: + assert not response.is_closed + assert response.http_request.headers.get("X-Stainless-Lang") == "python" + + browser_pool = response.parse() + assert_matches_type(SyncOffsetPagination[BrowserPool], browser_pool, path=["response"]) + + assert cast(Any, response.is_closed) is True + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + def test_method_delete(self, client: Kernel) -> None: + browser_pool = client.browser_pools.delete( + id_or_name="id_or_name", + ) + assert browser_pool is None + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + def test_method_delete_with_all_params(self, client: Kernel) -> None: + browser_pool = client.browser_pools.delete( + id_or_name="id_or_name", + force=True, + ) + assert browser_pool is None + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + def test_raw_response_delete(self, client: Kernel) -> None: + response = client.browser_pools.with_raw_response.delete( + id_or_name="id_or_name", + ) + + assert response.is_closed is True + assert response.http_request.headers.get("X-Stainless-Lang") == "python" + browser_pool = response.parse() + assert browser_pool is None + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + def test_streaming_response_delete(self, client: Kernel) -> None: + with client.browser_pools.with_streaming_response.delete( + id_or_name="id_or_name", + ) as response: + assert not response.is_closed + assert response.http_request.headers.get("X-Stainless-Lang") == "python" + + browser_pool = response.parse() + assert browser_pool is None + + assert cast(Any, response.is_closed) is True + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + def test_path_params_delete(self, client: Kernel) -> None: + with pytest.raises(ValueError, match=r"Expected a non-empty value for `id_or_name` but received ''"): + client.browser_pools.with_raw_response.delete( + id_or_name="", + ) + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + def test_method_acquire(self, client: Kernel) -> None: + browser_pool = client.browser_pools.acquire( + id_or_name="id_or_name", + ) + assert_matches_type(BrowserPoolAcquireResponse, browser_pool, path=["response"]) + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + def test_method_acquire_with_all_params(self, client: Kernel) -> None: + browser_pool = client.browser_pools.acquire( + id_or_name="id_or_name", + acquire_timeout_seconds=0, + name="checkout-flow-1", + start_url="https://example.com", + tags={ + "team": "backend", + "env": "staging", + }, + telemetry={ + "browser": { + "captcha": {"enabled": True}, + "connection": {"enabled": True}, + "console": {"enabled": True}, + "control": {"enabled": True}, + "interaction": {"enabled": True}, + "network": {"enabled": True}, + "page": {"enabled": True}, + "screenshot": {"enabled": True}, + "system": {"enabled": True}, + }, + "enabled": True, + }, + ) + assert_matches_type(BrowserPoolAcquireResponse, browser_pool, path=["response"]) + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + def test_raw_response_acquire(self, client: Kernel) -> None: + response = client.browser_pools.with_raw_response.acquire( + id_or_name="id_or_name", + ) + + assert response.is_closed is True + assert response.http_request.headers.get("X-Stainless-Lang") == "python" + browser_pool = response.parse() + assert_matches_type(BrowserPoolAcquireResponse, browser_pool, path=["response"]) + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + def test_streaming_response_acquire(self, client: Kernel) -> None: + with client.browser_pools.with_streaming_response.acquire( + id_or_name="id_or_name", + ) as response: + assert not response.is_closed + assert response.http_request.headers.get("X-Stainless-Lang") == "python" + + browser_pool = response.parse() + assert_matches_type(BrowserPoolAcquireResponse, browser_pool, path=["response"]) + + assert cast(Any, response.is_closed) is True + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + def test_path_params_acquire(self, client: Kernel) -> None: + with pytest.raises(ValueError, match=r"Expected a non-empty value for `id_or_name` but received ''"): + client.browser_pools.with_raw_response.acquire( + id_or_name="", + ) + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + def test_method_flush(self, client: Kernel) -> None: + browser_pool = client.browser_pools.flush( + "id_or_name", + ) + assert browser_pool is None + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + def test_raw_response_flush(self, client: Kernel) -> None: + response = client.browser_pools.with_raw_response.flush( + "id_or_name", + ) + + assert response.is_closed is True + assert response.http_request.headers.get("X-Stainless-Lang") == "python" + browser_pool = response.parse() + assert browser_pool is None + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + def test_streaming_response_flush(self, client: Kernel) -> None: + with client.browser_pools.with_streaming_response.flush( + "id_or_name", + ) as response: + assert not response.is_closed + assert response.http_request.headers.get("X-Stainless-Lang") == "python" + + browser_pool = response.parse() + assert browser_pool is None + + assert cast(Any, response.is_closed) is True + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + def test_path_params_flush(self, client: Kernel) -> None: + with pytest.raises(ValueError, match=r"Expected a non-empty value for `id_or_name` but received ''"): + client.browser_pools.with_raw_response.flush( + "", + ) + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + def test_method_release(self, client: Kernel) -> None: + browser_pool = client.browser_pools.release( + id_or_name="id_or_name", + session_id="ts8iy3sg25ibheguyni2lg9t", + ) + assert browser_pool is None + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + def test_method_release_with_all_params(self, client: Kernel) -> None: + browser_pool = client.browser_pools.release( + id_or_name="id_or_name", + session_id="ts8iy3sg25ibheguyni2lg9t", + reuse=False, + ) + assert browser_pool is None + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + def test_raw_response_release(self, client: Kernel) -> None: + response = client.browser_pools.with_raw_response.release( + id_or_name="id_or_name", + session_id="ts8iy3sg25ibheguyni2lg9t", + ) + + assert response.is_closed is True + assert response.http_request.headers.get("X-Stainless-Lang") == "python" + browser_pool = response.parse() + assert browser_pool is None + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + def test_streaming_response_release(self, client: Kernel) -> None: + with client.browser_pools.with_streaming_response.release( + id_or_name="id_or_name", + session_id="ts8iy3sg25ibheguyni2lg9t", + ) as response: + assert not response.is_closed + assert response.http_request.headers.get("X-Stainless-Lang") == "python" + + browser_pool = response.parse() + assert browser_pool is None + + assert cast(Any, response.is_closed) is True + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + def test_path_params_release(self, client: Kernel) -> None: + with pytest.raises(ValueError, match=r"Expected a non-empty value for `id_or_name` but received ''"): + client.browser_pools.with_raw_response.release( + id_or_name="", + session_id="ts8iy3sg25ibheguyni2lg9t", + ) + + +class TestAsyncBrowserPools: + parametrize = pytest.mark.parametrize( + "async_client", [False, True, {"http_client": "aiohttp"}], indirect=True, ids=["loose", "strict", "aiohttp"] + ) + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + async def test_method_create(self, async_client: AsyncKernel) -> None: + browser_pool = await async_client.browser_pools.create( + size=10, + ) + assert_matches_type(BrowserPool, browser_pool, path=["response"]) + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + async def test_method_create_with_all_params(self, async_client: AsyncKernel) -> None: + browser_pool = await async_client.browser_pools.create( + size=10, + chrome_policy={"foo": "bar"}, + extensions=[ + { + "id": "id", + "name": "name", + } + ], + fill_rate_per_minute=0, + headless=False, + kiosk_mode=True, + name="my-pool", + profile={ + "id": "id", + "name": "name", + }, + proxy_id="proxy_id", + refresh_on_profile_update=True, + start_url="https://example.com", + stealth=True, + telemetry={ + "browser": { + "captcha": {"enabled": True}, + "connection": {"enabled": True}, + "console": {"enabled": True}, + "control": {"enabled": True}, + "interaction": {"enabled": True}, + "network": {"enabled": True}, + "page": {"enabled": True}, + "screenshot": {"enabled": True}, + "system": {"enabled": True}, + }, + "enabled": True, + }, + timeout_seconds=10, + viewport={ + "height": 800, + "width": 1280, + "refresh_rate": 60, + }, + ) + assert_matches_type(BrowserPool, browser_pool, path=["response"]) + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + async def test_raw_response_create(self, async_client: AsyncKernel) -> None: + response = await async_client.browser_pools.with_raw_response.create( + size=10, + ) + + assert response.is_closed is True + assert response.http_request.headers.get("X-Stainless-Lang") == "python" + browser_pool = await response.parse() + assert_matches_type(BrowserPool, browser_pool, path=["response"]) + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + async def test_streaming_response_create(self, async_client: AsyncKernel) -> None: + async with async_client.browser_pools.with_streaming_response.create( + size=10, + ) as response: + assert not response.is_closed + assert response.http_request.headers.get("X-Stainless-Lang") == "python" + + browser_pool = await response.parse() + assert_matches_type(BrowserPool, browser_pool, path=["response"]) + + assert cast(Any, response.is_closed) is True + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + async def test_method_retrieve(self, async_client: AsyncKernel) -> None: + browser_pool = await async_client.browser_pools.retrieve( + "id_or_name", + ) + assert_matches_type(BrowserPool, browser_pool, path=["response"]) + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + async def test_raw_response_retrieve(self, async_client: AsyncKernel) -> None: + response = await async_client.browser_pools.with_raw_response.retrieve( + "id_or_name", + ) + + assert response.is_closed is True + assert response.http_request.headers.get("X-Stainless-Lang") == "python" + browser_pool = await response.parse() + assert_matches_type(BrowserPool, browser_pool, path=["response"]) + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + async def test_streaming_response_retrieve(self, async_client: AsyncKernel) -> None: + async with async_client.browser_pools.with_streaming_response.retrieve( + "id_or_name", + ) as response: + assert not response.is_closed + assert response.http_request.headers.get("X-Stainless-Lang") == "python" + + browser_pool = await response.parse() + assert_matches_type(BrowserPool, browser_pool, path=["response"]) + + assert cast(Any, response.is_closed) is True + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + async def test_path_params_retrieve(self, async_client: AsyncKernel) -> None: + with pytest.raises(ValueError, match=r"Expected a non-empty value for `id_or_name` but received ''"): + await async_client.browser_pools.with_raw_response.retrieve( + "", + ) + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + async def test_method_update(self, async_client: AsyncKernel) -> None: + browser_pool = await async_client.browser_pools.update( + id_or_name="id_or_name", + ) + assert_matches_type(BrowserPool, browser_pool, path=["response"]) + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + async def test_method_update_with_all_params(self, async_client: AsyncKernel) -> None: + browser_pool = await async_client.browser_pools.update( + id_or_name="id_or_name", + chrome_policy={"foo": "bar"}, + discard_all_idle=False, + extensions=[ + { + "id": "id", + "name": "name", + } + ], + fill_rate_per_minute=0, + headless=False, + kiosk_mode=True, + name="my-pool", + profile={ + "id": "id", + "name": "name", + }, + proxy_id="proxy_id", + refresh_on_profile_update=True, + size=10, + start_url="https://example.com", + stealth=True, + telemetry={ + "browser": { + "captcha": {"enabled": True}, + "connection": {"enabled": True}, + "console": {"enabled": True}, + "control": {"enabled": True}, + "interaction": {"enabled": True}, + "network": {"enabled": True}, + "page": {"enabled": True}, + "screenshot": {"enabled": True}, + "system": {"enabled": True}, + }, + "enabled": True, + }, + timeout_seconds=10, + viewport={ + "height": 800, + "width": 1280, + "refresh_rate": 60, + }, + ) + assert_matches_type(BrowserPool, browser_pool, path=["response"]) + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + async def test_raw_response_update(self, async_client: AsyncKernel) -> None: + response = await async_client.browser_pools.with_raw_response.update( + id_or_name="id_or_name", + ) + + assert response.is_closed is True + assert response.http_request.headers.get("X-Stainless-Lang") == "python" + browser_pool = await response.parse() + assert_matches_type(BrowserPool, browser_pool, path=["response"]) + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + async def test_streaming_response_update(self, async_client: AsyncKernel) -> None: + async with async_client.browser_pools.with_streaming_response.update( + id_or_name="id_or_name", + ) as response: + assert not response.is_closed + assert response.http_request.headers.get("X-Stainless-Lang") == "python" + + browser_pool = await response.parse() + assert_matches_type(BrowserPool, browser_pool, path=["response"]) + + assert cast(Any, response.is_closed) is True + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + async def test_path_params_update(self, async_client: AsyncKernel) -> None: + with pytest.raises(ValueError, match=r"Expected a non-empty value for `id_or_name` but received ''"): + await async_client.browser_pools.with_raw_response.update( + id_or_name="", + ) + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + async def test_method_list(self, async_client: AsyncKernel) -> None: + browser_pool = await async_client.browser_pools.list() + assert_matches_type(AsyncOffsetPagination[BrowserPool], browser_pool, path=["response"]) + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + async def test_method_list_with_all_params(self, async_client: AsyncKernel) -> None: + browser_pool = await async_client.browser_pools.list( + limit=1, + name="name", + offset=0, + query="query", + ) + assert_matches_type(AsyncOffsetPagination[BrowserPool], browser_pool, path=["response"]) + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + async def test_raw_response_list(self, async_client: AsyncKernel) -> None: + response = await async_client.browser_pools.with_raw_response.list() + + assert response.is_closed is True + assert response.http_request.headers.get("X-Stainless-Lang") == "python" + browser_pool = await response.parse() + assert_matches_type(AsyncOffsetPagination[BrowserPool], browser_pool, path=["response"]) + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + async def test_streaming_response_list(self, async_client: AsyncKernel) -> None: + async with async_client.browser_pools.with_streaming_response.list() as response: + assert not response.is_closed + assert response.http_request.headers.get("X-Stainless-Lang") == "python" + + browser_pool = await response.parse() + assert_matches_type(AsyncOffsetPagination[BrowserPool], browser_pool, path=["response"]) + + assert cast(Any, response.is_closed) is True + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + async def test_method_delete(self, async_client: AsyncKernel) -> None: + browser_pool = await async_client.browser_pools.delete( + id_or_name="id_or_name", + ) + assert browser_pool is None + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + async def test_method_delete_with_all_params(self, async_client: AsyncKernel) -> None: + browser_pool = await async_client.browser_pools.delete( + id_or_name="id_or_name", + force=True, + ) + assert browser_pool is None + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + async def test_raw_response_delete(self, async_client: AsyncKernel) -> None: + response = await async_client.browser_pools.with_raw_response.delete( + id_or_name="id_or_name", + ) + + assert response.is_closed is True + assert response.http_request.headers.get("X-Stainless-Lang") == "python" + browser_pool = await response.parse() + assert browser_pool is None + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + async def test_streaming_response_delete(self, async_client: AsyncKernel) -> None: + async with async_client.browser_pools.with_streaming_response.delete( + id_or_name="id_or_name", + ) as response: + assert not response.is_closed + assert response.http_request.headers.get("X-Stainless-Lang") == "python" + + browser_pool = await response.parse() + assert browser_pool is None + + assert cast(Any, response.is_closed) is True + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + async def test_path_params_delete(self, async_client: AsyncKernel) -> None: + with pytest.raises(ValueError, match=r"Expected a non-empty value for `id_or_name` but received ''"): + await async_client.browser_pools.with_raw_response.delete( + id_or_name="", + ) + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + async def test_method_acquire(self, async_client: AsyncKernel) -> None: + browser_pool = await async_client.browser_pools.acquire( + id_or_name="id_or_name", + ) + assert_matches_type(BrowserPoolAcquireResponse, browser_pool, path=["response"]) + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + async def test_method_acquire_with_all_params(self, async_client: AsyncKernel) -> None: + browser_pool = await async_client.browser_pools.acquire( + id_or_name="id_or_name", + acquire_timeout_seconds=0, + name="checkout-flow-1", + start_url="https://example.com", + tags={ + "team": "backend", + "env": "staging", + }, + telemetry={ + "browser": { + "captcha": {"enabled": True}, + "connection": {"enabled": True}, + "console": {"enabled": True}, + "control": {"enabled": True}, + "interaction": {"enabled": True}, + "network": {"enabled": True}, + "page": {"enabled": True}, + "screenshot": {"enabled": True}, + "system": {"enabled": True}, + }, + "enabled": True, + }, + ) + assert_matches_type(BrowserPoolAcquireResponse, browser_pool, path=["response"]) + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + async def test_raw_response_acquire(self, async_client: AsyncKernel) -> None: + response = await async_client.browser_pools.with_raw_response.acquire( + id_or_name="id_or_name", + ) + + assert response.is_closed is True + assert response.http_request.headers.get("X-Stainless-Lang") == "python" + browser_pool = await response.parse() + assert_matches_type(BrowserPoolAcquireResponse, browser_pool, path=["response"]) + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + async def test_streaming_response_acquire(self, async_client: AsyncKernel) -> None: + async with async_client.browser_pools.with_streaming_response.acquire( + id_or_name="id_or_name", + ) as response: + assert not response.is_closed + assert response.http_request.headers.get("X-Stainless-Lang") == "python" + + browser_pool = await response.parse() + assert_matches_type(BrowserPoolAcquireResponse, browser_pool, path=["response"]) + + assert cast(Any, response.is_closed) is True + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + async def test_path_params_acquire(self, async_client: AsyncKernel) -> None: + with pytest.raises(ValueError, match=r"Expected a non-empty value for `id_or_name` but received ''"): + await async_client.browser_pools.with_raw_response.acquire( + id_or_name="", + ) + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + async def test_method_flush(self, async_client: AsyncKernel) -> None: + browser_pool = await async_client.browser_pools.flush( + "id_or_name", + ) + assert browser_pool is None + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + async def test_raw_response_flush(self, async_client: AsyncKernel) -> None: + response = await async_client.browser_pools.with_raw_response.flush( + "id_or_name", + ) + + assert response.is_closed is True + assert response.http_request.headers.get("X-Stainless-Lang") == "python" + browser_pool = await response.parse() + assert browser_pool is None + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + async def test_streaming_response_flush(self, async_client: AsyncKernel) -> None: + async with async_client.browser_pools.with_streaming_response.flush( + "id_or_name", + ) as response: + assert not response.is_closed + assert response.http_request.headers.get("X-Stainless-Lang") == "python" + + browser_pool = await response.parse() + assert browser_pool is None + + assert cast(Any, response.is_closed) is True + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + async def test_path_params_flush(self, async_client: AsyncKernel) -> None: + with pytest.raises(ValueError, match=r"Expected a non-empty value for `id_or_name` but received ''"): + await async_client.browser_pools.with_raw_response.flush( + "", + ) + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + async def test_method_release(self, async_client: AsyncKernel) -> None: + browser_pool = await async_client.browser_pools.release( + id_or_name="id_or_name", + session_id="ts8iy3sg25ibheguyni2lg9t", + ) + assert browser_pool is None + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + async def test_method_release_with_all_params(self, async_client: AsyncKernel) -> None: + browser_pool = await async_client.browser_pools.release( + id_or_name="id_or_name", + session_id="ts8iy3sg25ibheguyni2lg9t", + reuse=False, + ) + assert browser_pool is None + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + async def test_raw_response_release(self, async_client: AsyncKernel) -> None: + response = await async_client.browser_pools.with_raw_response.release( + id_or_name="id_or_name", + session_id="ts8iy3sg25ibheguyni2lg9t", + ) + + assert response.is_closed is True + assert response.http_request.headers.get("X-Stainless-Lang") == "python" + browser_pool = await response.parse() + assert browser_pool is None + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + async def test_streaming_response_release(self, async_client: AsyncKernel) -> None: + async with async_client.browser_pools.with_streaming_response.release( + id_or_name="id_or_name", + session_id="ts8iy3sg25ibheguyni2lg9t", + ) as response: + assert not response.is_closed + assert response.http_request.headers.get("X-Stainless-Lang") == "python" + + browser_pool = await response.parse() + assert browser_pool is None + + assert cast(Any, response.is_closed) is True + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + async def test_path_params_release(self, async_client: AsyncKernel) -> None: + with pytest.raises(ValueError, match=r"Expected a non-empty value for `id_or_name` but received ''"): + await async_client.browser_pools.with_raw_response.release( + id_or_name="", + session_id="ts8iy3sg25ibheguyni2lg9t", + ) diff --git a/tests/api_resources/test_browsers.py b/tests/api_resources/test_browsers.py new file mode 100644 index 00000000..322fffed --- /dev/null +++ b/tests/api_resources/test_browsers.py @@ -0,0 +1,873 @@ +# 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 kernel import Kernel, AsyncKernel +from tests.utils import assert_matches_type +from kernel.types import ( + BrowserCurlResponse, + BrowserListResponse, + BrowserCreateResponse, + BrowserUpdateResponse, + BrowserRetrieveResponse, +) +from kernel.pagination import SyncOffsetPagination, AsyncOffsetPagination + +base_url = os.environ.get("TEST_API_BASE_URL", "http://127.0.0.1:4010") + + +class TestBrowsers: + parametrize = pytest.mark.parametrize("client", [False, True], indirect=True, ids=["loose", "strict"]) + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + def test_method_create(self, client: Kernel) -> None: + browser = client.browsers.create() + assert_matches_type(BrowserCreateResponse, browser, path=["response"]) + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + def test_method_create_with_all_params(self, client: Kernel) -> None: + browser = client.browsers.create( + chrome_policy={"foo": "bar"}, + extensions=[ + { + "id": "id", + "name": "name", + } + ], + gpu=False, + headless=False, + invocation_id="rr33xuugxj9h0bkf1rdt2bet", + kiosk_mode=True, + name="checkout-flow-1", + profile={ + "id": "id", + "name": "name", + "save_changes": True, + }, + proxy_id="proxy_id", + start_url="https://example.com", + stealth=True, + tags={ + "team": "backend", + "env": "staging", + }, + telemetry={ + "browser": { + "captcha": {"enabled": True}, + "connection": {"enabled": True}, + "console": {"enabled": True}, + "control": {"enabled": True}, + "interaction": {"enabled": True}, + "network": {"enabled": True}, + "page": {"enabled": True}, + "screenshot": {"enabled": True}, + "system": {"enabled": True}, + }, + "enabled": True, + }, + timeout_seconds=10, + viewport={ + "height": 800, + "width": 1280, + "refresh_rate": 60, + }, + ) + assert_matches_type(BrowserCreateResponse, browser, path=["response"]) + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + def test_raw_response_create(self, client: Kernel) -> None: + response = client.browsers.with_raw_response.create() + + assert response.is_closed is True + assert response.http_request.headers.get("X-Stainless-Lang") == "python" + browser = response.parse() + assert_matches_type(BrowserCreateResponse, browser, path=["response"]) + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + def test_streaming_response_create(self, client: Kernel) -> None: + with client.browsers.with_streaming_response.create() as response: + assert not response.is_closed + assert response.http_request.headers.get("X-Stainless-Lang") == "python" + + browser = response.parse() + assert_matches_type(BrowserCreateResponse, browser, path=["response"]) + + assert cast(Any, response.is_closed) is True + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + def test_method_retrieve(self, client: Kernel) -> None: + browser = client.browsers.retrieve( + id_or_name="htzv5orfit78e1m2biiifpbv", + ) + assert_matches_type(BrowserRetrieveResponse, browser, path=["response"]) + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + def test_method_retrieve_with_all_params(self, client: Kernel) -> None: + browser = client.browsers.retrieve( + id_or_name="htzv5orfit78e1m2biiifpbv", + include_deleted=True, + ) + assert_matches_type(BrowserRetrieveResponse, browser, path=["response"]) + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + def test_raw_response_retrieve(self, client: Kernel) -> None: + response = client.browsers.with_raw_response.retrieve( + id_or_name="htzv5orfit78e1m2biiifpbv", + ) + + assert response.is_closed is True + assert response.http_request.headers.get("X-Stainless-Lang") == "python" + browser = response.parse() + assert_matches_type(BrowserRetrieveResponse, browser, path=["response"]) + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + def test_streaming_response_retrieve(self, client: Kernel) -> None: + with client.browsers.with_streaming_response.retrieve( + id_or_name="htzv5orfit78e1m2biiifpbv", + ) as response: + assert not response.is_closed + assert response.http_request.headers.get("X-Stainless-Lang") == "python" + + browser = response.parse() + assert_matches_type(BrowserRetrieveResponse, browser, path=["response"]) + + assert cast(Any, response.is_closed) is True + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + def test_path_params_retrieve(self, client: Kernel) -> None: + with pytest.raises(ValueError, match=r"Expected a non-empty value for `id_or_name` but received ''"): + client.browsers.with_raw_response.retrieve( + id_or_name="", + ) + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + def test_method_update(self, client: Kernel) -> None: + browser = client.browsers.update( + id_or_name="htzv5orfit78e1m2biiifpbv", + ) + assert_matches_type(BrowserUpdateResponse, browser, path=["response"]) + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + def test_method_update_with_all_params(self, client: Kernel) -> None: + browser = client.browsers.update( + id_or_name="htzv5orfit78e1m2biiifpbv", + disable_default_proxy=True, + name="checkout-flow-1", + profile={ + "id": "id", + "name": "name", + "save_changes": True, + }, + proxy_id="proxy_id", + tags={ + "team": "backend", + "env": "staging", + }, + telemetry={ + "browser": { + "captcha": {"enabled": True}, + "connection": {"enabled": True}, + "console": {"enabled": True}, + "control": {"enabled": True}, + "interaction": {"enabled": True}, + "network": {"enabled": True}, + "page": {"enabled": True}, + "screenshot": {"enabled": True}, + "system": {"enabled": True}, + }, + "enabled": True, + }, + viewport={ + "height": 800, + "width": 1280, + "refresh_rate": 60, + "force": True, + }, + ) + assert_matches_type(BrowserUpdateResponse, browser, path=["response"]) + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + def test_raw_response_update(self, client: Kernel) -> None: + response = client.browsers.with_raw_response.update( + id_or_name="htzv5orfit78e1m2biiifpbv", + ) + + assert response.is_closed is True + assert response.http_request.headers.get("X-Stainless-Lang") == "python" + browser = response.parse() + assert_matches_type(BrowserUpdateResponse, browser, path=["response"]) + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + def test_streaming_response_update(self, client: Kernel) -> None: + with client.browsers.with_streaming_response.update( + id_or_name="htzv5orfit78e1m2biiifpbv", + ) as response: + assert not response.is_closed + assert response.http_request.headers.get("X-Stainless-Lang") == "python" + + browser = response.parse() + assert_matches_type(BrowserUpdateResponse, browser, path=["response"]) + + assert cast(Any, response.is_closed) is True + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + def test_path_params_update(self, client: Kernel) -> None: + with pytest.raises(ValueError, match=r"Expected a non-empty value for `id_or_name` but received ''"): + client.browsers.with_raw_response.update( + id_or_name="", + ) + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + def test_method_list(self, client: Kernel) -> None: + browser = client.browsers.list() + assert_matches_type(SyncOffsetPagination[BrowserListResponse], browser, path=["response"]) + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + def test_method_list_with_all_params(self, client: Kernel) -> None: + browser = client.browsers.list( + include_deleted=True, + limit=1, + offset=0, + query="query", + status="active", + tags={"foo": "string"}, + ) + assert_matches_type(SyncOffsetPagination[BrowserListResponse], browser, path=["response"]) + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + def test_raw_response_list(self, client: Kernel) -> None: + response = client.browsers.with_raw_response.list() + + assert response.is_closed is True + assert response.http_request.headers.get("X-Stainless-Lang") == "python" + browser = response.parse() + assert_matches_type(SyncOffsetPagination[BrowserListResponse], browser, path=["response"]) + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + def test_streaming_response_list(self, client: Kernel) -> None: + with client.browsers.with_streaming_response.list() as response: + assert not response.is_closed + assert response.http_request.headers.get("X-Stainless-Lang") == "python" + + browser = response.parse() + assert_matches_type(SyncOffsetPagination[BrowserListResponse], browser, path=["response"]) + + assert cast(Any, response.is_closed) is True + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + def test_method_curl(self, client: Kernel) -> None: + browser = client.browsers.curl( + id="id", + url="url", + ) + assert_matches_type(BrowserCurlResponse, browser, path=["response"]) + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + def test_method_curl_with_all_params(self, client: Kernel) -> None: + browser = client.browsers.curl( + id="id", + url="url", + body="body", + headers={"foo": "string"}, + method="GET", + response_encoding="utf8", + timeout_ms=1000, + ) + assert_matches_type(BrowserCurlResponse, browser, path=["response"]) + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + def test_raw_response_curl(self, client: Kernel) -> None: + response = client.browsers.with_raw_response.curl( + id="id", + url="url", + ) + + assert response.is_closed is True + assert response.http_request.headers.get("X-Stainless-Lang") == "python" + browser = response.parse() + assert_matches_type(BrowserCurlResponse, browser, path=["response"]) + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + def test_streaming_response_curl(self, client: Kernel) -> None: + with client.browsers.with_streaming_response.curl( + id="id", + url="url", + ) as response: + assert not response.is_closed + assert response.http_request.headers.get("X-Stainless-Lang") == "python" + + browser = response.parse() + assert_matches_type(BrowserCurlResponse, browser, path=["response"]) + + assert cast(Any, response.is_closed) is True + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + def test_path_params_curl(self, client: Kernel) -> None: + with pytest.raises(ValueError, match=r"Expected a non-empty value for `id` but received ''"): + client.browsers.with_raw_response.curl( + id="", + url="url", + ) + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + def test_method_delete_by_id(self, client: Kernel) -> None: + browser = client.browsers.delete_by_id( + "htzv5orfit78e1m2biiifpbv", + ) + assert browser is None + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + def test_raw_response_delete_by_id(self, client: Kernel) -> None: + response = client.browsers.with_raw_response.delete_by_id( + "htzv5orfit78e1m2biiifpbv", + ) + + assert response.is_closed is True + assert response.http_request.headers.get("X-Stainless-Lang") == "python" + browser = response.parse() + assert browser is None + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + def test_streaming_response_delete_by_id(self, client: Kernel) -> None: + with client.browsers.with_streaming_response.delete_by_id( + "htzv5orfit78e1m2biiifpbv", + ) as response: + assert not response.is_closed + assert response.http_request.headers.get("X-Stainless-Lang") == "python" + + browser = response.parse() + assert browser is None + + assert cast(Any, response.is_closed) is True + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + def test_path_params_delete_by_id(self, client: Kernel) -> None: + with pytest.raises(ValueError, match=r"Expected a non-empty value for `id_or_name` but received ''"): + client.browsers.with_raw_response.delete_by_id( + "", + ) + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + def test_method_load_extensions(self, client: Kernel) -> None: + browser = client.browsers.load_extensions( + id="id", + extensions=[ + { + "name": "name", + "zip_file": b"Example data", + } + ], + ) + assert browser is None + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + def test_raw_response_load_extensions(self, client: Kernel) -> None: + response = client.browsers.with_raw_response.load_extensions( + id="id", + extensions=[ + { + "name": "name", + "zip_file": b"Example data", + } + ], + ) + + assert response.is_closed is True + assert response.http_request.headers.get("X-Stainless-Lang") == "python" + browser = response.parse() + assert browser is None + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + def test_streaming_response_load_extensions(self, client: Kernel) -> None: + with client.browsers.with_streaming_response.load_extensions( + id="id", + extensions=[ + { + "name": "name", + "zip_file": b"Example data", + } + ], + ) as response: + assert not response.is_closed + assert response.http_request.headers.get("X-Stainless-Lang") == "python" + + browser = response.parse() + assert browser is None + + assert cast(Any, response.is_closed) is True + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + def test_path_params_load_extensions(self, client: Kernel) -> None: + with pytest.raises(ValueError, match=r"Expected a non-empty value for `id` but received ''"): + client.browsers.with_raw_response.load_extensions( + id="", + extensions=[ + { + "name": "name", + "zip_file": b"Example data", + } + ], + ) + + +class TestAsyncBrowsers: + parametrize = pytest.mark.parametrize( + "async_client", [False, True, {"http_client": "aiohttp"}], indirect=True, ids=["loose", "strict", "aiohttp"] + ) + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + async def test_method_create(self, async_client: AsyncKernel) -> None: + browser = await async_client.browsers.create() + assert_matches_type(BrowserCreateResponse, browser, path=["response"]) + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + async def test_method_create_with_all_params(self, async_client: AsyncKernel) -> None: + browser = await async_client.browsers.create( + chrome_policy={"foo": "bar"}, + extensions=[ + { + "id": "id", + "name": "name", + } + ], + gpu=False, + headless=False, + invocation_id="rr33xuugxj9h0bkf1rdt2bet", + kiosk_mode=True, + name="checkout-flow-1", + profile={ + "id": "id", + "name": "name", + "save_changes": True, + }, + proxy_id="proxy_id", + start_url="https://example.com", + stealth=True, + tags={ + "team": "backend", + "env": "staging", + }, + telemetry={ + "browser": { + "captcha": {"enabled": True}, + "connection": {"enabled": True}, + "console": {"enabled": True}, + "control": {"enabled": True}, + "interaction": {"enabled": True}, + "network": {"enabled": True}, + "page": {"enabled": True}, + "screenshot": {"enabled": True}, + "system": {"enabled": True}, + }, + "enabled": True, + }, + timeout_seconds=10, + viewport={ + "height": 800, + "width": 1280, + "refresh_rate": 60, + }, + ) + assert_matches_type(BrowserCreateResponse, browser, path=["response"]) + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + async def test_raw_response_create(self, async_client: AsyncKernel) -> None: + response = await async_client.browsers.with_raw_response.create() + + assert response.is_closed is True + assert response.http_request.headers.get("X-Stainless-Lang") == "python" + browser = await response.parse() + assert_matches_type(BrowserCreateResponse, browser, path=["response"]) + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + async def test_streaming_response_create(self, async_client: AsyncKernel) -> None: + async with async_client.browsers.with_streaming_response.create() as response: + assert not response.is_closed + assert response.http_request.headers.get("X-Stainless-Lang") == "python" + + browser = await response.parse() + assert_matches_type(BrowserCreateResponse, browser, path=["response"]) + + assert cast(Any, response.is_closed) is True + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + async def test_method_retrieve(self, async_client: AsyncKernel) -> None: + browser = await async_client.browsers.retrieve( + id_or_name="htzv5orfit78e1m2biiifpbv", + ) + assert_matches_type(BrowserRetrieveResponse, browser, path=["response"]) + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + async def test_method_retrieve_with_all_params(self, async_client: AsyncKernel) -> None: + browser = await async_client.browsers.retrieve( + id_or_name="htzv5orfit78e1m2biiifpbv", + include_deleted=True, + ) + assert_matches_type(BrowserRetrieveResponse, browser, path=["response"]) + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + async def test_raw_response_retrieve(self, async_client: AsyncKernel) -> None: + response = await async_client.browsers.with_raw_response.retrieve( + id_or_name="htzv5orfit78e1m2biiifpbv", + ) + + assert response.is_closed is True + assert response.http_request.headers.get("X-Stainless-Lang") == "python" + browser = await response.parse() + assert_matches_type(BrowserRetrieveResponse, browser, path=["response"]) + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + async def test_streaming_response_retrieve(self, async_client: AsyncKernel) -> None: + async with async_client.browsers.with_streaming_response.retrieve( + id_or_name="htzv5orfit78e1m2biiifpbv", + ) as response: + assert not response.is_closed + assert response.http_request.headers.get("X-Stainless-Lang") == "python" + + browser = await response.parse() + assert_matches_type(BrowserRetrieveResponse, browser, path=["response"]) + + assert cast(Any, response.is_closed) is True + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + async def test_path_params_retrieve(self, async_client: AsyncKernel) -> None: + with pytest.raises(ValueError, match=r"Expected a non-empty value for `id_or_name` but received ''"): + await async_client.browsers.with_raw_response.retrieve( + id_or_name="", + ) + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + async def test_method_update(self, async_client: AsyncKernel) -> None: + browser = await async_client.browsers.update( + id_or_name="htzv5orfit78e1m2biiifpbv", + ) + assert_matches_type(BrowserUpdateResponse, browser, path=["response"]) + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + async def test_method_update_with_all_params(self, async_client: AsyncKernel) -> None: + browser = await async_client.browsers.update( + id_or_name="htzv5orfit78e1m2biiifpbv", + disable_default_proxy=True, + name="checkout-flow-1", + profile={ + "id": "id", + "name": "name", + "save_changes": True, + }, + proxy_id="proxy_id", + tags={ + "team": "backend", + "env": "staging", + }, + telemetry={ + "browser": { + "captcha": {"enabled": True}, + "connection": {"enabled": True}, + "console": {"enabled": True}, + "control": {"enabled": True}, + "interaction": {"enabled": True}, + "network": {"enabled": True}, + "page": {"enabled": True}, + "screenshot": {"enabled": True}, + "system": {"enabled": True}, + }, + "enabled": True, + }, + viewport={ + "height": 800, + "width": 1280, + "refresh_rate": 60, + "force": True, + }, + ) + assert_matches_type(BrowserUpdateResponse, browser, path=["response"]) + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + async def test_raw_response_update(self, async_client: AsyncKernel) -> None: + response = await async_client.browsers.with_raw_response.update( + id_or_name="htzv5orfit78e1m2biiifpbv", + ) + + assert response.is_closed is True + assert response.http_request.headers.get("X-Stainless-Lang") == "python" + browser = await response.parse() + assert_matches_type(BrowserUpdateResponse, browser, path=["response"]) + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + async def test_streaming_response_update(self, async_client: AsyncKernel) -> None: + async with async_client.browsers.with_streaming_response.update( + id_or_name="htzv5orfit78e1m2biiifpbv", + ) as response: + assert not response.is_closed + assert response.http_request.headers.get("X-Stainless-Lang") == "python" + + browser = await response.parse() + assert_matches_type(BrowserUpdateResponse, browser, path=["response"]) + + assert cast(Any, response.is_closed) is True + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + async def test_path_params_update(self, async_client: AsyncKernel) -> None: + with pytest.raises(ValueError, match=r"Expected a non-empty value for `id_or_name` but received ''"): + await async_client.browsers.with_raw_response.update( + id_or_name="", + ) + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + async def test_method_list(self, async_client: AsyncKernel) -> None: + browser = await async_client.browsers.list() + assert_matches_type(AsyncOffsetPagination[BrowserListResponse], browser, path=["response"]) + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + async def test_method_list_with_all_params(self, async_client: AsyncKernel) -> None: + browser = await async_client.browsers.list( + include_deleted=True, + limit=1, + offset=0, + query="query", + status="active", + tags={"foo": "string"}, + ) + assert_matches_type(AsyncOffsetPagination[BrowserListResponse], browser, path=["response"]) + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + async def test_raw_response_list(self, async_client: AsyncKernel) -> None: + response = await async_client.browsers.with_raw_response.list() + + assert response.is_closed is True + assert response.http_request.headers.get("X-Stainless-Lang") == "python" + browser = await response.parse() + assert_matches_type(AsyncOffsetPagination[BrowserListResponse], browser, path=["response"]) + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + async def test_streaming_response_list(self, async_client: AsyncKernel) -> None: + async with async_client.browsers.with_streaming_response.list() as response: + assert not response.is_closed + assert response.http_request.headers.get("X-Stainless-Lang") == "python" + + browser = await response.parse() + assert_matches_type(AsyncOffsetPagination[BrowserListResponse], browser, path=["response"]) + + assert cast(Any, response.is_closed) is True + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + async def test_method_curl(self, async_client: AsyncKernel) -> None: + browser = await async_client.browsers.curl( + id="id", + url="url", + ) + assert_matches_type(BrowserCurlResponse, browser, path=["response"]) + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + async def test_method_curl_with_all_params(self, async_client: AsyncKernel) -> None: + browser = await async_client.browsers.curl( + id="id", + url="url", + body="body", + headers={"foo": "string"}, + method="GET", + response_encoding="utf8", + timeout_ms=1000, + ) + assert_matches_type(BrowserCurlResponse, browser, path=["response"]) + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + async def test_raw_response_curl(self, async_client: AsyncKernel) -> None: + response = await async_client.browsers.with_raw_response.curl( + id="id", + url="url", + ) + + assert response.is_closed is True + assert response.http_request.headers.get("X-Stainless-Lang") == "python" + browser = await response.parse() + assert_matches_type(BrowserCurlResponse, browser, path=["response"]) + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + async def test_streaming_response_curl(self, async_client: AsyncKernel) -> None: + async with async_client.browsers.with_streaming_response.curl( + id="id", + url="url", + ) as response: + assert not response.is_closed + assert response.http_request.headers.get("X-Stainless-Lang") == "python" + + browser = await response.parse() + assert_matches_type(BrowserCurlResponse, browser, path=["response"]) + + assert cast(Any, response.is_closed) is True + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + async def test_path_params_curl(self, async_client: AsyncKernel) -> None: + with pytest.raises(ValueError, match=r"Expected a non-empty value for `id` but received ''"): + await async_client.browsers.with_raw_response.curl( + id="", + url="url", + ) + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + async def test_method_delete_by_id(self, async_client: AsyncKernel) -> None: + browser = await async_client.browsers.delete_by_id( + "htzv5orfit78e1m2biiifpbv", + ) + assert browser is None + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + async def test_raw_response_delete_by_id(self, async_client: AsyncKernel) -> None: + response = await async_client.browsers.with_raw_response.delete_by_id( + "htzv5orfit78e1m2biiifpbv", + ) + + assert response.is_closed is True + assert response.http_request.headers.get("X-Stainless-Lang") == "python" + browser = await response.parse() + assert browser is None + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + async def test_streaming_response_delete_by_id(self, async_client: AsyncKernel) -> None: + async with async_client.browsers.with_streaming_response.delete_by_id( + "htzv5orfit78e1m2biiifpbv", + ) as response: + assert not response.is_closed + assert response.http_request.headers.get("X-Stainless-Lang") == "python" + + browser = await response.parse() + assert browser is None + + assert cast(Any, response.is_closed) is True + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + async def test_path_params_delete_by_id(self, async_client: AsyncKernel) -> None: + with pytest.raises(ValueError, match=r"Expected a non-empty value for `id_or_name` but received ''"): + await async_client.browsers.with_raw_response.delete_by_id( + "", + ) + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + async def test_method_load_extensions(self, async_client: AsyncKernel) -> None: + browser = await async_client.browsers.load_extensions( + id="id", + extensions=[ + { + "name": "name", + "zip_file": b"Example data", + } + ], + ) + assert browser is None + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + async def test_raw_response_load_extensions(self, async_client: AsyncKernel) -> None: + response = await async_client.browsers.with_raw_response.load_extensions( + id="id", + extensions=[ + { + "name": "name", + "zip_file": b"Example data", + } + ], + ) + + assert response.is_closed is True + assert response.http_request.headers.get("X-Stainless-Lang") == "python" + browser = await response.parse() + assert browser is None + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + async def test_streaming_response_load_extensions(self, async_client: AsyncKernel) -> None: + async with async_client.browsers.with_streaming_response.load_extensions( + id="id", + extensions=[ + { + "name": "name", + "zip_file": b"Example data", + } + ], + ) as response: + assert not response.is_closed + assert response.http_request.headers.get("X-Stainless-Lang") == "python" + + browser = await response.parse() + assert browser is None + + assert cast(Any, response.is_closed) is True + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + async def test_path_params_load_extensions(self, async_client: AsyncKernel) -> None: + with pytest.raises(ValueError, match=r"Expected a non-empty value for `id` but received ''"): + await async_client.browsers.with_raw_response.load_extensions( + id="", + extensions=[ + { + "name": "name", + "zip_file": b"Example data", + } + ], + ) diff --git a/tests/api_resources/test_credential_providers.py b/tests/api_resources/test_credential_providers.py new file mode 100644 index 00000000..a47d76bb --- /dev/null +++ b/tests/api_resources/test_credential_providers.py @@ -0,0 +1,653 @@ +# 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 kernel import Kernel, AsyncKernel +from tests.utils import assert_matches_type +from kernel.types import ( + CredentialProvider, + CredentialProviderTestResult, + CredentialProviderListItemsResponse, +) +from kernel.pagination import SyncOffsetPagination, AsyncOffsetPagination + +base_url = os.environ.get("TEST_API_BASE_URL", "http://127.0.0.1:4010") + + +class TestCredentialProviders: + parametrize = pytest.mark.parametrize("client", [False, True], indirect=True, ids=["loose", "strict"]) + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + def test_method_create(self, client: Kernel) -> None: + credential_provider = client.credential_providers.create( + token="ops_eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCJ9...", + name="my-1password", + provider_type="onepassword", + ) + assert_matches_type(CredentialProvider, credential_provider, path=["response"]) + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + def test_method_create_with_all_params(self, client: Kernel) -> None: + credential_provider = client.credential_providers.create( + token="ops_eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCJ9...", + name="my-1password", + provider_type="onepassword", + cache_ttl_seconds=300, + ) + assert_matches_type(CredentialProvider, credential_provider, path=["response"]) + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + def test_raw_response_create(self, client: Kernel) -> None: + response = client.credential_providers.with_raw_response.create( + token="ops_eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCJ9...", + name="my-1password", + provider_type="onepassword", + ) + + assert response.is_closed is True + assert response.http_request.headers.get("X-Stainless-Lang") == "python" + credential_provider = response.parse() + assert_matches_type(CredentialProvider, credential_provider, path=["response"]) + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + def test_streaming_response_create(self, client: Kernel) -> None: + with client.credential_providers.with_streaming_response.create( + token="ops_eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCJ9...", + name="my-1password", + provider_type="onepassword", + ) as response: + assert not response.is_closed + assert response.http_request.headers.get("X-Stainless-Lang") == "python" + + credential_provider = response.parse() + assert_matches_type(CredentialProvider, credential_provider, path=["response"]) + + assert cast(Any, response.is_closed) is True + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + def test_method_retrieve(self, client: Kernel) -> None: + credential_provider = client.credential_providers.retrieve( + "id", + ) + assert_matches_type(CredentialProvider, credential_provider, path=["response"]) + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + def test_raw_response_retrieve(self, client: Kernel) -> None: + response = client.credential_providers.with_raw_response.retrieve( + "id", + ) + + assert response.is_closed is True + assert response.http_request.headers.get("X-Stainless-Lang") == "python" + credential_provider = response.parse() + assert_matches_type(CredentialProvider, credential_provider, path=["response"]) + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + def test_streaming_response_retrieve(self, client: Kernel) -> None: + with client.credential_providers.with_streaming_response.retrieve( + "id", + ) as response: + assert not response.is_closed + assert response.http_request.headers.get("X-Stainless-Lang") == "python" + + credential_provider = response.parse() + assert_matches_type(CredentialProvider, credential_provider, path=["response"]) + + assert cast(Any, response.is_closed) is True + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + def test_path_params_retrieve(self, client: Kernel) -> None: + with pytest.raises(ValueError, match=r"Expected a non-empty value for `id` but received ''"): + client.credential_providers.with_raw_response.retrieve( + "", + ) + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + def test_method_update(self, client: Kernel) -> None: + credential_provider = client.credential_providers.update( + id="id", + ) + assert_matches_type(CredentialProvider, credential_provider, path=["response"]) + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + def test_method_update_with_all_params(self, client: Kernel) -> None: + credential_provider = client.credential_providers.update( + id="id", + token="ops_eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCJ9...", + cache_ttl_seconds=300, + enabled=True, + name="my-1password", + priority=0, + ) + assert_matches_type(CredentialProvider, credential_provider, path=["response"]) + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + def test_raw_response_update(self, client: Kernel) -> None: + response = client.credential_providers.with_raw_response.update( + id="id", + ) + + assert response.is_closed is True + assert response.http_request.headers.get("X-Stainless-Lang") == "python" + credential_provider = response.parse() + assert_matches_type(CredentialProvider, credential_provider, path=["response"]) + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + def test_streaming_response_update(self, client: Kernel) -> None: + with client.credential_providers.with_streaming_response.update( + id="id", + ) as response: + assert not response.is_closed + assert response.http_request.headers.get("X-Stainless-Lang") == "python" + + credential_provider = response.parse() + assert_matches_type(CredentialProvider, credential_provider, path=["response"]) + + assert cast(Any, response.is_closed) is True + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + def test_path_params_update(self, client: Kernel) -> None: + with pytest.raises(ValueError, match=r"Expected a non-empty value for `id` but received ''"): + client.credential_providers.with_raw_response.update( + id="", + ) + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + def test_method_list(self, client: Kernel) -> None: + credential_provider = client.credential_providers.list() + assert_matches_type(SyncOffsetPagination[CredentialProvider], credential_provider, path=["response"]) + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + def test_method_list_with_all_params(self, client: Kernel) -> None: + credential_provider = client.credential_providers.list( + limit=1, + offset=0, + query="query", + ) + assert_matches_type(SyncOffsetPagination[CredentialProvider], credential_provider, path=["response"]) + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + def test_raw_response_list(self, client: Kernel) -> None: + response = client.credential_providers.with_raw_response.list() + + assert response.is_closed is True + assert response.http_request.headers.get("X-Stainless-Lang") == "python" + credential_provider = response.parse() + assert_matches_type(SyncOffsetPagination[CredentialProvider], credential_provider, path=["response"]) + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + def test_streaming_response_list(self, client: Kernel) -> None: + with client.credential_providers.with_streaming_response.list() as response: + assert not response.is_closed + assert response.http_request.headers.get("X-Stainless-Lang") == "python" + + credential_provider = response.parse() + assert_matches_type(SyncOffsetPagination[CredentialProvider], credential_provider, path=["response"]) + + assert cast(Any, response.is_closed) is True + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + def test_method_delete(self, client: Kernel) -> None: + credential_provider = client.credential_providers.delete( + "id", + ) + assert credential_provider is None + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + def test_raw_response_delete(self, client: Kernel) -> None: + response = client.credential_providers.with_raw_response.delete( + "id", + ) + + assert response.is_closed is True + assert response.http_request.headers.get("X-Stainless-Lang") == "python" + credential_provider = response.parse() + assert credential_provider is None + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + def test_streaming_response_delete(self, client: Kernel) -> None: + with client.credential_providers.with_streaming_response.delete( + "id", + ) as response: + assert not response.is_closed + assert response.http_request.headers.get("X-Stainless-Lang") == "python" + + credential_provider = response.parse() + assert credential_provider is None + + assert cast(Any, response.is_closed) is True + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + def test_path_params_delete(self, client: Kernel) -> None: + with pytest.raises(ValueError, match=r"Expected a non-empty value for `id` but received ''"): + client.credential_providers.with_raw_response.delete( + "", + ) + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + def test_method_list_items(self, client: Kernel) -> None: + credential_provider = client.credential_providers.list_items( + "id", + ) + assert_matches_type(CredentialProviderListItemsResponse, credential_provider, path=["response"]) + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + def test_raw_response_list_items(self, client: Kernel) -> None: + response = client.credential_providers.with_raw_response.list_items( + "id", + ) + + assert response.is_closed is True + assert response.http_request.headers.get("X-Stainless-Lang") == "python" + credential_provider = response.parse() + assert_matches_type(CredentialProviderListItemsResponse, credential_provider, path=["response"]) + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + def test_streaming_response_list_items(self, client: Kernel) -> None: + with client.credential_providers.with_streaming_response.list_items( + "id", + ) as response: + assert not response.is_closed + assert response.http_request.headers.get("X-Stainless-Lang") == "python" + + credential_provider = response.parse() + assert_matches_type(CredentialProviderListItemsResponse, credential_provider, path=["response"]) + + assert cast(Any, response.is_closed) is True + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + def test_path_params_list_items(self, client: Kernel) -> None: + with pytest.raises(ValueError, match=r"Expected a non-empty value for `id` but received ''"): + client.credential_providers.with_raw_response.list_items( + "", + ) + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + def test_method_test(self, client: Kernel) -> None: + credential_provider = client.credential_providers.test( + "id", + ) + assert_matches_type(CredentialProviderTestResult, credential_provider, path=["response"]) + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + def test_raw_response_test(self, client: Kernel) -> None: + response = client.credential_providers.with_raw_response.test( + "id", + ) + + assert response.is_closed is True + assert response.http_request.headers.get("X-Stainless-Lang") == "python" + credential_provider = response.parse() + assert_matches_type(CredentialProviderTestResult, credential_provider, path=["response"]) + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + def test_streaming_response_test(self, client: Kernel) -> None: + with client.credential_providers.with_streaming_response.test( + "id", + ) as response: + assert not response.is_closed + assert response.http_request.headers.get("X-Stainless-Lang") == "python" + + credential_provider = response.parse() + assert_matches_type(CredentialProviderTestResult, credential_provider, path=["response"]) + + assert cast(Any, response.is_closed) is True + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + def test_path_params_test(self, client: Kernel) -> None: + with pytest.raises(ValueError, match=r"Expected a non-empty value for `id` but received ''"): + client.credential_providers.with_raw_response.test( + "", + ) + + +class TestAsyncCredentialProviders: + parametrize = pytest.mark.parametrize( + "async_client", [False, True, {"http_client": "aiohttp"}], indirect=True, ids=["loose", "strict", "aiohttp"] + ) + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + async def test_method_create(self, async_client: AsyncKernel) -> None: + credential_provider = await async_client.credential_providers.create( + token="ops_eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCJ9...", + name="my-1password", + provider_type="onepassword", + ) + assert_matches_type(CredentialProvider, credential_provider, path=["response"]) + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + async def test_method_create_with_all_params(self, async_client: AsyncKernel) -> None: + credential_provider = await async_client.credential_providers.create( + token="ops_eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCJ9...", + name="my-1password", + provider_type="onepassword", + cache_ttl_seconds=300, + ) + assert_matches_type(CredentialProvider, credential_provider, path=["response"]) + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + async def test_raw_response_create(self, async_client: AsyncKernel) -> None: + response = await async_client.credential_providers.with_raw_response.create( + token="ops_eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCJ9...", + name="my-1password", + provider_type="onepassword", + ) + + assert response.is_closed is True + assert response.http_request.headers.get("X-Stainless-Lang") == "python" + credential_provider = await response.parse() + assert_matches_type(CredentialProvider, credential_provider, path=["response"]) + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + async def test_streaming_response_create(self, async_client: AsyncKernel) -> None: + async with async_client.credential_providers.with_streaming_response.create( + token="ops_eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCJ9...", + name="my-1password", + provider_type="onepassword", + ) as response: + assert not response.is_closed + assert response.http_request.headers.get("X-Stainless-Lang") == "python" + + credential_provider = await response.parse() + assert_matches_type(CredentialProvider, credential_provider, path=["response"]) + + assert cast(Any, response.is_closed) is True + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + async def test_method_retrieve(self, async_client: AsyncKernel) -> None: + credential_provider = await async_client.credential_providers.retrieve( + "id", + ) + assert_matches_type(CredentialProvider, credential_provider, path=["response"]) + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + async def test_raw_response_retrieve(self, async_client: AsyncKernel) -> None: + response = await async_client.credential_providers.with_raw_response.retrieve( + "id", + ) + + assert response.is_closed is True + assert response.http_request.headers.get("X-Stainless-Lang") == "python" + credential_provider = await response.parse() + assert_matches_type(CredentialProvider, credential_provider, path=["response"]) + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + async def test_streaming_response_retrieve(self, async_client: AsyncKernel) -> None: + async with async_client.credential_providers.with_streaming_response.retrieve( + "id", + ) as response: + assert not response.is_closed + assert response.http_request.headers.get("X-Stainless-Lang") == "python" + + credential_provider = await response.parse() + assert_matches_type(CredentialProvider, credential_provider, path=["response"]) + + assert cast(Any, response.is_closed) is True + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + async def test_path_params_retrieve(self, async_client: AsyncKernel) -> None: + with pytest.raises(ValueError, match=r"Expected a non-empty value for `id` but received ''"): + await async_client.credential_providers.with_raw_response.retrieve( + "", + ) + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + async def test_method_update(self, async_client: AsyncKernel) -> None: + credential_provider = await async_client.credential_providers.update( + id="id", + ) + assert_matches_type(CredentialProvider, credential_provider, path=["response"]) + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + async def test_method_update_with_all_params(self, async_client: AsyncKernel) -> None: + credential_provider = await async_client.credential_providers.update( + id="id", + token="ops_eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCJ9...", + cache_ttl_seconds=300, + enabled=True, + name="my-1password", + priority=0, + ) + assert_matches_type(CredentialProvider, credential_provider, path=["response"]) + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + async def test_raw_response_update(self, async_client: AsyncKernel) -> None: + response = await async_client.credential_providers.with_raw_response.update( + id="id", + ) + + assert response.is_closed is True + assert response.http_request.headers.get("X-Stainless-Lang") == "python" + credential_provider = await response.parse() + assert_matches_type(CredentialProvider, credential_provider, path=["response"]) + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + async def test_streaming_response_update(self, async_client: AsyncKernel) -> None: + async with async_client.credential_providers.with_streaming_response.update( + id="id", + ) as response: + assert not response.is_closed + assert response.http_request.headers.get("X-Stainless-Lang") == "python" + + credential_provider = await response.parse() + assert_matches_type(CredentialProvider, credential_provider, path=["response"]) + + assert cast(Any, response.is_closed) is True + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + async def test_path_params_update(self, async_client: AsyncKernel) -> None: + with pytest.raises(ValueError, match=r"Expected a non-empty value for `id` but received ''"): + await async_client.credential_providers.with_raw_response.update( + id="", + ) + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + async def test_method_list(self, async_client: AsyncKernel) -> None: + credential_provider = await async_client.credential_providers.list() + assert_matches_type(AsyncOffsetPagination[CredentialProvider], credential_provider, path=["response"]) + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + async def test_method_list_with_all_params(self, async_client: AsyncKernel) -> None: + credential_provider = await async_client.credential_providers.list( + limit=1, + offset=0, + query="query", + ) + assert_matches_type(AsyncOffsetPagination[CredentialProvider], credential_provider, path=["response"]) + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + async def test_raw_response_list(self, async_client: AsyncKernel) -> None: + response = await async_client.credential_providers.with_raw_response.list() + + assert response.is_closed is True + assert response.http_request.headers.get("X-Stainless-Lang") == "python" + credential_provider = await response.parse() + assert_matches_type(AsyncOffsetPagination[CredentialProvider], credential_provider, path=["response"]) + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + async def test_streaming_response_list(self, async_client: AsyncKernel) -> None: + async with async_client.credential_providers.with_streaming_response.list() as response: + assert not response.is_closed + assert response.http_request.headers.get("X-Stainless-Lang") == "python" + + credential_provider = await response.parse() + assert_matches_type(AsyncOffsetPagination[CredentialProvider], credential_provider, path=["response"]) + + assert cast(Any, response.is_closed) is True + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + async def test_method_delete(self, async_client: AsyncKernel) -> None: + credential_provider = await async_client.credential_providers.delete( + "id", + ) + assert credential_provider is None + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + async def test_raw_response_delete(self, async_client: AsyncKernel) -> None: + response = await async_client.credential_providers.with_raw_response.delete( + "id", + ) + + assert response.is_closed is True + assert response.http_request.headers.get("X-Stainless-Lang") == "python" + credential_provider = await response.parse() + assert credential_provider is None + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + async def test_streaming_response_delete(self, async_client: AsyncKernel) -> None: + async with async_client.credential_providers.with_streaming_response.delete( + "id", + ) as response: + assert not response.is_closed + assert response.http_request.headers.get("X-Stainless-Lang") == "python" + + credential_provider = await response.parse() + assert credential_provider is None + + assert cast(Any, response.is_closed) is True + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + async def test_path_params_delete(self, async_client: AsyncKernel) -> None: + with pytest.raises(ValueError, match=r"Expected a non-empty value for `id` but received ''"): + await async_client.credential_providers.with_raw_response.delete( + "", + ) + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + async def test_method_list_items(self, async_client: AsyncKernel) -> None: + credential_provider = await async_client.credential_providers.list_items( + "id", + ) + assert_matches_type(CredentialProviderListItemsResponse, credential_provider, path=["response"]) + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + async def test_raw_response_list_items(self, async_client: AsyncKernel) -> None: + response = await async_client.credential_providers.with_raw_response.list_items( + "id", + ) + + assert response.is_closed is True + assert response.http_request.headers.get("X-Stainless-Lang") == "python" + credential_provider = await response.parse() + assert_matches_type(CredentialProviderListItemsResponse, credential_provider, path=["response"]) + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + async def test_streaming_response_list_items(self, async_client: AsyncKernel) -> None: + async with async_client.credential_providers.with_streaming_response.list_items( + "id", + ) as response: + assert not response.is_closed + assert response.http_request.headers.get("X-Stainless-Lang") == "python" + + credential_provider = await response.parse() + assert_matches_type(CredentialProviderListItemsResponse, credential_provider, path=["response"]) + + assert cast(Any, response.is_closed) is True + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + async def test_path_params_list_items(self, async_client: AsyncKernel) -> None: + with pytest.raises(ValueError, match=r"Expected a non-empty value for `id` but received ''"): + await async_client.credential_providers.with_raw_response.list_items( + "", + ) + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + async def test_method_test(self, async_client: AsyncKernel) -> None: + credential_provider = await async_client.credential_providers.test( + "id", + ) + assert_matches_type(CredentialProviderTestResult, credential_provider, path=["response"]) + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + async def test_raw_response_test(self, async_client: AsyncKernel) -> None: + response = await async_client.credential_providers.with_raw_response.test( + "id", + ) + + assert response.is_closed is True + assert response.http_request.headers.get("X-Stainless-Lang") == "python" + credential_provider = await response.parse() + assert_matches_type(CredentialProviderTestResult, credential_provider, path=["response"]) + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + async def test_streaming_response_test(self, async_client: AsyncKernel) -> None: + async with async_client.credential_providers.with_streaming_response.test( + "id", + ) as response: + assert not response.is_closed + assert response.http_request.headers.get("X-Stainless-Lang") == "python" + + credential_provider = await response.parse() + assert_matches_type(CredentialProviderTestResult, credential_provider, path=["response"]) + + assert cast(Any, response.is_closed) is True + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + async def test_path_params_test(self, async_client: AsyncKernel) -> None: + with pytest.raises(ValueError, match=r"Expected a non-empty value for `id` but received ''"): + await async_client.credential_providers.with_raw_response.test( + "", + ) diff --git a/tests/api_resources/test_credentials.py b/tests/api_resources/test_credentials.py new file mode 100644 index 00000000..9fddf1c0 --- /dev/null +++ b/tests/api_resources/test_credentials.py @@ -0,0 +1,602 @@ +# 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 kernel import Kernel, AsyncKernel +from tests.utils import assert_matches_type +from kernel.types import ( + Credential, + CredentialTotpCodeResponse, +) +from kernel.pagination import SyncOffsetPagination, AsyncOffsetPagination + +base_url = os.environ.get("TEST_API_BASE_URL", "http://127.0.0.1:4010") + + +class TestCredentials: + parametrize = pytest.mark.parametrize("client", [False, True], indirect=True, ids=["loose", "strict"]) + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + def test_method_create(self, client: Kernel) -> None: + credential = client.credentials.create( + domain="netflix.com", + name="my-netflix-login", + values={ + "username": "user@example.com", + "password": "mysecretpassword", + }, + ) + assert_matches_type(Credential, credential, path=["response"]) + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + def test_method_create_with_all_params(self, client: Kernel) -> None: + credential = client.credentials.create( + domain="netflix.com", + name="my-netflix-login", + values={ + "username": "user@example.com", + "password": "mysecretpassword", + }, + sso_provider="google", + totp_secret="JBSWY3DPEHPK3PXP", + ) + assert_matches_type(Credential, credential, path=["response"]) + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + def test_raw_response_create(self, client: Kernel) -> None: + response = client.credentials.with_raw_response.create( + domain="netflix.com", + name="my-netflix-login", + values={ + "username": "user@example.com", + "password": "mysecretpassword", + }, + ) + + assert response.is_closed is True + assert response.http_request.headers.get("X-Stainless-Lang") == "python" + credential = response.parse() + assert_matches_type(Credential, credential, path=["response"]) + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + def test_streaming_response_create(self, client: Kernel) -> None: + with client.credentials.with_streaming_response.create( + domain="netflix.com", + name="my-netflix-login", + values={ + "username": "user@example.com", + "password": "mysecretpassword", + }, + ) as response: + assert not response.is_closed + assert response.http_request.headers.get("X-Stainless-Lang") == "python" + + credential = response.parse() + assert_matches_type(Credential, credential, path=["response"]) + + assert cast(Any, response.is_closed) is True + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + def test_method_retrieve(self, client: Kernel) -> None: + credential = client.credentials.retrieve( + "id_or_name", + ) + assert_matches_type(Credential, credential, path=["response"]) + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + def test_raw_response_retrieve(self, client: Kernel) -> None: + response = client.credentials.with_raw_response.retrieve( + "id_or_name", + ) + + assert response.is_closed is True + assert response.http_request.headers.get("X-Stainless-Lang") == "python" + credential = response.parse() + assert_matches_type(Credential, credential, path=["response"]) + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + def test_streaming_response_retrieve(self, client: Kernel) -> None: + with client.credentials.with_streaming_response.retrieve( + "id_or_name", + ) as response: + assert not response.is_closed + assert response.http_request.headers.get("X-Stainless-Lang") == "python" + + credential = response.parse() + assert_matches_type(Credential, credential, path=["response"]) + + assert cast(Any, response.is_closed) is True + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + def test_path_params_retrieve(self, client: Kernel) -> None: + with pytest.raises(ValueError, match=r"Expected a non-empty value for `id_or_name` but received ''"): + client.credentials.with_raw_response.retrieve( + "", + ) + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + def test_method_update(self, client: Kernel) -> None: + credential = client.credentials.update( + id_or_name="id_or_name", + ) + assert_matches_type(Credential, credential, path=["response"]) + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + def test_method_update_with_all_params(self, client: Kernel) -> None: + credential = client.credentials.update( + id_or_name="id_or_name", + name="my-updated-login", + remove_value_keys=["old_field"], + sso_provider="google", + totp_secret="JBSWY3DPEHPK3PXP", + values={ + "username": "user@example.com", + "password": "newpassword", + }, + ) + assert_matches_type(Credential, credential, path=["response"]) + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + def test_raw_response_update(self, client: Kernel) -> None: + response = client.credentials.with_raw_response.update( + id_or_name="id_or_name", + ) + + assert response.is_closed is True + assert response.http_request.headers.get("X-Stainless-Lang") == "python" + credential = response.parse() + assert_matches_type(Credential, credential, path=["response"]) + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + def test_streaming_response_update(self, client: Kernel) -> None: + with client.credentials.with_streaming_response.update( + id_or_name="id_or_name", + ) as response: + assert not response.is_closed + assert response.http_request.headers.get("X-Stainless-Lang") == "python" + + credential = response.parse() + assert_matches_type(Credential, credential, path=["response"]) + + assert cast(Any, response.is_closed) is True + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + def test_path_params_update(self, client: Kernel) -> None: + with pytest.raises(ValueError, match=r"Expected a non-empty value for `id_or_name` but received ''"): + client.credentials.with_raw_response.update( + id_or_name="", + ) + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + def test_method_list(self, client: Kernel) -> None: + credential = client.credentials.list() + assert_matches_type(SyncOffsetPagination[Credential], credential, path=["response"]) + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + def test_method_list_with_all_params(self, client: Kernel) -> None: + credential = client.credentials.list( + domain="domain", + limit=100, + offset=0, + query="query", + ) + assert_matches_type(SyncOffsetPagination[Credential], credential, path=["response"]) + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + def test_raw_response_list(self, client: Kernel) -> None: + response = client.credentials.with_raw_response.list() + + assert response.is_closed is True + assert response.http_request.headers.get("X-Stainless-Lang") == "python" + credential = response.parse() + assert_matches_type(SyncOffsetPagination[Credential], credential, path=["response"]) + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + def test_streaming_response_list(self, client: Kernel) -> None: + with client.credentials.with_streaming_response.list() as response: + assert not response.is_closed + assert response.http_request.headers.get("X-Stainless-Lang") == "python" + + credential = response.parse() + assert_matches_type(SyncOffsetPagination[Credential], credential, path=["response"]) + + assert cast(Any, response.is_closed) is True + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + def test_method_delete(self, client: Kernel) -> None: + credential = client.credentials.delete( + "id_or_name", + ) + assert credential is None + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + def test_raw_response_delete(self, client: Kernel) -> None: + response = client.credentials.with_raw_response.delete( + "id_or_name", + ) + + assert response.is_closed is True + assert response.http_request.headers.get("X-Stainless-Lang") == "python" + credential = response.parse() + assert credential is None + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + def test_streaming_response_delete(self, client: Kernel) -> None: + with client.credentials.with_streaming_response.delete( + "id_or_name", + ) as response: + assert not response.is_closed + assert response.http_request.headers.get("X-Stainless-Lang") == "python" + + credential = response.parse() + assert credential is None + + assert cast(Any, response.is_closed) is True + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + def test_path_params_delete(self, client: Kernel) -> None: + with pytest.raises(ValueError, match=r"Expected a non-empty value for `id_or_name` but received ''"): + client.credentials.with_raw_response.delete( + "", + ) + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + def test_method_totp_code(self, client: Kernel) -> None: + credential = client.credentials.totp_code( + "id_or_name", + ) + assert_matches_type(CredentialTotpCodeResponse, credential, path=["response"]) + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + def test_raw_response_totp_code(self, client: Kernel) -> None: + response = client.credentials.with_raw_response.totp_code( + "id_or_name", + ) + + assert response.is_closed is True + assert response.http_request.headers.get("X-Stainless-Lang") == "python" + credential = response.parse() + assert_matches_type(CredentialTotpCodeResponse, credential, path=["response"]) + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + def test_streaming_response_totp_code(self, client: Kernel) -> None: + with client.credentials.with_streaming_response.totp_code( + "id_or_name", + ) as response: + assert not response.is_closed + assert response.http_request.headers.get("X-Stainless-Lang") == "python" + + credential = response.parse() + assert_matches_type(CredentialTotpCodeResponse, credential, path=["response"]) + + assert cast(Any, response.is_closed) is True + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + def test_path_params_totp_code(self, client: Kernel) -> None: + with pytest.raises(ValueError, match=r"Expected a non-empty value for `id_or_name` but received ''"): + client.credentials.with_raw_response.totp_code( + "", + ) + + +class TestAsyncCredentials: + parametrize = pytest.mark.parametrize( + "async_client", [False, True, {"http_client": "aiohttp"}], indirect=True, ids=["loose", "strict", "aiohttp"] + ) + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + async def test_method_create(self, async_client: AsyncKernel) -> None: + credential = await async_client.credentials.create( + domain="netflix.com", + name="my-netflix-login", + values={ + "username": "user@example.com", + "password": "mysecretpassword", + }, + ) + assert_matches_type(Credential, credential, path=["response"]) + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + async def test_method_create_with_all_params(self, async_client: AsyncKernel) -> None: + credential = await async_client.credentials.create( + domain="netflix.com", + name="my-netflix-login", + values={ + "username": "user@example.com", + "password": "mysecretpassword", + }, + sso_provider="google", + totp_secret="JBSWY3DPEHPK3PXP", + ) + assert_matches_type(Credential, credential, path=["response"]) + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + async def test_raw_response_create(self, async_client: AsyncKernel) -> None: + response = await async_client.credentials.with_raw_response.create( + domain="netflix.com", + name="my-netflix-login", + values={ + "username": "user@example.com", + "password": "mysecretpassword", + }, + ) + + assert response.is_closed is True + assert response.http_request.headers.get("X-Stainless-Lang") == "python" + credential = await response.parse() + assert_matches_type(Credential, credential, path=["response"]) + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + async def test_streaming_response_create(self, async_client: AsyncKernel) -> None: + async with async_client.credentials.with_streaming_response.create( + domain="netflix.com", + name="my-netflix-login", + values={ + "username": "user@example.com", + "password": "mysecretpassword", + }, + ) as response: + assert not response.is_closed + assert response.http_request.headers.get("X-Stainless-Lang") == "python" + + credential = await response.parse() + assert_matches_type(Credential, credential, path=["response"]) + + assert cast(Any, response.is_closed) is True + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + async def test_method_retrieve(self, async_client: AsyncKernel) -> None: + credential = await async_client.credentials.retrieve( + "id_or_name", + ) + assert_matches_type(Credential, credential, path=["response"]) + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + async def test_raw_response_retrieve(self, async_client: AsyncKernel) -> None: + response = await async_client.credentials.with_raw_response.retrieve( + "id_or_name", + ) + + assert response.is_closed is True + assert response.http_request.headers.get("X-Stainless-Lang") == "python" + credential = await response.parse() + assert_matches_type(Credential, credential, path=["response"]) + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + async def test_streaming_response_retrieve(self, async_client: AsyncKernel) -> None: + async with async_client.credentials.with_streaming_response.retrieve( + "id_or_name", + ) as response: + assert not response.is_closed + assert response.http_request.headers.get("X-Stainless-Lang") == "python" + + credential = await response.parse() + assert_matches_type(Credential, credential, path=["response"]) + + assert cast(Any, response.is_closed) is True + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + async def test_path_params_retrieve(self, async_client: AsyncKernel) -> None: + with pytest.raises(ValueError, match=r"Expected a non-empty value for `id_or_name` but received ''"): + await async_client.credentials.with_raw_response.retrieve( + "", + ) + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + async def test_method_update(self, async_client: AsyncKernel) -> None: + credential = await async_client.credentials.update( + id_or_name="id_or_name", + ) + assert_matches_type(Credential, credential, path=["response"]) + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + async def test_method_update_with_all_params(self, async_client: AsyncKernel) -> None: + credential = await async_client.credentials.update( + id_or_name="id_or_name", + name="my-updated-login", + remove_value_keys=["old_field"], + sso_provider="google", + totp_secret="JBSWY3DPEHPK3PXP", + values={ + "username": "user@example.com", + "password": "newpassword", + }, + ) + assert_matches_type(Credential, credential, path=["response"]) + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + async def test_raw_response_update(self, async_client: AsyncKernel) -> None: + response = await async_client.credentials.with_raw_response.update( + id_or_name="id_or_name", + ) + + assert response.is_closed is True + assert response.http_request.headers.get("X-Stainless-Lang") == "python" + credential = await response.parse() + assert_matches_type(Credential, credential, path=["response"]) + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + async def test_streaming_response_update(self, async_client: AsyncKernel) -> None: + async with async_client.credentials.with_streaming_response.update( + id_or_name="id_or_name", + ) as response: + assert not response.is_closed + assert response.http_request.headers.get("X-Stainless-Lang") == "python" + + credential = await response.parse() + assert_matches_type(Credential, credential, path=["response"]) + + assert cast(Any, response.is_closed) is True + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + async def test_path_params_update(self, async_client: AsyncKernel) -> None: + with pytest.raises(ValueError, match=r"Expected a non-empty value for `id_or_name` but received ''"): + await async_client.credentials.with_raw_response.update( + id_or_name="", + ) + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + async def test_method_list(self, async_client: AsyncKernel) -> None: + credential = await async_client.credentials.list() + assert_matches_type(AsyncOffsetPagination[Credential], credential, path=["response"]) + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + async def test_method_list_with_all_params(self, async_client: AsyncKernel) -> None: + credential = await async_client.credentials.list( + domain="domain", + limit=100, + offset=0, + query="query", + ) + assert_matches_type(AsyncOffsetPagination[Credential], credential, path=["response"]) + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + async def test_raw_response_list(self, async_client: AsyncKernel) -> None: + response = await async_client.credentials.with_raw_response.list() + + assert response.is_closed is True + assert response.http_request.headers.get("X-Stainless-Lang") == "python" + credential = await response.parse() + assert_matches_type(AsyncOffsetPagination[Credential], credential, path=["response"]) + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + async def test_streaming_response_list(self, async_client: AsyncKernel) -> None: + async with async_client.credentials.with_streaming_response.list() as response: + assert not response.is_closed + assert response.http_request.headers.get("X-Stainless-Lang") == "python" + + credential = await response.parse() + assert_matches_type(AsyncOffsetPagination[Credential], credential, path=["response"]) + + assert cast(Any, response.is_closed) is True + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + async def test_method_delete(self, async_client: AsyncKernel) -> None: + credential = await async_client.credentials.delete( + "id_or_name", + ) + assert credential is None + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + async def test_raw_response_delete(self, async_client: AsyncKernel) -> None: + response = await async_client.credentials.with_raw_response.delete( + "id_or_name", + ) + + assert response.is_closed is True + assert response.http_request.headers.get("X-Stainless-Lang") == "python" + credential = await response.parse() + assert credential is None + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + async def test_streaming_response_delete(self, async_client: AsyncKernel) -> None: + async with async_client.credentials.with_streaming_response.delete( + "id_or_name", + ) as response: + assert not response.is_closed + assert response.http_request.headers.get("X-Stainless-Lang") == "python" + + credential = await response.parse() + assert credential is None + + assert cast(Any, response.is_closed) is True + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + async def test_path_params_delete(self, async_client: AsyncKernel) -> None: + with pytest.raises(ValueError, match=r"Expected a non-empty value for `id_or_name` but received ''"): + await async_client.credentials.with_raw_response.delete( + "", + ) + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + async def test_method_totp_code(self, async_client: AsyncKernel) -> None: + credential = await async_client.credentials.totp_code( + "id_or_name", + ) + assert_matches_type(CredentialTotpCodeResponse, credential, path=["response"]) + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + async def test_raw_response_totp_code(self, async_client: AsyncKernel) -> None: + response = await async_client.credentials.with_raw_response.totp_code( + "id_or_name", + ) + + assert response.is_closed is True + assert response.http_request.headers.get("X-Stainless-Lang") == "python" + credential = await response.parse() + assert_matches_type(CredentialTotpCodeResponse, credential, path=["response"]) + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + async def test_streaming_response_totp_code(self, async_client: AsyncKernel) -> None: + async with async_client.credentials.with_streaming_response.totp_code( + "id_or_name", + ) as response: + assert not response.is_closed + assert response.http_request.headers.get("X-Stainless-Lang") == "python" + + credential = await response.parse() + assert_matches_type(CredentialTotpCodeResponse, credential, path=["response"]) + + assert cast(Any, response.is_closed) is True + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + async def test_path_params_totp_code(self, async_client: AsyncKernel) -> None: + with pytest.raises(ValueError, match=r"Expected a non-empty value for `id_or_name` but received ''"): + await async_client.credentials.with_raw_response.totp_code( + "", + ) diff --git a/tests/api_resources/test_deployments.py b/tests/api_resources/test_deployments.py new file mode 100644 index 00000000..4f619015 --- /dev/null +++ b/tests/api_resources/test_deployments.py @@ -0,0 +1,481 @@ +# 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 kernel import Kernel, AsyncKernel +from tests.utils import assert_matches_type +from kernel.types import ( + DeploymentListResponse, + DeploymentCreateResponse, + DeploymentRetrieveResponse, +) +from kernel.pagination import SyncOffsetPagination, AsyncOffsetPagination + +base_url = os.environ.get("TEST_API_BASE_URL", "http://127.0.0.1:4010") + + +class TestDeployments: + parametrize = pytest.mark.parametrize("client", [False, True], indirect=True, ids=["loose", "strict"]) + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + def test_method_create(self, client: Kernel) -> None: + deployment = client.deployments.create() + assert_matches_type(DeploymentCreateResponse, deployment, path=["response"]) + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + def test_method_create_with_all_params(self, client: Kernel) -> None: + deployment = client.deployments.create( + entrypoint_rel_path="src/app.py", + env_vars={"FOO": "bar"}, + file=b"Example data", + force=False, + region="aws.us-east-1a", + source={ + "entrypoint": "src/index.ts", + "ref": "main", + "type": "github", + "url": "https://github.com/org/repo", + "auth": { + "token": "ghs_***", + "method": "github_token", + }, + "path": "apps/api", + }, + version="1.0.0", + ) + assert_matches_type(DeploymentCreateResponse, deployment, path=["response"]) + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + def test_raw_response_create(self, client: Kernel) -> None: + response = client.deployments.with_raw_response.create() + + assert response.is_closed is True + assert response.http_request.headers.get("X-Stainless-Lang") == "python" + deployment = response.parse() + assert_matches_type(DeploymentCreateResponse, deployment, path=["response"]) + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + def test_streaming_response_create(self, client: Kernel) -> None: + with client.deployments.with_streaming_response.create() as response: + assert not response.is_closed + assert response.http_request.headers.get("X-Stainless-Lang") == "python" + + deployment = response.parse() + assert_matches_type(DeploymentCreateResponse, deployment, path=["response"]) + + assert cast(Any, response.is_closed) is True + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + def test_method_retrieve(self, client: Kernel) -> None: + deployment = client.deployments.retrieve( + "id", + ) + assert_matches_type(DeploymentRetrieveResponse, deployment, path=["response"]) + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + def test_raw_response_retrieve(self, client: Kernel) -> None: + response = client.deployments.with_raw_response.retrieve( + "id", + ) + + assert response.is_closed is True + assert response.http_request.headers.get("X-Stainless-Lang") == "python" + deployment = response.parse() + assert_matches_type(DeploymentRetrieveResponse, deployment, path=["response"]) + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + def test_streaming_response_retrieve(self, client: Kernel) -> None: + with client.deployments.with_streaming_response.retrieve( + "id", + ) as response: + assert not response.is_closed + assert response.http_request.headers.get("X-Stainless-Lang") == "python" + + deployment = response.parse() + assert_matches_type(DeploymentRetrieveResponse, deployment, path=["response"]) + + assert cast(Any, response.is_closed) is True + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + def test_path_params_retrieve(self, client: Kernel) -> None: + with pytest.raises(ValueError, match=r"Expected a non-empty value for `id` but received ''"): + client.deployments.with_raw_response.retrieve( + "", + ) + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + def test_method_list(self, client: Kernel) -> None: + deployment = client.deployments.list() + assert_matches_type(SyncOffsetPagination[DeploymentListResponse], deployment, path=["response"]) + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + def test_method_list_with_all_params(self, client: Kernel) -> None: + deployment = client.deployments.list( + app_name="app_name", + app_version="app_version", + limit=1, + offset=0, + query="query", + ) + assert_matches_type(SyncOffsetPagination[DeploymentListResponse], deployment, path=["response"]) + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + def test_raw_response_list(self, client: Kernel) -> None: + response = client.deployments.with_raw_response.list() + + assert response.is_closed is True + assert response.http_request.headers.get("X-Stainless-Lang") == "python" + deployment = response.parse() + assert_matches_type(SyncOffsetPagination[DeploymentListResponse], deployment, path=["response"]) + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + def test_streaming_response_list(self, client: Kernel) -> None: + with client.deployments.with_streaming_response.list() as response: + assert not response.is_closed + assert response.http_request.headers.get("X-Stainless-Lang") == "python" + + deployment = response.parse() + assert_matches_type(SyncOffsetPagination[DeploymentListResponse], deployment, path=["response"]) + + assert cast(Any, response.is_closed) is True + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + def test_method_delete(self, client: Kernel) -> None: + deployment = client.deployments.delete( + "id", + ) + assert deployment is None + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + def test_raw_response_delete(self, client: Kernel) -> None: + response = client.deployments.with_raw_response.delete( + "id", + ) + + assert response.is_closed is True + assert response.http_request.headers.get("X-Stainless-Lang") == "python" + deployment = response.parse() + assert deployment is None + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + def test_streaming_response_delete(self, client: Kernel) -> None: + with client.deployments.with_streaming_response.delete( + "id", + ) as response: + assert not response.is_closed + assert response.http_request.headers.get("X-Stainless-Lang") == "python" + + deployment = response.parse() + assert deployment is None + + assert cast(Any, response.is_closed) is True + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + def test_path_params_delete(self, client: Kernel) -> None: + with pytest.raises(ValueError, match=r"Expected a non-empty value for `id` but received ''"): + client.deployments.with_raw_response.delete( + "", + ) + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + def test_method_follow(self, client: Kernel) -> None: + deployment_stream = client.deployments.follow( + id="id", + ) + deployment_stream.response.close() + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + def test_method_follow_with_all_params(self, client: Kernel) -> None: + deployment_stream = client.deployments.follow( + id="id", + since="2025-06-20T12:00:00Z", + ) + deployment_stream.response.close() + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + def test_raw_response_follow(self, client: Kernel) -> None: + response = client.deployments.with_raw_response.follow( + id="id", + ) + + assert response.http_request.headers.get("X-Stainless-Lang") == "python" + stream = response.parse() + stream.close() + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + def test_streaming_response_follow(self, client: Kernel) -> None: + with client.deployments.with_streaming_response.follow( + id="id", + ) 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 + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + def test_path_params_follow(self, client: Kernel) -> None: + with pytest.raises(ValueError, match=r"Expected a non-empty value for `id` but received ''"): + client.deployments.with_raw_response.follow( + id="", + ) + + +class TestAsyncDeployments: + parametrize = pytest.mark.parametrize( + "async_client", [False, True, {"http_client": "aiohttp"}], indirect=True, ids=["loose", "strict", "aiohttp"] + ) + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + async def test_method_create(self, async_client: AsyncKernel) -> None: + deployment = await async_client.deployments.create() + assert_matches_type(DeploymentCreateResponse, deployment, path=["response"]) + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + async def test_method_create_with_all_params(self, async_client: AsyncKernel) -> None: + deployment = await async_client.deployments.create( + entrypoint_rel_path="src/app.py", + env_vars={"FOO": "bar"}, + file=b"Example data", + force=False, + region="aws.us-east-1a", + source={ + "entrypoint": "src/index.ts", + "ref": "main", + "type": "github", + "url": "https://github.com/org/repo", + "auth": { + "token": "ghs_***", + "method": "github_token", + }, + "path": "apps/api", + }, + version="1.0.0", + ) + assert_matches_type(DeploymentCreateResponse, deployment, path=["response"]) + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + async def test_raw_response_create(self, async_client: AsyncKernel) -> None: + response = await async_client.deployments.with_raw_response.create() + + assert response.is_closed is True + assert response.http_request.headers.get("X-Stainless-Lang") == "python" + deployment = await response.parse() + assert_matches_type(DeploymentCreateResponse, deployment, path=["response"]) + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + async def test_streaming_response_create(self, async_client: AsyncKernel) -> None: + async with async_client.deployments.with_streaming_response.create() as response: + assert not response.is_closed + assert response.http_request.headers.get("X-Stainless-Lang") == "python" + + deployment = await response.parse() + assert_matches_type(DeploymentCreateResponse, deployment, path=["response"]) + + assert cast(Any, response.is_closed) is True + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + async def test_method_retrieve(self, async_client: AsyncKernel) -> None: + deployment = await async_client.deployments.retrieve( + "id", + ) + assert_matches_type(DeploymentRetrieveResponse, deployment, path=["response"]) + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + async def test_raw_response_retrieve(self, async_client: AsyncKernel) -> None: + response = await async_client.deployments.with_raw_response.retrieve( + "id", + ) + + assert response.is_closed is True + assert response.http_request.headers.get("X-Stainless-Lang") == "python" + deployment = await response.parse() + assert_matches_type(DeploymentRetrieveResponse, deployment, path=["response"]) + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + async def test_streaming_response_retrieve(self, async_client: AsyncKernel) -> None: + async with async_client.deployments.with_streaming_response.retrieve( + "id", + ) as response: + assert not response.is_closed + assert response.http_request.headers.get("X-Stainless-Lang") == "python" + + deployment = await response.parse() + assert_matches_type(DeploymentRetrieveResponse, deployment, path=["response"]) + + assert cast(Any, response.is_closed) is True + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + async def test_path_params_retrieve(self, async_client: AsyncKernel) -> None: + with pytest.raises(ValueError, match=r"Expected a non-empty value for `id` but received ''"): + await async_client.deployments.with_raw_response.retrieve( + "", + ) + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + async def test_method_list(self, async_client: AsyncKernel) -> None: + deployment = await async_client.deployments.list() + assert_matches_type(AsyncOffsetPagination[DeploymentListResponse], deployment, path=["response"]) + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + async def test_method_list_with_all_params(self, async_client: AsyncKernel) -> None: + deployment = await async_client.deployments.list( + app_name="app_name", + app_version="app_version", + limit=1, + offset=0, + query="query", + ) + assert_matches_type(AsyncOffsetPagination[DeploymentListResponse], deployment, path=["response"]) + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + async def test_raw_response_list(self, async_client: AsyncKernel) -> None: + response = await async_client.deployments.with_raw_response.list() + + assert response.is_closed is True + assert response.http_request.headers.get("X-Stainless-Lang") == "python" + deployment = await response.parse() + assert_matches_type(AsyncOffsetPagination[DeploymentListResponse], deployment, path=["response"]) + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + async def test_streaming_response_list(self, async_client: AsyncKernel) -> None: + async with async_client.deployments.with_streaming_response.list() as response: + assert not response.is_closed + assert response.http_request.headers.get("X-Stainless-Lang") == "python" + + deployment = await response.parse() + assert_matches_type(AsyncOffsetPagination[DeploymentListResponse], deployment, path=["response"]) + + assert cast(Any, response.is_closed) is True + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + async def test_method_delete(self, async_client: AsyncKernel) -> None: + deployment = await async_client.deployments.delete( + "id", + ) + assert deployment is None + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + async def test_raw_response_delete(self, async_client: AsyncKernel) -> None: + response = await async_client.deployments.with_raw_response.delete( + "id", + ) + + assert response.is_closed is True + assert response.http_request.headers.get("X-Stainless-Lang") == "python" + deployment = await response.parse() + assert deployment is None + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + async def test_streaming_response_delete(self, async_client: AsyncKernel) -> None: + async with async_client.deployments.with_streaming_response.delete( + "id", + ) as response: + assert not response.is_closed + assert response.http_request.headers.get("X-Stainless-Lang") == "python" + + deployment = await response.parse() + assert deployment is None + + assert cast(Any, response.is_closed) is True + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + async def test_path_params_delete(self, async_client: AsyncKernel) -> None: + with pytest.raises(ValueError, match=r"Expected a non-empty value for `id` but received ''"): + await async_client.deployments.with_raw_response.delete( + "", + ) + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + async def test_method_follow(self, async_client: AsyncKernel) -> None: + deployment_stream = await async_client.deployments.follow( + id="id", + ) + await deployment_stream.response.aclose() + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + async def test_method_follow_with_all_params(self, async_client: AsyncKernel) -> None: + deployment_stream = await async_client.deployments.follow( + id="id", + since="2025-06-20T12:00:00Z", + ) + await deployment_stream.response.aclose() + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + async def test_raw_response_follow(self, async_client: AsyncKernel) -> None: + response = await async_client.deployments.with_raw_response.follow( + id="id", + ) + + assert response.http_request.headers.get("X-Stainless-Lang") == "python" + stream = await response.parse() + await stream.close() + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + async def test_streaming_response_follow(self, async_client: AsyncKernel) -> None: + async with async_client.deployments.with_streaming_response.follow( + id="id", + ) 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 + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + async def test_path_params_follow(self, async_client: AsyncKernel) -> None: + with pytest.raises(ValueError, match=r"Expected a non-empty value for `id` but received ''"): + await async_client.deployments.with_raw_response.follow( + id="", + ) diff --git a/tests/api_resources/test_extensions.py b/tests/api_resources/test_extensions.py new file mode 100644 index 00000000..ace8ec6b --- /dev/null +++ b/tests/api_resources/test_extensions.py @@ -0,0 +1,585 @@ +# 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 httpx +import pytest +from respx import MockRouter + +from kernel import Kernel, AsyncKernel +from tests.utils import assert_matches_type +from kernel.types import ( + ExtensionGetResponse, + ExtensionListResponse, + ExtensionUploadResponse, +) +from kernel._response import ( + BinaryAPIResponse, + AsyncBinaryAPIResponse, + StreamedBinaryAPIResponse, + AsyncStreamedBinaryAPIResponse, +) +from kernel.pagination import SyncOffsetPagination, AsyncOffsetPagination + +base_url = os.environ.get("TEST_API_BASE_URL", "http://127.0.0.1:4010") + + +class TestExtensions: + parametrize = pytest.mark.parametrize("client", [False, True], indirect=True, ids=["loose", "strict"]) + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + def test_method_list(self, client: Kernel) -> None: + extension = client.extensions.list() + assert_matches_type(SyncOffsetPagination[ExtensionListResponse], extension, path=["response"]) + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + def test_method_list_with_all_params(self, client: Kernel) -> None: + extension = client.extensions.list( + limit=1, + name="name", + offset=0, + query="query", + ) + assert_matches_type(SyncOffsetPagination[ExtensionListResponse], extension, path=["response"]) + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + def test_raw_response_list(self, client: Kernel) -> None: + response = client.extensions.with_raw_response.list() + + assert response.is_closed is True + assert response.http_request.headers.get("X-Stainless-Lang") == "python" + extension = response.parse() + assert_matches_type(SyncOffsetPagination[ExtensionListResponse], extension, path=["response"]) + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + def test_streaming_response_list(self, client: Kernel) -> None: + with client.extensions.with_streaming_response.list() as response: + assert not response.is_closed + assert response.http_request.headers.get("X-Stainless-Lang") == "python" + + extension = response.parse() + assert_matches_type(SyncOffsetPagination[ExtensionListResponse], extension, path=["response"]) + + assert cast(Any, response.is_closed) is True + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + def test_method_delete(self, client: Kernel) -> None: + extension = client.extensions.delete( + "id_or_name", + ) + assert extension is None + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + def test_raw_response_delete(self, client: Kernel) -> None: + response = client.extensions.with_raw_response.delete( + "id_or_name", + ) + + assert response.is_closed is True + assert response.http_request.headers.get("X-Stainless-Lang") == "python" + extension = response.parse() + assert extension is None + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + def test_streaming_response_delete(self, client: Kernel) -> None: + with client.extensions.with_streaming_response.delete( + "id_or_name", + ) as response: + assert not response.is_closed + assert response.http_request.headers.get("X-Stainless-Lang") == "python" + + extension = response.parse() + assert extension is None + + assert cast(Any, response.is_closed) is True + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + def test_path_params_delete(self, client: Kernel) -> None: + with pytest.raises(ValueError, match=r"Expected a non-empty value for `id_or_name` but received ''"): + client.extensions.with_raw_response.delete( + "", + ) + + @parametrize + @pytest.mark.respx(base_url=base_url) + def test_method_download(self, client: Kernel, respx_mock: MockRouter) -> None: + respx_mock.get("/extensions/id_or_name").mock(return_value=httpx.Response(200, json={"foo": "bar"})) + extension = client.extensions.download( + "id_or_name", + ) + assert extension.is_closed + assert extension.json() == {"foo": "bar"} + assert cast(Any, extension.is_closed) is True + assert isinstance(extension, BinaryAPIResponse) + + @parametrize + @pytest.mark.respx(base_url=base_url) + def test_raw_response_download(self, client: Kernel, respx_mock: MockRouter) -> None: + respx_mock.get("/extensions/id_or_name").mock(return_value=httpx.Response(200, json={"foo": "bar"})) + + extension = client.extensions.with_raw_response.download( + "id_or_name", + ) + + assert extension.is_closed is True + assert extension.http_request.headers.get("X-Stainless-Lang") == "python" + assert extension.json() == {"foo": "bar"} + assert isinstance(extension, BinaryAPIResponse) + + @parametrize + @pytest.mark.respx(base_url=base_url) + def test_streaming_response_download(self, client: Kernel, respx_mock: MockRouter) -> None: + respx_mock.get("/extensions/id_or_name").mock(return_value=httpx.Response(200, json={"foo": "bar"})) + with client.extensions.with_streaming_response.download( + "id_or_name", + ) as extension: + assert not extension.is_closed + assert extension.http_request.headers.get("X-Stainless-Lang") == "python" + + assert extension.json() == {"foo": "bar"} + assert cast(Any, extension.is_closed) is True + assert isinstance(extension, StreamedBinaryAPIResponse) + + assert cast(Any, extension.is_closed) is True + + @parametrize + @pytest.mark.respx(base_url=base_url) + def test_path_params_download(self, client: Kernel) -> None: + with pytest.raises(ValueError, match=r"Expected a non-empty value for `id_or_name` but received ''"): + client.extensions.with_raw_response.download( + "", + ) + + @parametrize + @pytest.mark.respx(base_url=base_url) + def test_method_download_from_chrome_store(self, client: Kernel, respx_mock: MockRouter) -> None: + respx_mock.get("/extensions/from_chrome_store").mock(return_value=httpx.Response(200, json={"foo": "bar"})) + extension = client.extensions.download_from_chrome_store( + url="url", + ) + assert extension.is_closed + assert extension.json() == {"foo": "bar"} + assert cast(Any, extension.is_closed) is True + assert isinstance(extension, BinaryAPIResponse) + + @parametrize + @pytest.mark.respx(base_url=base_url) + def test_method_download_from_chrome_store_with_all_params(self, client: Kernel, respx_mock: MockRouter) -> None: + respx_mock.get("/extensions/from_chrome_store").mock(return_value=httpx.Response(200, json={"foo": "bar"})) + extension = client.extensions.download_from_chrome_store( + url="url", + os="win", + ) + assert extension.is_closed + assert extension.json() == {"foo": "bar"} + assert cast(Any, extension.is_closed) is True + assert isinstance(extension, BinaryAPIResponse) + + @parametrize + @pytest.mark.respx(base_url=base_url) + def test_raw_response_download_from_chrome_store(self, client: Kernel, respx_mock: MockRouter) -> None: + respx_mock.get("/extensions/from_chrome_store").mock(return_value=httpx.Response(200, json={"foo": "bar"})) + + extension = client.extensions.with_raw_response.download_from_chrome_store( + url="url", + ) + + assert extension.is_closed is True + assert extension.http_request.headers.get("X-Stainless-Lang") == "python" + assert extension.json() == {"foo": "bar"} + assert isinstance(extension, BinaryAPIResponse) + + @parametrize + @pytest.mark.respx(base_url=base_url) + def test_streaming_response_download_from_chrome_store(self, client: Kernel, respx_mock: MockRouter) -> None: + respx_mock.get("/extensions/from_chrome_store").mock(return_value=httpx.Response(200, json={"foo": "bar"})) + with client.extensions.with_streaming_response.download_from_chrome_store( + url="url", + ) as extension: + assert not extension.is_closed + assert extension.http_request.headers.get("X-Stainless-Lang") == "python" + + assert extension.json() == {"foo": "bar"} + assert cast(Any, extension.is_closed) is True + assert isinstance(extension, StreamedBinaryAPIResponse) + + assert cast(Any, extension.is_closed) is True + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + def test_method_get(self, client: Kernel) -> None: + extension = client.extensions.get( + "id_or_name", + ) + assert_matches_type(ExtensionGetResponse, extension, path=["response"]) + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + def test_raw_response_get(self, client: Kernel) -> None: + response = client.extensions.with_raw_response.get( + "id_or_name", + ) + + assert response.is_closed is True + assert response.http_request.headers.get("X-Stainless-Lang") == "python" + extension = response.parse() + assert_matches_type(ExtensionGetResponse, extension, path=["response"]) + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + def test_streaming_response_get(self, client: Kernel) -> None: + with client.extensions.with_streaming_response.get( + "id_or_name", + ) as response: + assert not response.is_closed + assert response.http_request.headers.get("X-Stainless-Lang") == "python" + + extension = response.parse() + assert_matches_type(ExtensionGetResponse, extension, path=["response"]) + + assert cast(Any, response.is_closed) is True + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + def test_path_params_get(self, client: Kernel) -> None: + with pytest.raises(ValueError, match=r"Expected a non-empty value for `id_or_name` but received ''"): + client.extensions.with_raw_response.get( + "", + ) + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + def test_method_upload(self, client: Kernel) -> None: + extension = client.extensions.upload( + file=b"Example data", + ) + assert_matches_type(ExtensionUploadResponse, extension, path=["response"]) + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + def test_method_upload_with_all_params(self, client: Kernel) -> None: + extension = client.extensions.upload( + file=b"Example data", + name="name", + ) + assert_matches_type(ExtensionUploadResponse, extension, path=["response"]) + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + def test_raw_response_upload(self, client: Kernel) -> None: + response = client.extensions.with_raw_response.upload( + file=b"Example data", + ) + + assert response.is_closed is True + assert response.http_request.headers.get("X-Stainless-Lang") == "python" + extension = response.parse() + assert_matches_type(ExtensionUploadResponse, extension, path=["response"]) + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + def test_streaming_response_upload(self, client: Kernel) -> None: + with client.extensions.with_streaming_response.upload( + file=b"Example data", + ) as response: + assert not response.is_closed + assert response.http_request.headers.get("X-Stainless-Lang") == "python" + + extension = response.parse() + assert_matches_type(ExtensionUploadResponse, extension, path=["response"]) + + assert cast(Any, response.is_closed) is True + + +class TestAsyncExtensions: + parametrize = pytest.mark.parametrize( + "async_client", [False, True, {"http_client": "aiohttp"}], indirect=True, ids=["loose", "strict", "aiohttp"] + ) + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + async def test_method_list(self, async_client: AsyncKernel) -> None: + extension = await async_client.extensions.list() + assert_matches_type(AsyncOffsetPagination[ExtensionListResponse], extension, path=["response"]) + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + async def test_method_list_with_all_params(self, async_client: AsyncKernel) -> None: + extension = await async_client.extensions.list( + limit=1, + name="name", + offset=0, + query="query", + ) + assert_matches_type(AsyncOffsetPagination[ExtensionListResponse], extension, path=["response"]) + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + async def test_raw_response_list(self, async_client: AsyncKernel) -> None: + response = await async_client.extensions.with_raw_response.list() + + assert response.is_closed is True + assert response.http_request.headers.get("X-Stainless-Lang") == "python" + extension = await response.parse() + assert_matches_type(AsyncOffsetPagination[ExtensionListResponse], extension, path=["response"]) + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + async def test_streaming_response_list(self, async_client: AsyncKernel) -> None: + async with async_client.extensions.with_streaming_response.list() as response: + assert not response.is_closed + assert response.http_request.headers.get("X-Stainless-Lang") == "python" + + extension = await response.parse() + assert_matches_type(AsyncOffsetPagination[ExtensionListResponse], extension, path=["response"]) + + assert cast(Any, response.is_closed) is True + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + async def test_method_delete(self, async_client: AsyncKernel) -> None: + extension = await async_client.extensions.delete( + "id_or_name", + ) + assert extension is None + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + async def test_raw_response_delete(self, async_client: AsyncKernel) -> None: + response = await async_client.extensions.with_raw_response.delete( + "id_or_name", + ) + + assert response.is_closed is True + assert response.http_request.headers.get("X-Stainless-Lang") == "python" + extension = await response.parse() + assert extension is None + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + async def test_streaming_response_delete(self, async_client: AsyncKernel) -> None: + async with async_client.extensions.with_streaming_response.delete( + "id_or_name", + ) as response: + assert not response.is_closed + assert response.http_request.headers.get("X-Stainless-Lang") == "python" + + extension = await response.parse() + assert extension is None + + assert cast(Any, response.is_closed) is True + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + async def test_path_params_delete(self, async_client: AsyncKernel) -> None: + with pytest.raises(ValueError, match=r"Expected a non-empty value for `id_or_name` but received ''"): + await async_client.extensions.with_raw_response.delete( + "", + ) + + @parametrize + @pytest.mark.respx(base_url=base_url) + async def test_method_download(self, async_client: AsyncKernel, respx_mock: MockRouter) -> None: + respx_mock.get("/extensions/id_or_name").mock(return_value=httpx.Response(200, json={"foo": "bar"})) + extension = await async_client.extensions.download( + "id_or_name", + ) + assert extension.is_closed + assert await extension.json() == {"foo": "bar"} + assert cast(Any, extension.is_closed) is True + assert isinstance(extension, AsyncBinaryAPIResponse) + + @parametrize + @pytest.mark.respx(base_url=base_url) + async def test_raw_response_download(self, async_client: AsyncKernel, respx_mock: MockRouter) -> None: + respx_mock.get("/extensions/id_or_name").mock(return_value=httpx.Response(200, json={"foo": "bar"})) + + extension = await async_client.extensions.with_raw_response.download( + "id_or_name", + ) + + assert extension.is_closed is True + assert extension.http_request.headers.get("X-Stainless-Lang") == "python" + assert await extension.json() == {"foo": "bar"} + assert isinstance(extension, AsyncBinaryAPIResponse) + + @parametrize + @pytest.mark.respx(base_url=base_url) + async def test_streaming_response_download(self, async_client: AsyncKernel, respx_mock: MockRouter) -> None: + respx_mock.get("/extensions/id_or_name").mock(return_value=httpx.Response(200, json={"foo": "bar"})) + async with async_client.extensions.with_streaming_response.download( + "id_or_name", + ) as extension: + assert not extension.is_closed + assert extension.http_request.headers.get("X-Stainless-Lang") == "python" + + assert await extension.json() == {"foo": "bar"} + assert cast(Any, extension.is_closed) is True + assert isinstance(extension, AsyncStreamedBinaryAPIResponse) + + assert cast(Any, extension.is_closed) is True + + @parametrize + @pytest.mark.respx(base_url=base_url) + async def test_path_params_download(self, async_client: AsyncKernel) -> None: + with pytest.raises(ValueError, match=r"Expected a non-empty value for `id_or_name` but received ''"): + await async_client.extensions.with_raw_response.download( + "", + ) + + @parametrize + @pytest.mark.respx(base_url=base_url) + async def test_method_download_from_chrome_store(self, async_client: AsyncKernel, respx_mock: MockRouter) -> None: + respx_mock.get("/extensions/from_chrome_store").mock(return_value=httpx.Response(200, json={"foo": "bar"})) + extension = await async_client.extensions.download_from_chrome_store( + url="url", + ) + assert extension.is_closed + assert await extension.json() == {"foo": "bar"} + assert cast(Any, extension.is_closed) is True + assert isinstance(extension, AsyncBinaryAPIResponse) + + @parametrize + @pytest.mark.respx(base_url=base_url) + async def test_method_download_from_chrome_store_with_all_params( + self, async_client: AsyncKernel, respx_mock: MockRouter + ) -> None: + respx_mock.get("/extensions/from_chrome_store").mock(return_value=httpx.Response(200, json={"foo": "bar"})) + extension = await async_client.extensions.download_from_chrome_store( + url="url", + os="win", + ) + assert extension.is_closed + assert await extension.json() == {"foo": "bar"} + assert cast(Any, extension.is_closed) is True + assert isinstance(extension, AsyncBinaryAPIResponse) + + @parametrize + @pytest.mark.respx(base_url=base_url) + async def test_raw_response_download_from_chrome_store( + self, async_client: AsyncKernel, respx_mock: MockRouter + ) -> None: + respx_mock.get("/extensions/from_chrome_store").mock(return_value=httpx.Response(200, json={"foo": "bar"})) + + extension = await async_client.extensions.with_raw_response.download_from_chrome_store( + url="url", + ) + + assert extension.is_closed is True + assert extension.http_request.headers.get("X-Stainless-Lang") == "python" + assert await extension.json() == {"foo": "bar"} + assert isinstance(extension, AsyncBinaryAPIResponse) + + @parametrize + @pytest.mark.respx(base_url=base_url) + async def test_streaming_response_download_from_chrome_store( + self, async_client: AsyncKernel, respx_mock: MockRouter + ) -> None: + respx_mock.get("/extensions/from_chrome_store").mock(return_value=httpx.Response(200, json={"foo": "bar"})) + async with async_client.extensions.with_streaming_response.download_from_chrome_store( + url="url", + ) as extension: + assert not extension.is_closed + assert extension.http_request.headers.get("X-Stainless-Lang") == "python" + + assert await extension.json() == {"foo": "bar"} + assert cast(Any, extension.is_closed) is True + assert isinstance(extension, AsyncStreamedBinaryAPIResponse) + + assert cast(Any, extension.is_closed) is True + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + async def test_method_get(self, async_client: AsyncKernel) -> None: + extension = await async_client.extensions.get( + "id_or_name", + ) + assert_matches_type(ExtensionGetResponse, extension, path=["response"]) + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + async def test_raw_response_get(self, async_client: AsyncKernel) -> None: + response = await async_client.extensions.with_raw_response.get( + "id_or_name", + ) + + assert response.is_closed is True + assert response.http_request.headers.get("X-Stainless-Lang") == "python" + extension = await response.parse() + assert_matches_type(ExtensionGetResponse, extension, path=["response"]) + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + async def test_streaming_response_get(self, async_client: AsyncKernel) -> None: + async with async_client.extensions.with_streaming_response.get( + "id_or_name", + ) as response: + assert not response.is_closed + assert response.http_request.headers.get("X-Stainless-Lang") == "python" + + extension = await response.parse() + assert_matches_type(ExtensionGetResponse, extension, path=["response"]) + + assert cast(Any, response.is_closed) is True + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + async def test_path_params_get(self, async_client: AsyncKernel) -> None: + with pytest.raises(ValueError, match=r"Expected a non-empty value for `id_or_name` but received ''"): + await async_client.extensions.with_raw_response.get( + "", + ) + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + async def test_method_upload(self, async_client: AsyncKernel) -> None: + extension = await async_client.extensions.upload( + file=b"Example data", + ) + assert_matches_type(ExtensionUploadResponse, extension, path=["response"]) + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + async def test_method_upload_with_all_params(self, async_client: AsyncKernel) -> None: + extension = await async_client.extensions.upload( + file=b"Example data", + name="name", + ) + assert_matches_type(ExtensionUploadResponse, extension, path=["response"]) + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + async def test_raw_response_upload(self, async_client: AsyncKernel) -> None: + response = await async_client.extensions.with_raw_response.upload( + file=b"Example data", + ) + + assert response.is_closed is True + assert response.http_request.headers.get("X-Stainless-Lang") == "python" + extension = await response.parse() + assert_matches_type(ExtensionUploadResponse, extension, path=["response"]) + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + async def test_streaming_response_upload(self, async_client: AsyncKernel) -> None: + async with async_client.extensions.with_streaming_response.upload( + file=b"Example data", + ) as response: + assert not response.is_closed + assert response.http_request.headers.get("X-Stainless-Lang") == "python" + + extension = await response.parse() + assert_matches_type(ExtensionUploadResponse, extension, path=["response"]) + + assert cast(Any, response.is_closed) is True diff --git a/tests/api_resources/test_invocations.py b/tests/api_resources/test_invocations.py new file mode 100644 index 00000000..2617629e --- /dev/null +++ b/tests/api_resources/test_invocations.py @@ -0,0 +1,689 @@ +# 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 kernel import Kernel, AsyncKernel +from tests.utils import assert_matches_type +from kernel.types import ( + InvocationListResponse, + InvocationCreateResponse, + InvocationUpdateResponse, + InvocationRetrieveResponse, + InvocationListBrowsersResponse, +) +from kernel.pagination import SyncOffsetPagination, AsyncOffsetPagination + +base_url = os.environ.get("TEST_API_BASE_URL", "http://127.0.0.1:4010") + + +class TestInvocations: + parametrize = pytest.mark.parametrize("client", [False, True], indirect=True, ids=["loose", "strict"]) + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + def test_method_create(self, client: Kernel) -> None: + invocation = client.invocations.create( + action_name="analyze", + app_name="my-app", + version="1.0.0", + ) + assert_matches_type(InvocationCreateResponse, invocation, path=["response"]) + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + def test_method_create_with_all_params(self, client: Kernel) -> None: + invocation = client.invocations.create( + action_name="analyze", + app_name="my-app", + version="1.0.0", + async_=True, + async_timeout_seconds=600, + payload='{"data":"example input"}', + ) + assert_matches_type(InvocationCreateResponse, invocation, path=["response"]) + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + def test_raw_response_create(self, client: Kernel) -> None: + response = client.invocations.with_raw_response.create( + action_name="analyze", + app_name="my-app", + version="1.0.0", + ) + + assert response.is_closed is True + assert response.http_request.headers.get("X-Stainless-Lang") == "python" + invocation = response.parse() + assert_matches_type(InvocationCreateResponse, invocation, path=["response"]) + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + def test_streaming_response_create(self, client: Kernel) -> None: + with client.invocations.with_streaming_response.create( + action_name="analyze", + app_name="my-app", + version="1.0.0", + ) as response: + assert not response.is_closed + assert response.http_request.headers.get("X-Stainless-Lang") == "python" + + invocation = response.parse() + assert_matches_type(InvocationCreateResponse, invocation, path=["response"]) + + assert cast(Any, response.is_closed) is True + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + def test_method_retrieve(self, client: Kernel) -> None: + invocation = client.invocations.retrieve( + "rr33xuugxj9h0bkf1rdt2bet", + ) + assert_matches_type(InvocationRetrieveResponse, invocation, path=["response"]) + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + def test_raw_response_retrieve(self, client: Kernel) -> None: + response = client.invocations.with_raw_response.retrieve( + "rr33xuugxj9h0bkf1rdt2bet", + ) + + assert response.is_closed is True + assert response.http_request.headers.get("X-Stainless-Lang") == "python" + invocation = response.parse() + assert_matches_type(InvocationRetrieveResponse, invocation, path=["response"]) + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + def test_streaming_response_retrieve(self, client: Kernel) -> None: + with client.invocations.with_streaming_response.retrieve( + "rr33xuugxj9h0bkf1rdt2bet", + ) as response: + assert not response.is_closed + assert response.http_request.headers.get("X-Stainless-Lang") == "python" + + invocation = response.parse() + assert_matches_type(InvocationRetrieveResponse, invocation, path=["response"]) + + assert cast(Any, response.is_closed) is True + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + def test_path_params_retrieve(self, client: Kernel) -> None: + with pytest.raises(ValueError, match=r"Expected a non-empty value for `id` but received ''"): + client.invocations.with_raw_response.retrieve( + "", + ) + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + def test_method_update(self, client: Kernel) -> None: + invocation = client.invocations.update( + id="id", + status="succeeded", + ) + assert_matches_type(InvocationUpdateResponse, invocation, path=["response"]) + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + def test_method_update_with_all_params(self, client: Kernel) -> None: + invocation = client.invocations.update( + id="id", + status="succeeded", + output="output", + ) + assert_matches_type(InvocationUpdateResponse, invocation, path=["response"]) + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + def test_raw_response_update(self, client: Kernel) -> None: + response = client.invocations.with_raw_response.update( + id="id", + status="succeeded", + ) + + assert response.is_closed is True + assert response.http_request.headers.get("X-Stainless-Lang") == "python" + invocation = response.parse() + assert_matches_type(InvocationUpdateResponse, invocation, path=["response"]) + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + def test_streaming_response_update(self, client: Kernel) -> None: + with client.invocations.with_streaming_response.update( + id="id", + status="succeeded", + ) as response: + assert not response.is_closed + assert response.http_request.headers.get("X-Stainless-Lang") == "python" + + invocation = response.parse() + assert_matches_type(InvocationUpdateResponse, invocation, path=["response"]) + + assert cast(Any, response.is_closed) is True + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + def test_path_params_update(self, client: Kernel) -> None: + with pytest.raises(ValueError, match=r"Expected a non-empty value for `id` but received ''"): + client.invocations.with_raw_response.update( + id="", + status="succeeded", + ) + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + def test_method_list(self, client: Kernel) -> None: + invocation = client.invocations.list() + assert_matches_type(SyncOffsetPagination[InvocationListResponse], invocation, path=["response"]) + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + def test_method_list_with_all_params(self, client: Kernel) -> None: + invocation = client.invocations.list( + action_name="action_name", + app_name="app_name", + deployment_id="deployment_id", + limit=1, + offset=0, + query="query", + since="2025-06-20T12:00:00Z", + status="queued", + version="version", + ) + assert_matches_type(SyncOffsetPagination[InvocationListResponse], invocation, path=["response"]) + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + def test_raw_response_list(self, client: Kernel) -> None: + response = client.invocations.with_raw_response.list() + + assert response.is_closed is True + assert response.http_request.headers.get("X-Stainless-Lang") == "python" + invocation = response.parse() + assert_matches_type(SyncOffsetPagination[InvocationListResponse], invocation, path=["response"]) + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + def test_streaming_response_list(self, client: Kernel) -> None: + with client.invocations.with_streaming_response.list() as response: + assert not response.is_closed + assert response.http_request.headers.get("X-Stainless-Lang") == "python" + + invocation = response.parse() + assert_matches_type(SyncOffsetPagination[InvocationListResponse], invocation, path=["response"]) + + assert cast(Any, response.is_closed) is True + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + def test_method_delete_browsers(self, client: Kernel) -> None: + invocation = client.invocations.delete_browsers( + "id", + ) + assert invocation is None + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + def test_raw_response_delete_browsers(self, client: Kernel) -> None: + response = client.invocations.with_raw_response.delete_browsers( + "id", + ) + + assert response.is_closed is True + assert response.http_request.headers.get("X-Stainless-Lang") == "python" + invocation = response.parse() + assert invocation is None + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + def test_streaming_response_delete_browsers(self, client: Kernel) -> None: + with client.invocations.with_streaming_response.delete_browsers( + "id", + ) as response: + assert not response.is_closed + assert response.http_request.headers.get("X-Stainless-Lang") == "python" + + invocation = response.parse() + assert invocation is None + + assert cast(Any, response.is_closed) is True + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + def test_path_params_delete_browsers(self, client: Kernel) -> None: + with pytest.raises(ValueError, match=r"Expected a non-empty value for `id` but received ''"): + client.invocations.with_raw_response.delete_browsers( + "", + ) + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + def test_method_follow(self, client: Kernel) -> None: + invocation_stream = client.invocations.follow( + id="id", + ) + invocation_stream.response.close() + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + def test_method_follow_with_all_params(self, client: Kernel) -> None: + invocation_stream = client.invocations.follow( + id="id", + since="2025-06-20T12:00:00Z", + ) + invocation_stream.response.close() + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + def test_raw_response_follow(self, client: Kernel) -> None: + response = client.invocations.with_raw_response.follow( + id="id", + ) + + assert response.http_request.headers.get("X-Stainless-Lang") == "python" + stream = response.parse() + stream.close() + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + def test_streaming_response_follow(self, client: Kernel) -> None: + with client.invocations.with_streaming_response.follow( + id="id", + ) 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 + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + def test_path_params_follow(self, client: Kernel) -> None: + with pytest.raises(ValueError, match=r"Expected a non-empty value for `id` but received ''"): + client.invocations.with_raw_response.follow( + id="", + ) + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + def test_method_list_browsers(self, client: Kernel) -> None: + invocation = client.invocations.list_browsers( + "id", + ) + assert_matches_type(InvocationListBrowsersResponse, invocation, path=["response"]) + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + def test_raw_response_list_browsers(self, client: Kernel) -> None: + response = client.invocations.with_raw_response.list_browsers( + "id", + ) + + assert response.is_closed is True + assert response.http_request.headers.get("X-Stainless-Lang") == "python" + invocation = response.parse() + assert_matches_type(InvocationListBrowsersResponse, invocation, path=["response"]) + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + def test_streaming_response_list_browsers(self, client: Kernel) -> None: + with client.invocations.with_streaming_response.list_browsers( + "id", + ) as response: + assert not response.is_closed + assert response.http_request.headers.get("X-Stainless-Lang") == "python" + + invocation = response.parse() + assert_matches_type(InvocationListBrowsersResponse, invocation, path=["response"]) + + assert cast(Any, response.is_closed) is True + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + def test_path_params_list_browsers(self, client: Kernel) -> None: + with pytest.raises(ValueError, match=r"Expected a non-empty value for `id` but received ''"): + client.invocations.with_raw_response.list_browsers( + "", + ) + + +class TestAsyncInvocations: + parametrize = pytest.mark.parametrize( + "async_client", [False, True, {"http_client": "aiohttp"}], indirect=True, ids=["loose", "strict", "aiohttp"] + ) + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + async def test_method_create(self, async_client: AsyncKernel) -> None: + invocation = await async_client.invocations.create( + action_name="analyze", + app_name="my-app", + version="1.0.0", + ) + assert_matches_type(InvocationCreateResponse, invocation, path=["response"]) + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + async def test_method_create_with_all_params(self, async_client: AsyncKernel) -> None: + invocation = await async_client.invocations.create( + action_name="analyze", + app_name="my-app", + version="1.0.0", + async_=True, + async_timeout_seconds=600, + payload='{"data":"example input"}', + ) + assert_matches_type(InvocationCreateResponse, invocation, path=["response"]) + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + async def test_raw_response_create(self, async_client: AsyncKernel) -> None: + response = await async_client.invocations.with_raw_response.create( + action_name="analyze", + app_name="my-app", + version="1.0.0", + ) + + assert response.is_closed is True + assert response.http_request.headers.get("X-Stainless-Lang") == "python" + invocation = await response.parse() + assert_matches_type(InvocationCreateResponse, invocation, path=["response"]) + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + async def test_streaming_response_create(self, async_client: AsyncKernel) -> None: + async with async_client.invocations.with_streaming_response.create( + action_name="analyze", + app_name="my-app", + version="1.0.0", + ) as response: + assert not response.is_closed + assert response.http_request.headers.get("X-Stainless-Lang") == "python" + + invocation = await response.parse() + assert_matches_type(InvocationCreateResponse, invocation, path=["response"]) + + assert cast(Any, response.is_closed) is True + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + async def test_method_retrieve(self, async_client: AsyncKernel) -> None: + invocation = await async_client.invocations.retrieve( + "rr33xuugxj9h0bkf1rdt2bet", + ) + assert_matches_type(InvocationRetrieveResponse, invocation, path=["response"]) + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + async def test_raw_response_retrieve(self, async_client: AsyncKernel) -> None: + response = await async_client.invocations.with_raw_response.retrieve( + "rr33xuugxj9h0bkf1rdt2bet", + ) + + assert response.is_closed is True + assert response.http_request.headers.get("X-Stainless-Lang") == "python" + invocation = await response.parse() + assert_matches_type(InvocationRetrieveResponse, invocation, path=["response"]) + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + async def test_streaming_response_retrieve(self, async_client: AsyncKernel) -> None: + async with async_client.invocations.with_streaming_response.retrieve( + "rr33xuugxj9h0bkf1rdt2bet", + ) as response: + assert not response.is_closed + assert response.http_request.headers.get("X-Stainless-Lang") == "python" + + invocation = await response.parse() + assert_matches_type(InvocationRetrieveResponse, invocation, path=["response"]) + + assert cast(Any, response.is_closed) is True + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + async def test_path_params_retrieve(self, async_client: AsyncKernel) -> None: + with pytest.raises(ValueError, match=r"Expected a non-empty value for `id` but received ''"): + await async_client.invocations.with_raw_response.retrieve( + "", + ) + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + async def test_method_update(self, async_client: AsyncKernel) -> None: + invocation = await async_client.invocations.update( + id="id", + status="succeeded", + ) + assert_matches_type(InvocationUpdateResponse, invocation, path=["response"]) + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + async def test_method_update_with_all_params(self, async_client: AsyncKernel) -> None: + invocation = await async_client.invocations.update( + id="id", + status="succeeded", + output="output", + ) + assert_matches_type(InvocationUpdateResponse, invocation, path=["response"]) + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + async def test_raw_response_update(self, async_client: AsyncKernel) -> None: + response = await async_client.invocations.with_raw_response.update( + id="id", + status="succeeded", + ) + + assert response.is_closed is True + assert response.http_request.headers.get("X-Stainless-Lang") == "python" + invocation = await response.parse() + assert_matches_type(InvocationUpdateResponse, invocation, path=["response"]) + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + async def test_streaming_response_update(self, async_client: AsyncKernel) -> None: + async with async_client.invocations.with_streaming_response.update( + id="id", + status="succeeded", + ) as response: + assert not response.is_closed + assert response.http_request.headers.get("X-Stainless-Lang") == "python" + + invocation = await response.parse() + assert_matches_type(InvocationUpdateResponse, invocation, path=["response"]) + + assert cast(Any, response.is_closed) is True + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + async def test_path_params_update(self, async_client: AsyncKernel) -> None: + with pytest.raises(ValueError, match=r"Expected a non-empty value for `id` but received ''"): + await async_client.invocations.with_raw_response.update( + id="", + status="succeeded", + ) + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + async def test_method_list(self, async_client: AsyncKernel) -> None: + invocation = await async_client.invocations.list() + assert_matches_type(AsyncOffsetPagination[InvocationListResponse], invocation, path=["response"]) + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + async def test_method_list_with_all_params(self, async_client: AsyncKernel) -> None: + invocation = await async_client.invocations.list( + action_name="action_name", + app_name="app_name", + deployment_id="deployment_id", + limit=1, + offset=0, + query="query", + since="2025-06-20T12:00:00Z", + status="queued", + version="version", + ) + assert_matches_type(AsyncOffsetPagination[InvocationListResponse], invocation, path=["response"]) + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + async def test_raw_response_list(self, async_client: AsyncKernel) -> None: + response = await async_client.invocations.with_raw_response.list() + + assert response.is_closed is True + assert response.http_request.headers.get("X-Stainless-Lang") == "python" + invocation = await response.parse() + assert_matches_type(AsyncOffsetPagination[InvocationListResponse], invocation, path=["response"]) + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + async def test_streaming_response_list(self, async_client: AsyncKernel) -> None: + async with async_client.invocations.with_streaming_response.list() as response: + assert not response.is_closed + assert response.http_request.headers.get("X-Stainless-Lang") == "python" + + invocation = await response.parse() + assert_matches_type(AsyncOffsetPagination[InvocationListResponse], invocation, path=["response"]) + + assert cast(Any, response.is_closed) is True + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + async def test_method_delete_browsers(self, async_client: AsyncKernel) -> None: + invocation = await async_client.invocations.delete_browsers( + "id", + ) + assert invocation is None + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + async def test_raw_response_delete_browsers(self, async_client: AsyncKernel) -> None: + response = await async_client.invocations.with_raw_response.delete_browsers( + "id", + ) + + assert response.is_closed is True + assert response.http_request.headers.get("X-Stainless-Lang") == "python" + invocation = await response.parse() + assert invocation is None + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + async def test_streaming_response_delete_browsers(self, async_client: AsyncKernel) -> None: + async with async_client.invocations.with_streaming_response.delete_browsers( + "id", + ) as response: + assert not response.is_closed + assert response.http_request.headers.get("X-Stainless-Lang") == "python" + + invocation = await response.parse() + assert invocation is None + + assert cast(Any, response.is_closed) is True + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + async def test_path_params_delete_browsers(self, async_client: AsyncKernel) -> None: + with pytest.raises(ValueError, match=r"Expected a non-empty value for `id` but received ''"): + await async_client.invocations.with_raw_response.delete_browsers( + "", + ) + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + async def test_method_follow(self, async_client: AsyncKernel) -> None: + invocation_stream = await async_client.invocations.follow( + id="id", + ) + await invocation_stream.response.aclose() + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + async def test_method_follow_with_all_params(self, async_client: AsyncKernel) -> None: + invocation_stream = await async_client.invocations.follow( + id="id", + since="2025-06-20T12:00:00Z", + ) + await invocation_stream.response.aclose() + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + async def test_raw_response_follow(self, async_client: AsyncKernel) -> None: + response = await async_client.invocations.with_raw_response.follow( + id="id", + ) + + assert response.http_request.headers.get("X-Stainless-Lang") == "python" + stream = await response.parse() + await stream.close() + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + async def test_streaming_response_follow(self, async_client: AsyncKernel) -> None: + async with async_client.invocations.with_streaming_response.follow( + id="id", + ) 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 + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + async def test_path_params_follow(self, async_client: AsyncKernel) -> None: + with pytest.raises(ValueError, match=r"Expected a non-empty value for `id` but received ''"): + await async_client.invocations.with_raw_response.follow( + id="", + ) + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + async def test_method_list_browsers(self, async_client: AsyncKernel) -> None: + invocation = await async_client.invocations.list_browsers( + "id", + ) + assert_matches_type(InvocationListBrowsersResponse, invocation, path=["response"]) + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + async def test_raw_response_list_browsers(self, async_client: AsyncKernel) -> None: + response = await async_client.invocations.with_raw_response.list_browsers( + "id", + ) + + assert response.is_closed is True + assert response.http_request.headers.get("X-Stainless-Lang") == "python" + invocation = await response.parse() + assert_matches_type(InvocationListBrowsersResponse, invocation, path=["response"]) + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + async def test_streaming_response_list_browsers(self, async_client: AsyncKernel) -> None: + async with async_client.invocations.with_streaming_response.list_browsers( + "id", + ) as response: + assert not response.is_closed + assert response.http_request.headers.get("X-Stainless-Lang") == "python" + + invocation = await response.parse() + assert_matches_type(InvocationListBrowsersResponse, invocation, path=["response"]) + + assert cast(Any, response.is_closed) is True + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + async def test_path_params_list_browsers(self, async_client: AsyncKernel) -> None: + with pytest.raises(ValueError, match=r"Expected a non-empty value for `id` but received ''"): + await async_client.invocations.with_raw_response.list_browsers( + "", + ) diff --git a/tests/api_resources/test_profiles.py b/tests/api_resources/test_profiles.py new file mode 100644 index 00000000..9317cf48 --- /dev/null +++ b/tests/api_resources/test_profiles.py @@ -0,0 +1,571 @@ +# 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 httpx +import pytest +from respx import MockRouter + +from kernel import Kernel, AsyncKernel +from tests.utils import assert_matches_type +from kernel.types import ( + Profile, +) +from kernel._response import ( + BinaryAPIResponse, + AsyncBinaryAPIResponse, + StreamedBinaryAPIResponse, + AsyncStreamedBinaryAPIResponse, +) +from kernel.pagination import SyncOffsetPagination, AsyncOffsetPagination + +base_url = os.environ.get("TEST_API_BASE_URL", "http://127.0.0.1:4010") + + +class TestProfiles: + parametrize = pytest.mark.parametrize("client", [False, True], indirect=True, ids=["loose", "strict"]) + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + def test_method_create(self, client: Kernel) -> None: + profile = client.profiles.create() + assert_matches_type(Profile, profile, path=["response"]) + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + def test_method_create_with_all_params(self, client: Kernel) -> None: + profile = client.profiles.create( + name="name", + ) + assert_matches_type(Profile, profile, path=["response"]) + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + def test_raw_response_create(self, client: Kernel) -> None: + response = client.profiles.with_raw_response.create() + + assert response.is_closed is True + assert response.http_request.headers.get("X-Stainless-Lang") == "python" + profile = response.parse() + assert_matches_type(Profile, profile, path=["response"]) + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + def test_streaming_response_create(self, client: Kernel) -> None: + with client.profiles.with_streaming_response.create() as response: + assert not response.is_closed + assert response.http_request.headers.get("X-Stainless-Lang") == "python" + + profile = response.parse() + assert_matches_type(Profile, profile, path=["response"]) + + assert cast(Any, response.is_closed) is True + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + def test_method_retrieve(self, client: Kernel) -> None: + profile = client.profiles.retrieve( + "id_or_name", + ) + assert_matches_type(Profile, profile, path=["response"]) + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + def test_raw_response_retrieve(self, client: Kernel) -> None: + response = client.profiles.with_raw_response.retrieve( + "id_or_name", + ) + + assert response.is_closed is True + assert response.http_request.headers.get("X-Stainless-Lang") == "python" + profile = response.parse() + assert_matches_type(Profile, profile, path=["response"]) + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + def test_streaming_response_retrieve(self, client: Kernel) -> None: + with client.profiles.with_streaming_response.retrieve( + "id_or_name", + ) as response: + assert not response.is_closed + assert response.http_request.headers.get("X-Stainless-Lang") == "python" + + profile = response.parse() + assert_matches_type(Profile, profile, path=["response"]) + + assert cast(Any, response.is_closed) is True + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + def test_path_params_retrieve(self, client: Kernel) -> None: + with pytest.raises(ValueError, match=r"Expected a non-empty value for `id_or_name` but received ''"): + client.profiles.with_raw_response.retrieve( + "", + ) + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + def test_method_update(self, client: Kernel) -> None: + profile = client.profiles.update( + id_or_name="id_or_name", + name="my-renamed-profile", + ) + assert_matches_type(Profile, profile, path=["response"]) + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + def test_raw_response_update(self, client: Kernel) -> None: + response = client.profiles.with_raw_response.update( + id_or_name="id_or_name", + name="my-renamed-profile", + ) + + assert response.is_closed is True + assert response.http_request.headers.get("X-Stainless-Lang") == "python" + profile = response.parse() + assert_matches_type(Profile, profile, path=["response"]) + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + def test_streaming_response_update(self, client: Kernel) -> None: + with client.profiles.with_streaming_response.update( + id_or_name="id_or_name", + name="my-renamed-profile", + ) as response: + assert not response.is_closed + assert response.http_request.headers.get("X-Stainless-Lang") == "python" + + profile = response.parse() + assert_matches_type(Profile, profile, path=["response"]) + + assert cast(Any, response.is_closed) is True + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + def test_path_params_update(self, client: Kernel) -> None: + with pytest.raises(ValueError, match=r"Expected a non-empty value for `id_or_name` but received ''"): + client.profiles.with_raw_response.update( + id_or_name="", + name="my-renamed-profile", + ) + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + def test_method_list(self, client: Kernel) -> None: + profile = client.profiles.list() + assert_matches_type(SyncOffsetPagination[Profile], profile, path=["response"]) + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + def test_method_list_with_all_params(self, client: Kernel) -> None: + profile = client.profiles.list( + limit=1, + name="name", + offset=0, + query="query", + ) + assert_matches_type(SyncOffsetPagination[Profile], profile, path=["response"]) + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + def test_raw_response_list(self, client: Kernel) -> None: + response = client.profiles.with_raw_response.list() + + assert response.is_closed is True + assert response.http_request.headers.get("X-Stainless-Lang") == "python" + profile = response.parse() + assert_matches_type(SyncOffsetPagination[Profile], profile, path=["response"]) + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + def test_streaming_response_list(self, client: Kernel) -> None: + with client.profiles.with_streaming_response.list() as response: + assert not response.is_closed + assert response.http_request.headers.get("X-Stainless-Lang") == "python" + + profile = response.parse() + assert_matches_type(SyncOffsetPagination[Profile], profile, path=["response"]) + + assert cast(Any, response.is_closed) is True + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + def test_method_delete(self, client: Kernel) -> None: + profile = client.profiles.delete( + "id_or_name", + ) + assert profile is None + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + def test_raw_response_delete(self, client: Kernel) -> None: + response = client.profiles.with_raw_response.delete( + "id_or_name", + ) + + assert response.is_closed is True + assert response.http_request.headers.get("X-Stainless-Lang") == "python" + profile = response.parse() + assert profile is None + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + def test_streaming_response_delete(self, client: Kernel) -> None: + with client.profiles.with_streaming_response.delete( + "id_or_name", + ) as response: + assert not response.is_closed + assert response.http_request.headers.get("X-Stainless-Lang") == "python" + + profile = response.parse() + assert profile is None + + assert cast(Any, response.is_closed) is True + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + def test_path_params_delete(self, client: Kernel) -> None: + with pytest.raises(ValueError, match=r"Expected a non-empty value for `id_or_name` but received ''"): + client.profiles.with_raw_response.delete( + "", + ) + + @parametrize + @pytest.mark.respx(base_url=base_url) + def test_method_download(self, client: Kernel, respx_mock: MockRouter) -> None: + respx_mock.get("/profiles/id_or_name/download").mock(return_value=httpx.Response(200, json={"foo": "bar"})) + profile = client.profiles.download( + id_or_name="id_or_name", + ) + assert profile.is_closed + assert profile.json() == {"foo": "bar"} + assert cast(Any, profile.is_closed) is True + assert isinstance(profile, BinaryAPIResponse) + + @parametrize + @pytest.mark.respx(base_url=base_url) + def test_method_download_with_all_params(self, client: Kernel, respx_mock: MockRouter) -> None: + respx_mock.get("/profiles/id_or_name/download").mock(return_value=httpx.Response(200, json={"foo": "bar"})) + profile = client.profiles.download( + id_or_name="id_or_name", + format="tar.zst", + ) + assert profile.is_closed + assert profile.json() == {"foo": "bar"} + assert cast(Any, profile.is_closed) is True + assert isinstance(profile, BinaryAPIResponse) + + @parametrize + @pytest.mark.respx(base_url=base_url) + def test_raw_response_download(self, client: Kernel, respx_mock: MockRouter) -> None: + respx_mock.get("/profiles/id_or_name/download").mock(return_value=httpx.Response(200, json={"foo": "bar"})) + + profile = client.profiles.with_raw_response.download( + id_or_name="id_or_name", + ) + + assert profile.is_closed is True + assert profile.http_request.headers.get("X-Stainless-Lang") == "python" + assert profile.json() == {"foo": "bar"} + assert isinstance(profile, BinaryAPIResponse) + + @parametrize + @pytest.mark.respx(base_url=base_url) + def test_streaming_response_download(self, client: Kernel, respx_mock: MockRouter) -> None: + respx_mock.get("/profiles/id_or_name/download").mock(return_value=httpx.Response(200, json={"foo": "bar"})) + with client.profiles.with_streaming_response.download( + id_or_name="id_or_name", + ) as profile: + assert not profile.is_closed + assert profile.http_request.headers.get("X-Stainless-Lang") == "python" + + assert profile.json() == {"foo": "bar"} + assert cast(Any, profile.is_closed) is True + assert isinstance(profile, StreamedBinaryAPIResponse) + + assert cast(Any, profile.is_closed) is True + + @parametrize + @pytest.mark.respx(base_url=base_url) + def test_path_params_download(self, client: Kernel) -> None: + with pytest.raises(ValueError, match=r"Expected a non-empty value for `id_or_name` but received ''"): + client.profiles.with_raw_response.download( + id_or_name="", + ) + + +class TestAsyncProfiles: + parametrize = pytest.mark.parametrize( + "async_client", [False, True, {"http_client": "aiohttp"}], indirect=True, ids=["loose", "strict", "aiohttp"] + ) + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + async def test_method_create(self, async_client: AsyncKernel) -> None: + profile = await async_client.profiles.create() + assert_matches_type(Profile, profile, path=["response"]) + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + async def test_method_create_with_all_params(self, async_client: AsyncKernel) -> None: + profile = await async_client.profiles.create( + name="name", + ) + assert_matches_type(Profile, profile, path=["response"]) + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + async def test_raw_response_create(self, async_client: AsyncKernel) -> None: + response = await async_client.profiles.with_raw_response.create() + + assert response.is_closed is True + assert response.http_request.headers.get("X-Stainless-Lang") == "python" + profile = await response.parse() + assert_matches_type(Profile, profile, path=["response"]) + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + async def test_streaming_response_create(self, async_client: AsyncKernel) -> None: + async with async_client.profiles.with_streaming_response.create() as response: + assert not response.is_closed + assert response.http_request.headers.get("X-Stainless-Lang") == "python" + + profile = await response.parse() + assert_matches_type(Profile, profile, path=["response"]) + + assert cast(Any, response.is_closed) is True + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + async def test_method_retrieve(self, async_client: AsyncKernel) -> None: + profile = await async_client.profiles.retrieve( + "id_or_name", + ) + assert_matches_type(Profile, profile, path=["response"]) + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + async def test_raw_response_retrieve(self, async_client: AsyncKernel) -> None: + response = await async_client.profiles.with_raw_response.retrieve( + "id_or_name", + ) + + assert response.is_closed is True + assert response.http_request.headers.get("X-Stainless-Lang") == "python" + profile = await response.parse() + assert_matches_type(Profile, profile, path=["response"]) + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + async def test_streaming_response_retrieve(self, async_client: AsyncKernel) -> None: + async with async_client.profiles.with_streaming_response.retrieve( + "id_or_name", + ) as response: + assert not response.is_closed + assert response.http_request.headers.get("X-Stainless-Lang") == "python" + + profile = await response.parse() + assert_matches_type(Profile, profile, path=["response"]) + + assert cast(Any, response.is_closed) is True + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + async def test_path_params_retrieve(self, async_client: AsyncKernel) -> None: + with pytest.raises(ValueError, match=r"Expected a non-empty value for `id_or_name` but received ''"): + await async_client.profiles.with_raw_response.retrieve( + "", + ) + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + async def test_method_update(self, async_client: AsyncKernel) -> None: + profile = await async_client.profiles.update( + id_or_name="id_or_name", + name="my-renamed-profile", + ) + assert_matches_type(Profile, profile, path=["response"]) + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + async def test_raw_response_update(self, async_client: AsyncKernel) -> None: + response = await async_client.profiles.with_raw_response.update( + id_or_name="id_or_name", + name="my-renamed-profile", + ) + + assert response.is_closed is True + assert response.http_request.headers.get("X-Stainless-Lang") == "python" + profile = await response.parse() + assert_matches_type(Profile, profile, path=["response"]) + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + async def test_streaming_response_update(self, async_client: AsyncKernel) -> None: + async with async_client.profiles.with_streaming_response.update( + id_or_name="id_or_name", + name="my-renamed-profile", + ) as response: + assert not response.is_closed + assert response.http_request.headers.get("X-Stainless-Lang") == "python" + + profile = await response.parse() + assert_matches_type(Profile, profile, path=["response"]) + + assert cast(Any, response.is_closed) is True + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + async def test_path_params_update(self, async_client: AsyncKernel) -> None: + with pytest.raises(ValueError, match=r"Expected a non-empty value for `id_or_name` but received ''"): + await async_client.profiles.with_raw_response.update( + id_or_name="", + name="my-renamed-profile", + ) + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + async def test_method_list(self, async_client: AsyncKernel) -> None: + profile = await async_client.profiles.list() + assert_matches_type(AsyncOffsetPagination[Profile], profile, path=["response"]) + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + async def test_method_list_with_all_params(self, async_client: AsyncKernel) -> None: + profile = await async_client.profiles.list( + limit=1, + name="name", + offset=0, + query="query", + ) + assert_matches_type(AsyncOffsetPagination[Profile], profile, path=["response"]) + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + async def test_raw_response_list(self, async_client: AsyncKernel) -> None: + response = await async_client.profiles.with_raw_response.list() + + assert response.is_closed is True + assert response.http_request.headers.get("X-Stainless-Lang") == "python" + profile = await response.parse() + assert_matches_type(AsyncOffsetPagination[Profile], profile, path=["response"]) + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + async def test_streaming_response_list(self, async_client: AsyncKernel) -> None: + async with async_client.profiles.with_streaming_response.list() as response: + assert not response.is_closed + assert response.http_request.headers.get("X-Stainless-Lang") == "python" + + profile = await response.parse() + assert_matches_type(AsyncOffsetPagination[Profile], profile, path=["response"]) + + assert cast(Any, response.is_closed) is True + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + async def test_method_delete(self, async_client: AsyncKernel) -> None: + profile = await async_client.profiles.delete( + "id_or_name", + ) + assert profile is None + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + async def test_raw_response_delete(self, async_client: AsyncKernel) -> None: + response = await async_client.profiles.with_raw_response.delete( + "id_or_name", + ) + + assert response.is_closed is True + assert response.http_request.headers.get("X-Stainless-Lang") == "python" + profile = await response.parse() + assert profile is None + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + async def test_streaming_response_delete(self, async_client: AsyncKernel) -> None: + async with async_client.profiles.with_streaming_response.delete( + "id_or_name", + ) as response: + assert not response.is_closed + assert response.http_request.headers.get("X-Stainless-Lang") == "python" + + profile = await response.parse() + assert profile is None + + assert cast(Any, response.is_closed) is True + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + async def test_path_params_delete(self, async_client: AsyncKernel) -> None: + with pytest.raises(ValueError, match=r"Expected a non-empty value for `id_or_name` but received ''"): + await async_client.profiles.with_raw_response.delete( + "", + ) + + @parametrize + @pytest.mark.respx(base_url=base_url) + async def test_method_download(self, async_client: AsyncKernel, respx_mock: MockRouter) -> None: + respx_mock.get("/profiles/id_or_name/download").mock(return_value=httpx.Response(200, json={"foo": "bar"})) + profile = await async_client.profiles.download( + id_or_name="id_or_name", + ) + assert profile.is_closed + assert await profile.json() == {"foo": "bar"} + assert cast(Any, profile.is_closed) is True + assert isinstance(profile, AsyncBinaryAPIResponse) + + @parametrize + @pytest.mark.respx(base_url=base_url) + async def test_method_download_with_all_params(self, async_client: AsyncKernel, respx_mock: MockRouter) -> None: + respx_mock.get("/profiles/id_or_name/download").mock(return_value=httpx.Response(200, json={"foo": "bar"})) + profile = await async_client.profiles.download( + id_or_name="id_or_name", + format="tar.zst", + ) + assert profile.is_closed + assert await profile.json() == {"foo": "bar"} + assert cast(Any, profile.is_closed) is True + assert isinstance(profile, AsyncBinaryAPIResponse) + + @parametrize + @pytest.mark.respx(base_url=base_url) + async def test_raw_response_download(self, async_client: AsyncKernel, respx_mock: MockRouter) -> None: + respx_mock.get("/profiles/id_or_name/download").mock(return_value=httpx.Response(200, json={"foo": "bar"})) + + profile = await async_client.profiles.with_raw_response.download( + id_or_name="id_or_name", + ) + + assert profile.is_closed is True + assert profile.http_request.headers.get("X-Stainless-Lang") == "python" + assert await profile.json() == {"foo": "bar"} + assert isinstance(profile, AsyncBinaryAPIResponse) + + @parametrize + @pytest.mark.respx(base_url=base_url) + async def test_streaming_response_download(self, async_client: AsyncKernel, respx_mock: MockRouter) -> None: + respx_mock.get("/profiles/id_or_name/download").mock(return_value=httpx.Response(200, json={"foo": "bar"})) + async with async_client.profiles.with_streaming_response.download( + id_or_name="id_or_name", + ) as profile: + assert not profile.is_closed + assert profile.http_request.headers.get("X-Stainless-Lang") == "python" + + assert await profile.json() == {"foo": "bar"} + assert cast(Any, profile.is_closed) is True + assert isinstance(profile, AsyncStreamedBinaryAPIResponse) + + assert cast(Any, profile.is_closed) is True + + @parametrize + @pytest.mark.respx(base_url=base_url) + async def test_path_params_download(self, async_client: AsyncKernel) -> None: + with pytest.raises(ValueError, match=r"Expected a non-empty value for `id_or_name` but received ''"): + await async_client.profiles.with_raw_response.download( + id_or_name="", + ) diff --git a/tests/api_resources/test_projects.py b/tests/api_resources/test_projects.py new file mode 100644 index 00000000..e8d98c29 --- /dev/null +++ b/tests/api_resources/test_projects.py @@ -0,0 +1,443 @@ +# 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 kernel import Kernel, AsyncKernel +from tests.utils import assert_matches_type +from kernel.types import Project +from kernel.pagination import SyncOffsetPagination, AsyncOffsetPagination + +base_url = os.environ.get("TEST_API_BASE_URL", "http://127.0.0.1:4010") + + +class TestProjects: + parametrize = pytest.mark.parametrize("client", [False, True], indirect=True, ids=["loose", "strict"]) + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + def test_method_create(self, client: Kernel) -> None: + project = client.projects.create( + name="staging", + ) + assert_matches_type(Project, project, path=["response"]) + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + def test_raw_response_create(self, client: Kernel) -> None: + response = client.projects.with_raw_response.create( + name="staging", + ) + + 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="Mock server tests are disabled") + @parametrize + def test_streaming_response_create(self, client: Kernel) -> None: + with client.projects.with_streaming_response.create( + name="staging", + ) 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 + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + def test_method_retrieve(self, client: Kernel) -> None: + project = client.projects.retrieve( + "id", + ) + assert_matches_type(Project, project, path=["response"]) + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + def test_raw_response_retrieve(self, client: Kernel) -> None: + response = client.projects.with_raw_response.retrieve( + "id", + ) + + 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="Mock server tests are disabled") + @parametrize + def test_streaming_response_retrieve(self, client: Kernel) -> None: + with client.projects.with_streaming_response.retrieve( + "id", + ) 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 + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + def test_path_params_retrieve(self, client: Kernel) -> None: + with pytest.raises(ValueError, match=r"Expected a non-empty value for `id` but received ''"): + client.projects.with_raw_response.retrieve( + "", + ) + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + def test_method_update(self, client: Kernel) -> None: + project = client.projects.update( + id="id", + ) + assert_matches_type(Project, project, path=["response"]) + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + def test_method_update_with_all_params(self, client: Kernel) -> None: + project = client.projects.update( + id="id", + name="x", + status="active", + ) + assert_matches_type(Project, project, path=["response"]) + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + def test_raw_response_update(self, client: Kernel) -> None: + response = client.projects.with_raw_response.update( + id="id", + ) + + 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="Mock server tests are disabled") + @parametrize + def test_streaming_response_update(self, client: Kernel) -> None: + with client.projects.with_streaming_response.update( + id="id", + ) 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 + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + def test_path_params_update(self, client: Kernel) -> None: + with pytest.raises(ValueError, match=r"Expected a non-empty value for `id` but received ''"): + client.projects.with_raw_response.update( + id="", + ) + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + def test_method_list(self, client: Kernel) -> None: + project = client.projects.list() + assert_matches_type(SyncOffsetPagination[Project], project, path=["response"]) + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + def test_method_list_with_all_params(self, client: Kernel) -> None: + project = client.projects.list( + limit=100, + name="name", + offset=0, + query="query", + ) + assert_matches_type(SyncOffsetPagination[Project], project, path=["response"]) + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + def test_raw_response_list(self, client: Kernel) -> None: + response = client.projects.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(SyncOffsetPagination[Project], project, path=["response"]) + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + def test_streaming_response_list(self, client: Kernel) -> None: + with client.projects.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(SyncOffsetPagination[Project], project, path=["response"]) + + assert cast(Any, response.is_closed) is True + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + def test_method_delete(self, client: Kernel) -> None: + project = client.projects.delete( + "id", + ) + assert project is None + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + def test_raw_response_delete(self, client: Kernel) -> None: + response = client.projects.with_raw_response.delete( + "id", + ) + + assert response.is_closed is True + assert response.http_request.headers.get("X-Stainless-Lang") == "python" + project = response.parse() + assert project is None + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + def test_streaming_response_delete(self, client: Kernel) -> None: + with client.projects.with_streaming_response.delete( + "id", + ) as response: + assert not response.is_closed + assert response.http_request.headers.get("X-Stainless-Lang") == "python" + + project = response.parse() + assert project is None + + assert cast(Any, response.is_closed) is True + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + def test_path_params_delete(self, client: Kernel) -> None: + with pytest.raises(ValueError, match=r"Expected a non-empty value for `id` but received ''"): + client.projects.with_raw_response.delete( + "", + ) + + +class TestAsyncProjects: + parametrize = pytest.mark.parametrize( + "async_client", [False, True, {"http_client": "aiohttp"}], indirect=True, ids=["loose", "strict", "aiohttp"] + ) + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + async def test_method_create(self, async_client: AsyncKernel) -> None: + project = await async_client.projects.create( + name="staging", + ) + assert_matches_type(Project, project, path=["response"]) + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + async def test_raw_response_create(self, async_client: AsyncKernel) -> None: + response = await async_client.projects.with_raw_response.create( + name="staging", + ) + + 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="Mock server tests are disabled") + @parametrize + async def test_streaming_response_create(self, async_client: AsyncKernel) -> None: + async with async_client.projects.with_streaming_response.create( + name="staging", + ) 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 + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + async def test_method_retrieve(self, async_client: AsyncKernel) -> None: + project = await async_client.projects.retrieve( + "id", + ) + assert_matches_type(Project, project, path=["response"]) + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + async def test_raw_response_retrieve(self, async_client: AsyncKernel) -> None: + response = await async_client.projects.with_raw_response.retrieve( + "id", + ) + + 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="Mock server tests are disabled") + @parametrize + async def test_streaming_response_retrieve(self, async_client: AsyncKernel) -> None: + async with async_client.projects.with_streaming_response.retrieve( + "id", + ) 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 + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + async def test_path_params_retrieve(self, async_client: AsyncKernel) -> None: + with pytest.raises(ValueError, match=r"Expected a non-empty value for `id` but received ''"): + await async_client.projects.with_raw_response.retrieve( + "", + ) + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + async def test_method_update(self, async_client: AsyncKernel) -> None: + project = await async_client.projects.update( + id="id", + ) + assert_matches_type(Project, project, path=["response"]) + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + async def test_method_update_with_all_params(self, async_client: AsyncKernel) -> None: + project = await async_client.projects.update( + id="id", + name="x", + status="active", + ) + assert_matches_type(Project, project, path=["response"]) + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + async def test_raw_response_update(self, async_client: AsyncKernel) -> None: + response = await async_client.projects.with_raw_response.update( + id="id", + ) + + 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="Mock server tests are disabled") + @parametrize + async def test_streaming_response_update(self, async_client: AsyncKernel) -> None: + async with async_client.projects.with_streaming_response.update( + id="id", + ) 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 + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + async def test_path_params_update(self, async_client: AsyncKernel) -> None: + with pytest.raises(ValueError, match=r"Expected a non-empty value for `id` but received ''"): + await async_client.projects.with_raw_response.update( + id="", + ) + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + async def test_method_list(self, async_client: AsyncKernel) -> None: + project = await async_client.projects.list() + assert_matches_type(AsyncOffsetPagination[Project], project, path=["response"]) + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + async def test_method_list_with_all_params(self, async_client: AsyncKernel) -> None: + project = await async_client.projects.list( + limit=100, + name="name", + offset=0, + query="query", + ) + assert_matches_type(AsyncOffsetPagination[Project], project, path=["response"]) + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + async def test_raw_response_list(self, async_client: AsyncKernel) -> None: + response = await async_client.projects.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(AsyncOffsetPagination[Project], project, path=["response"]) + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + async def test_streaming_response_list(self, async_client: AsyncKernel) -> None: + async with async_client.projects.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(AsyncOffsetPagination[Project], project, path=["response"]) + + assert cast(Any, response.is_closed) is True + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + async def test_method_delete(self, async_client: AsyncKernel) -> None: + project = await async_client.projects.delete( + "id", + ) + assert project is None + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + async def test_raw_response_delete(self, async_client: AsyncKernel) -> None: + response = await async_client.projects.with_raw_response.delete( + "id", + ) + + assert response.is_closed is True + assert response.http_request.headers.get("X-Stainless-Lang") == "python" + project = await response.parse() + assert project is None + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + async def test_streaming_response_delete(self, async_client: AsyncKernel) -> None: + async with async_client.projects.with_streaming_response.delete( + "id", + ) as response: + assert not response.is_closed + assert response.http_request.headers.get("X-Stainless-Lang") == "python" + + project = await response.parse() + assert project is None + + assert cast(Any, response.is_closed) is True + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + async def test_path_params_delete(self, async_client: AsyncKernel) -> None: + with pytest.raises(ValueError, match=r"Expected a non-empty value for `id` but received ''"): + await async_client.projects.with_raw_response.delete( + "", + ) diff --git a/tests/api_resources/test_proxies.py b/tests/api_resources/test_proxies.py new file mode 100644 index 00000000..bdc48364 --- /dev/null +++ b/tests/api_resources/test_proxies.py @@ -0,0 +1,563 @@ +# 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 kernel import Kernel, AsyncKernel +from tests.utils import assert_matches_type +from kernel.types import ( + ProxyListResponse, + ProxyCheckResponse, + ProxyCreateResponse, + ProxyUpdateResponse, + ProxyRetrieveResponse, +) +from kernel.pagination import SyncOffsetPagination, AsyncOffsetPagination + +base_url = os.environ.get("TEST_API_BASE_URL", "http://127.0.0.1:4010") + + +class TestProxies: + parametrize = pytest.mark.parametrize("client", [False, True], indirect=True, ids=["loose", "strict"]) + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + def test_method_create(self, client: Kernel) -> None: + proxy = client.proxies.create( + type="datacenter", + ) + assert_matches_type(ProxyCreateResponse, proxy, path=["response"]) + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + def test_method_create_with_all_params(self, client: Kernel) -> None: + proxy = client.proxies.create( + type="datacenter", + bypass_hosts=["string"], + config={"country": "US"}, + name="name", + protocol="http", + ) + assert_matches_type(ProxyCreateResponse, proxy, path=["response"]) + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + def test_raw_response_create(self, client: Kernel) -> None: + response = client.proxies.with_raw_response.create( + type="datacenter", + ) + + assert response.is_closed is True + assert response.http_request.headers.get("X-Stainless-Lang") == "python" + proxy = response.parse() + assert_matches_type(ProxyCreateResponse, proxy, path=["response"]) + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + def test_streaming_response_create(self, client: Kernel) -> None: + with client.proxies.with_streaming_response.create( + type="datacenter", + ) as response: + assert not response.is_closed + assert response.http_request.headers.get("X-Stainless-Lang") == "python" + + proxy = response.parse() + assert_matches_type(ProxyCreateResponse, proxy, path=["response"]) + + assert cast(Any, response.is_closed) is True + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + def test_method_retrieve(self, client: Kernel) -> None: + proxy = client.proxies.retrieve( + "id", + ) + assert_matches_type(ProxyRetrieveResponse, proxy, path=["response"]) + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + def test_raw_response_retrieve(self, client: Kernel) -> None: + response = client.proxies.with_raw_response.retrieve( + "id", + ) + + assert response.is_closed is True + assert response.http_request.headers.get("X-Stainless-Lang") == "python" + proxy = response.parse() + assert_matches_type(ProxyRetrieveResponse, proxy, path=["response"]) + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + def test_streaming_response_retrieve(self, client: Kernel) -> None: + with client.proxies.with_streaming_response.retrieve( + "id", + ) as response: + assert not response.is_closed + assert response.http_request.headers.get("X-Stainless-Lang") == "python" + + proxy = response.parse() + assert_matches_type(ProxyRetrieveResponse, proxy, path=["response"]) + + assert cast(Any, response.is_closed) is True + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + def test_path_params_retrieve(self, client: Kernel) -> None: + with pytest.raises(ValueError, match=r"Expected a non-empty value for `id` but received ''"): + client.proxies.with_raw_response.retrieve( + "", + ) + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + def test_method_update(self, client: Kernel) -> None: + proxy = client.proxies.update( + id="id", + name="my-renamed-proxy", + ) + assert_matches_type(ProxyUpdateResponse, proxy, path=["response"]) + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + def test_raw_response_update(self, client: Kernel) -> None: + response = client.proxies.with_raw_response.update( + id="id", + name="my-renamed-proxy", + ) + + assert response.is_closed is True + assert response.http_request.headers.get("X-Stainless-Lang") == "python" + proxy = response.parse() + assert_matches_type(ProxyUpdateResponse, proxy, path=["response"]) + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + def test_streaming_response_update(self, client: Kernel) -> None: + with client.proxies.with_streaming_response.update( + id="id", + name="my-renamed-proxy", + ) as response: + assert not response.is_closed + assert response.http_request.headers.get("X-Stainless-Lang") == "python" + + proxy = response.parse() + assert_matches_type(ProxyUpdateResponse, proxy, path=["response"]) + + assert cast(Any, response.is_closed) is True + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + def test_path_params_update(self, client: Kernel) -> None: + with pytest.raises(ValueError, match=r"Expected a non-empty value for `id` but received ''"): + client.proxies.with_raw_response.update( + id="", + name="my-renamed-proxy", + ) + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + def test_method_list(self, client: Kernel) -> None: + proxy = client.proxies.list() + assert_matches_type(SyncOffsetPagination[ProxyListResponse], proxy, path=["response"]) + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + def test_method_list_with_all_params(self, client: Kernel) -> None: + proxy = client.proxies.list( + limit=1, + name="name", + offset=0, + query="query", + ) + assert_matches_type(SyncOffsetPagination[ProxyListResponse], proxy, path=["response"]) + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + def test_raw_response_list(self, client: Kernel) -> None: + response = client.proxies.with_raw_response.list() + + assert response.is_closed is True + assert response.http_request.headers.get("X-Stainless-Lang") == "python" + proxy = response.parse() + assert_matches_type(SyncOffsetPagination[ProxyListResponse], proxy, path=["response"]) + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + def test_streaming_response_list(self, client: Kernel) -> None: + with client.proxies.with_streaming_response.list() as response: + assert not response.is_closed + assert response.http_request.headers.get("X-Stainless-Lang") == "python" + + proxy = response.parse() + assert_matches_type(SyncOffsetPagination[ProxyListResponse], proxy, path=["response"]) + + assert cast(Any, response.is_closed) is True + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + def test_method_delete(self, client: Kernel) -> None: + proxy = client.proxies.delete( + "id", + ) + assert proxy is None + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + def test_raw_response_delete(self, client: Kernel) -> None: + response = client.proxies.with_raw_response.delete( + "id", + ) + + assert response.is_closed is True + assert response.http_request.headers.get("X-Stainless-Lang") == "python" + proxy = response.parse() + assert proxy is None + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + def test_streaming_response_delete(self, client: Kernel) -> None: + with client.proxies.with_streaming_response.delete( + "id", + ) as response: + assert not response.is_closed + assert response.http_request.headers.get("X-Stainless-Lang") == "python" + + proxy = response.parse() + assert proxy is None + + assert cast(Any, response.is_closed) is True + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + def test_path_params_delete(self, client: Kernel) -> None: + with pytest.raises(ValueError, match=r"Expected a non-empty value for `id` but received ''"): + client.proxies.with_raw_response.delete( + "", + ) + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + def test_method_check(self, client: Kernel) -> None: + proxy = client.proxies.check( + id="id", + ) + assert_matches_type(ProxyCheckResponse, proxy, path=["response"]) + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + def test_method_check_with_all_params(self, client: Kernel) -> None: + proxy = client.proxies.check( + id="id", + url="https://example.com", + ) + assert_matches_type(ProxyCheckResponse, proxy, path=["response"]) + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + def test_raw_response_check(self, client: Kernel) -> None: + response = client.proxies.with_raw_response.check( + id="id", + ) + + assert response.is_closed is True + assert response.http_request.headers.get("X-Stainless-Lang") == "python" + proxy = response.parse() + assert_matches_type(ProxyCheckResponse, proxy, path=["response"]) + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + def test_streaming_response_check(self, client: Kernel) -> None: + with client.proxies.with_streaming_response.check( + id="id", + ) as response: + assert not response.is_closed + assert response.http_request.headers.get("X-Stainless-Lang") == "python" + + proxy = response.parse() + assert_matches_type(ProxyCheckResponse, proxy, path=["response"]) + + assert cast(Any, response.is_closed) is True + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + def test_path_params_check(self, client: Kernel) -> None: + with pytest.raises(ValueError, match=r"Expected a non-empty value for `id` but received ''"): + client.proxies.with_raw_response.check( + id="", + ) + + +class TestAsyncProxies: + parametrize = pytest.mark.parametrize( + "async_client", [False, True, {"http_client": "aiohttp"}], indirect=True, ids=["loose", "strict", "aiohttp"] + ) + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + async def test_method_create(self, async_client: AsyncKernel) -> None: + proxy = await async_client.proxies.create( + type="datacenter", + ) + assert_matches_type(ProxyCreateResponse, proxy, path=["response"]) + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + async def test_method_create_with_all_params(self, async_client: AsyncKernel) -> None: + proxy = await async_client.proxies.create( + type="datacenter", + bypass_hosts=["string"], + config={"country": "US"}, + name="name", + protocol="http", + ) + assert_matches_type(ProxyCreateResponse, proxy, path=["response"]) + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + async def test_raw_response_create(self, async_client: AsyncKernel) -> None: + response = await async_client.proxies.with_raw_response.create( + type="datacenter", + ) + + assert response.is_closed is True + assert response.http_request.headers.get("X-Stainless-Lang") == "python" + proxy = await response.parse() + assert_matches_type(ProxyCreateResponse, proxy, path=["response"]) + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + async def test_streaming_response_create(self, async_client: AsyncKernel) -> None: + async with async_client.proxies.with_streaming_response.create( + type="datacenter", + ) as response: + assert not response.is_closed + assert response.http_request.headers.get("X-Stainless-Lang") == "python" + + proxy = await response.parse() + assert_matches_type(ProxyCreateResponse, proxy, path=["response"]) + + assert cast(Any, response.is_closed) is True + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + async def test_method_retrieve(self, async_client: AsyncKernel) -> None: + proxy = await async_client.proxies.retrieve( + "id", + ) + assert_matches_type(ProxyRetrieveResponse, proxy, path=["response"]) + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + async def test_raw_response_retrieve(self, async_client: AsyncKernel) -> None: + response = await async_client.proxies.with_raw_response.retrieve( + "id", + ) + + assert response.is_closed is True + assert response.http_request.headers.get("X-Stainless-Lang") == "python" + proxy = await response.parse() + assert_matches_type(ProxyRetrieveResponse, proxy, path=["response"]) + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + async def test_streaming_response_retrieve(self, async_client: AsyncKernel) -> None: + async with async_client.proxies.with_streaming_response.retrieve( + "id", + ) as response: + assert not response.is_closed + assert response.http_request.headers.get("X-Stainless-Lang") == "python" + + proxy = await response.parse() + assert_matches_type(ProxyRetrieveResponse, proxy, path=["response"]) + + assert cast(Any, response.is_closed) is True + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + async def test_path_params_retrieve(self, async_client: AsyncKernel) -> None: + with pytest.raises(ValueError, match=r"Expected a non-empty value for `id` but received ''"): + await async_client.proxies.with_raw_response.retrieve( + "", + ) + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + async def test_method_update(self, async_client: AsyncKernel) -> None: + proxy = await async_client.proxies.update( + id="id", + name="my-renamed-proxy", + ) + assert_matches_type(ProxyUpdateResponse, proxy, path=["response"]) + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + async def test_raw_response_update(self, async_client: AsyncKernel) -> None: + response = await async_client.proxies.with_raw_response.update( + id="id", + name="my-renamed-proxy", + ) + + assert response.is_closed is True + assert response.http_request.headers.get("X-Stainless-Lang") == "python" + proxy = await response.parse() + assert_matches_type(ProxyUpdateResponse, proxy, path=["response"]) + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + async def test_streaming_response_update(self, async_client: AsyncKernel) -> None: + async with async_client.proxies.with_streaming_response.update( + id="id", + name="my-renamed-proxy", + ) as response: + assert not response.is_closed + assert response.http_request.headers.get("X-Stainless-Lang") == "python" + + proxy = await response.parse() + assert_matches_type(ProxyUpdateResponse, proxy, path=["response"]) + + assert cast(Any, response.is_closed) is True + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + async def test_path_params_update(self, async_client: AsyncKernel) -> None: + with pytest.raises(ValueError, match=r"Expected a non-empty value for `id` but received ''"): + await async_client.proxies.with_raw_response.update( + id="", + name="my-renamed-proxy", + ) + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + async def test_method_list(self, async_client: AsyncKernel) -> None: + proxy = await async_client.proxies.list() + assert_matches_type(AsyncOffsetPagination[ProxyListResponse], proxy, path=["response"]) + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + async def test_method_list_with_all_params(self, async_client: AsyncKernel) -> None: + proxy = await async_client.proxies.list( + limit=1, + name="name", + offset=0, + query="query", + ) + assert_matches_type(AsyncOffsetPagination[ProxyListResponse], proxy, path=["response"]) + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + async def test_raw_response_list(self, async_client: AsyncKernel) -> None: + response = await async_client.proxies.with_raw_response.list() + + assert response.is_closed is True + assert response.http_request.headers.get("X-Stainless-Lang") == "python" + proxy = await response.parse() + assert_matches_type(AsyncOffsetPagination[ProxyListResponse], proxy, path=["response"]) + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + async def test_streaming_response_list(self, async_client: AsyncKernel) -> None: + async with async_client.proxies.with_streaming_response.list() as response: + assert not response.is_closed + assert response.http_request.headers.get("X-Stainless-Lang") == "python" + + proxy = await response.parse() + assert_matches_type(AsyncOffsetPagination[ProxyListResponse], proxy, path=["response"]) + + assert cast(Any, response.is_closed) is True + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + async def test_method_delete(self, async_client: AsyncKernel) -> None: + proxy = await async_client.proxies.delete( + "id", + ) + assert proxy is None + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + async def test_raw_response_delete(self, async_client: AsyncKernel) -> None: + response = await async_client.proxies.with_raw_response.delete( + "id", + ) + + assert response.is_closed is True + assert response.http_request.headers.get("X-Stainless-Lang") == "python" + proxy = await response.parse() + assert proxy is None + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + async def test_streaming_response_delete(self, async_client: AsyncKernel) -> None: + async with async_client.proxies.with_streaming_response.delete( + "id", + ) as response: + assert not response.is_closed + assert response.http_request.headers.get("X-Stainless-Lang") == "python" + + proxy = await response.parse() + assert proxy is None + + assert cast(Any, response.is_closed) is True + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + async def test_path_params_delete(self, async_client: AsyncKernel) -> None: + with pytest.raises(ValueError, match=r"Expected a non-empty value for `id` but received ''"): + await async_client.proxies.with_raw_response.delete( + "", + ) + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + async def test_method_check(self, async_client: AsyncKernel) -> None: + proxy = await async_client.proxies.check( + id="id", + ) + assert_matches_type(ProxyCheckResponse, proxy, path=["response"]) + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + async def test_method_check_with_all_params(self, async_client: AsyncKernel) -> None: + proxy = await async_client.proxies.check( + id="id", + url="https://example.com", + ) + assert_matches_type(ProxyCheckResponse, proxy, path=["response"]) + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + async def test_raw_response_check(self, async_client: AsyncKernel) -> None: + response = await async_client.proxies.with_raw_response.check( + id="id", + ) + + assert response.is_closed is True + assert response.http_request.headers.get("X-Stainless-Lang") == "python" + proxy = await response.parse() + assert_matches_type(ProxyCheckResponse, proxy, path=["response"]) + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + async def test_streaming_response_check(self, async_client: AsyncKernel) -> None: + async with async_client.proxies.with_streaming_response.check( + id="id", + ) as response: + assert not response.is_closed + assert response.http_request.headers.get("X-Stainless-Lang") == "python" + + proxy = await response.parse() + assert_matches_type(ProxyCheckResponse, proxy, path=["response"]) + + assert cast(Any, response.is_closed) is True + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + async def test_path_params_check(self, async_client: AsyncKernel) -> None: + with pytest.raises(ValueError, match=r"Expected a non-empty value for `id` but received ''"): + await async_client.proxies.with_raw_response.check( + id="", + ) diff --git a/tests/conftest.py b/tests/conftest.py new file mode 100644 index 00000000..c860af02 --- /dev/null +++ b/tests/conftest.py @@ -0,0 +1,84 @@ +# 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 kernel import Kernel, AsyncKernel, DefaultAioHttpClient +from kernel._utils import is_dict + +if TYPE_CHECKING: + from _pytest.fixtures import FixtureRequest # pyright: ignore[reportPrivateImportUsage] + +pytest.register_assert_rewrite("tests.utils") + +logging.getLogger("kernel").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") + +api_key = "My API Key" + + +@pytest.fixture(scope="session") +def client(request: FixtureRequest) -> Iterator[Kernel]: + strict = getattr(request, "param", True) + if not isinstance(strict, bool): + raise TypeError(f"Unexpected fixture parameter type {type(strict)}, expected {bool}") + + with Kernel(base_url=base_url, api_key=api_key, _strict_response_validation=strict) as client: + yield client + + +@pytest.fixture(scope="session") +async def async_client(request: FixtureRequest) -> AsyncIterator[AsyncKernel]: + 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 AsyncKernel( + base_url=base_url, api_key=api_key, _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 00000000..af5626b4 --- /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 00000000..e6005cbf --- /dev/null +++ b/tests/test_client.py @@ -0,0 +1,1980 @@ +# 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 asyncio +import inspect +import dataclasses +import tracemalloc +from typing import Any, Union, TypeVar, Callable, Iterable, Iterator, Optional, Coroutine, cast +from unittest import mock +from typing_extensions import Literal, AsyncIterator, override + +import httpx +import pytest +from respx import MockRouter +from pydantic import ValidationError + +from kernel import Kernel, AsyncKernel, APIResponseValidationError +from kernel._types import Omit +from kernel._utils import asyncify +from kernel._models import BaseModel, FinalRequestOptions +from kernel._exceptions import KernelError, APIStatusError, APITimeoutError, APIResponseValidationError +from kernel._base_client import ( + DEFAULT_TIMEOUT, + HTTPX_DEFAULT_TIMEOUT, + BaseClient, + OtherPlatform, + DefaultHttpxClient, + DefaultAsyncHttpxClient, + get_platform, + make_request_options, +) + +from .utils import update_env + +T = TypeVar("T") +base_url = os.environ.get("TEST_API_BASE_URL", "http://127.0.0.1:4010") +api_key = "My API Key" + + +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 mirror_request_content(request: httpx.Request) -> httpx.Response: + return httpx.Response(200, content=request.content) + + +# note: we can't use the httpx.MockTransport class as it consumes the request +# body itself, which means we can't test that the body is read lazily +class MockTransport(httpx.BaseTransport, httpx.AsyncBaseTransport): + def __init__( + self, + handler: Callable[[httpx.Request], httpx.Response] + | Callable[[httpx.Request], Coroutine[Any, Any, httpx.Response]], + ) -> None: + self.handler = handler + + @override + def handle_request( + self, + request: httpx.Request, + ) -> httpx.Response: + assert not inspect.iscoroutinefunction(self.handler), "handler must not be a coroutine function" + assert inspect.isfunction(self.handler), "handler must be a function" + return self.handler(request) + + @override + async def handle_async_request( + self, + request: httpx.Request, + ) -> httpx.Response: + assert inspect.iscoroutinefunction(self.handler), "handler must be a coroutine function" + return await self.handler(request) + + +@dataclasses.dataclass +class Counter: + value: int = 0 + + +def _make_sync_iterator(iterable: Iterable[T], counter: Optional[Counter] = None) -> Iterator[T]: + for item in iterable: + if counter: + counter.value += 1 + yield item + + +async def _make_async_iterator(iterable: Iterable[T], counter: Optional[Counter] = None) -> AsyncIterator[T]: + for item in iterable: + if counter: + counter.value += 1 + yield item + + +def _get_open_connections(client: Kernel | AsyncKernel) -> int: + transport = client._client._transport + assert isinstance(transport, httpx.HTTPTransport) or isinstance(transport, httpx.AsyncHTTPTransport) + + pool = transport._pool + return len(pool._requests) + + +class TestKernel: + @pytest.mark.respx(base_url=base_url) + def test_raw_response(self, respx_mock: MockRouter, client: Kernel) -> None: + respx_mock.post("/foo").mock(return_value=httpx.Response(200, json={"foo": "bar"})) + + 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, client: Kernel) -> None: + respx_mock.post("/foo").mock( + return_value=httpx.Response(200, headers={"Content-Type": "application/binary"}, content='{"foo": "bar"}') + ) + + 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, client: Kernel) -> None: + copied = client.copy() + assert id(copied) != id(client) + + copied = client.copy(api_key="another My API Key") + assert copied.api_key == "another My API Key" + assert client.api_key == "My API Key" + + def test_copy_default_options(self, client: Kernel) -> None: + # options that have a default are overridden correctly + copied = client.copy(max_retries=7) + assert copied.max_retries == 7 + assert client.max_retries == 2 + + copied2 = copied.copy(max_retries=6) + assert copied2.max_retries == 6 + assert copied.max_retries == 7 + + # timeout + assert isinstance(client.timeout, httpx.Timeout) + copied = client.copy(timeout=None) + assert copied.timeout is None + assert isinstance(client.timeout, httpx.Timeout) + + def test_copy_default_headers(self) -> None: + client = Kernel( + base_url=base_url, api_key=api_key, _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"}) + client.close() + + def test_copy_default_query(self) -> None: + client = Kernel( + base_url=base_url, api_key=api_key, _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"}) + + client.close() + + def test_copy_signature(self, client: Kernel) -> 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. + client.__init__, # type: ignore[misc] + ) + copy_signature = inspect.signature(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, client: Kernel) -> None: + options = FinalRequestOptions(method="get", url="/foo") + + def build_request(options: FinalRequestOptions) -> None: + client_copy = client.copy() + client_copy._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. + "kernel/_legacy_response.py", + "kernel/_response.py", + # pydantic.BaseModel.model_dump || pydantic.BaseModel.dict leak memory for some reason. + "kernel/_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, client: Kernel) -> None: + request = client._build_request(FinalRequestOptions(method="get", url="/foo")) + timeout = httpx.Timeout(**request.extensions["timeout"]) # type: ignore + assert timeout == DEFAULT_TIMEOUT + + 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) + + def test_client_timeout_option(self) -> None: + client = Kernel(base_url=base_url, api_key=api_key, _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) + + 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: + client = Kernel( + base_url=base_url, api_key=api_key, _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) + + client.close() + + # no timeout given to the httpx client should not use the httpx default + with httpx.Client() as http_client: + client = Kernel( + base_url=base_url, api_key=api_key, _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 + + client.close() + + # explicitly passing the default timeout currently results in it being ignored + with httpx.Client(timeout=HTTPX_DEFAULT_TIMEOUT) as http_client: + client = Kernel( + base_url=base_url, api_key=api_key, _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 + + 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: + Kernel( + base_url=base_url, + api_key=api_key, + _strict_response_validation=True, + http_client=cast(Any, http_client), + ) + + def test_default_headers_option(self) -> None: + test_client = Kernel( + base_url=base_url, api_key=api_key, _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" + + test_client2 = Kernel( + base_url=base_url, + api_key=api_key, + _strict_response_validation=True, + default_headers={ + "X-Foo": "stainless", + "X-Stainless-Lang": "my-overriding-header", + }, + ) + 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_validate_headers(self) -> None: + client = Kernel(base_url=base_url, api_key=api_key, _strict_response_validation=True) + request = client._build_request(FinalRequestOptions(method="get", url="/foo")) + assert request.headers.get("Authorization") == f"Bearer {api_key}" + + with pytest.raises(KernelError): + with update_env(**{"KERNEL_API_KEY": Omit()}): + client2 = Kernel(base_url=base_url, api_key=None, _strict_response_validation=True) + _ = client2 + + def test_default_query_option(self) -> None: + client = Kernel( + base_url=base_url, api_key=api_key, _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"} + + client.close() + + def test_hardcoded_query_params_in_url(self, client: Kernel) -> None: + request = client._build_request(FinalRequestOptions(method="get", url="/foo?beta=true")) + url = httpx.URL(request.url) + assert dict(url.params) == {"beta": "true"} + + request = client._build_request( + FinalRequestOptions( + method="get", + url="/foo?beta=true", + params={"limit": "10", "page": "abc"}, + ) + ) + url = httpx.URL(request.url) + assert dict(url.params) == {"beta": "true", "limit": "10", "page": "abc"} + + request = client._build_request( + FinalRequestOptions( + method="get", + url="/files/a%2Fb?beta=true", + params={"limit": "10"}, + ) + ) + assert request.url.raw_path == b"/files/a%2Fb?beta=true&limit=10" + + def test_request_extra_json(self, client: Kernel) -> None: + request = 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 = 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 = 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, client: Kernel) -> None: + request = 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 = 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, client: Kernel) -> None: + request = 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 = 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 = 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: Kernel) -> 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_binary_content_upload(self, respx_mock: MockRouter, client: Kernel) -> None: + respx_mock.post("/upload").mock(side_effect=mirror_request_content) + + file_content = b"Hello, this is a test file." + + response = client.post( + "/upload", + content=file_content, + cast_to=httpx.Response, + options={"headers": {"Content-Type": "application/octet-stream"}}, + ) + + assert response.status_code == 200 + assert response.request.headers["Content-Type"] == "application/octet-stream" + assert response.content == file_content + + def test_binary_content_upload_with_iterator(self) -> None: + file_content = b"Hello, this is a test file." + counter = Counter() + iterator = _make_sync_iterator([file_content], counter=counter) + + def mock_handler(request: httpx.Request) -> httpx.Response: + assert counter.value == 0, "the request body should not have been read" + return httpx.Response(200, content=request.read()) + + with Kernel( + base_url=base_url, + api_key=api_key, + _strict_response_validation=True, + http_client=httpx.Client(transport=MockTransport(handler=mock_handler)), + ) as client: + response = client.post( + "/upload", + content=iterator, + cast_to=httpx.Response, + options={"headers": {"Content-Type": "application/octet-stream"}}, + ) + + assert response.status_code == 200 + assert response.request.headers["Content-Type"] == "application/octet-stream" + assert response.content == file_content + assert counter.value == 1 + + @pytest.mark.respx(base_url=base_url) + def test_binary_content_upload_with_body_is_deprecated(self, respx_mock: MockRouter, client: Kernel) -> None: + respx_mock.post("/upload").mock(side_effect=mirror_request_content) + + file_content = b"Hello, this is a test file." + + with pytest.deprecated_call( + match="Passing raw bytes as `body` is deprecated and will be removed in a future version. Please pass raw bytes via the `content` parameter instead." + ): + response = client.post( + "/upload", + body=file_content, + cast_to=httpx.Response, + options={"headers": {"Content-Type": "application/octet-stream"}}, + ) + + assert response.status_code == 200 + assert response.request.headers["Content-Type"] == "application/octet-stream" + assert response.content == file_content + + @pytest.mark.respx(base_url=base_url) + def test_basic_union_response(self, respx_mock: MockRouter, client: Kernel) -> 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 = 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, client: Kernel) -> 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 = 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 = 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, client: Kernel) -> 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 = client.get("/foo", cast_to=Model) + assert isinstance(response, Model) + assert response.foo == 2 + + def test_base_url_setter(self) -> None: + client = Kernel(base_url="https://example.com/from_init", api_key=api_key, _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/" + + client.close() + + def test_base_url_env(self) -> None: + with update_env(KERNEL_BASE_URL="http://localhost:5000/from/env"): + client = Kernel(api_key=api_key, _strict_response_validation=True) + assert client.base_url == "http://localhost:5000/from/env/" + + # explicit environment arg requires explicitness + with update_env(KERNEL_BASE_URL="http://localhost:5000/from/env"): + with pytest.raises(ValueError, match=r"you must pass base_url=None"): + Kernel(api_key=api_key, _strict_response_validation=True, environment="production") + + client = Kernel(base_url=None, api_key=api_key, _strict_response_validation=True, environment="production") + assert str(client.base_url).startswith("https://api.onkernel.com/") + + client.close() + + @pytest.mark.parametrize( + "client", + [ + Kernel(base_url="http://localhost:5000/custom/path/", api_key=api_key, _strict_response_validation=True), + Kernel( + base_url="http://localhost:5000/custom/path/", + api_key=api_key, + _strict_response_validation=True, + http_client=httpx.Client(), + ), + ], + ids=["standard", "custom http client"], + ) + def test_base_url_trailing_slash(self, client: Kernel) -> None: + request = client._build_request( + FinalRequestOptions( + method="post", + url="/foo", + json_data={"foo": "bar"}, + ), + ) + assert request.url == "http://localhost:5000/custom/path/foo" + client.close() + + @pytest.mark.parametrize( + "client", + [ + Kernel(base_url="http://localhost:5000/custom/path/", api_key=api_key, _strict_response_validation=True), + Kernel( + base_url="http://localhost:5000/custom/path/", + api_key=api_key, + _strict_response_validation=True, + http_client=httpx.Client(), + ), + ], + ids=["standard", "custom http client"], + ) + def test_base_url_no_trailing_slash(self, client: Kernel) -> None: + request = client._build_request( + FinalRequestOptions( + method="post", + url="/foo", + json_data={"foo": "bar"}, + ), + ) + assert request.url == "http://localhost:5000/custom/path/foo" + client.close() + + @pytest.mark.parametrize( + "client", + [ + Kernel(base_url="http://localhost:5000/custom/path/", api_key=api_key, _strict_response_validation=True), + Kernel( + base_url="http://localhost:5000/custom/path/", + api_key=api_key, + _strict_response_validation=True, + http_client=httpx.Client(), + ), + ], + ids=["standard", "custom http client"], + ) + def test_absolute_request_url(self, client: Kernel) -> None: + request = client._build_request( + FinalRequestOptions( + method="post", + url="https://myapi.com/foo", + json_data={"foo": "bar"}, + ), + ) + assert request.url == "https://myapi.com/foo" + client.close() + + def test_copied_client_does_not_close_http(self) -> None: + test_client = Kernel(base_url=base_url, api_key=api_key, _strict_response_validation=True) + assert not test_client.is_closed() + + copied = test_client.copy() + assert copied is not test_client + + del copied + + assert not test_client.is_closed() + + def test_client_context_manager(self) -> None: + test_client = Kernel(base_url=base_url, api_key=api_key, _strict_response_validation=True) + with test_client as c2: + assert c2 is test_client + assert not c2.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, client: Kernel) -> 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: + 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"): + Kernel(base_url=base_url, api_key=api_key, _strict_response_validation=True, max_retries=cast(Any, None)) + + @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 = Kernel(base_url=base_url, api_key=api_key, _strict_response_validation=True) + + with pytest.raises(APIResponseValidationError): + strict_client.get("/foo", cast_to=Model) + + non_strict_client = Kernel(base_url=base_url, api_key=api_key, _strict_response_validation=False) + + 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", + [ + [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, client: Kernel + ) -> 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) + assert calculated == pytest.approx(timeout, 0.5 * 0.875) # pyright: ignore[reportUnknownMemberType] + + @mock.patch("kernel._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: Kernel) -> None: + respx_mock.post("/browsers").mock(side_effect=httpx.TimeoutException("Test timeout error")) + + with pytest.raises(APITimeoutError): + client.browsers.with_streaming_response.create().__enter__() + + assert _get_open_connections(client) == 0 + + @mock.patch("kernel._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: Kernel) -> None: + respx_mock.post("/browsers").mock(return_value=httpx.Response(500)) + + with pytest.raises(APIStatusError): + client.browsers.with_streaming_response.create().__enter__() + assert _get_open_connections(client) == 0 + + @pytest.mark.parametrize("failures_before_success", [0, 2, 4]) + @mock.patch("kernel._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: Kernel, + 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.post("/browsers").mock(side_effect=retry_handler) + + response = client.browsers.with_raw_response.create() + + 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("kernel._base_client.BaseClient._calculate_retry_timeout", _low_retry_timeout) + @pytest.mark.respx(base_url=base_url) + def test_omit_retry_count_header( + self, client: Kernel, 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.post("/browsers").mock(side_effect=retry_handler) + + response = client.browsers.with_raw_response.create(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("kernel._base_client.BaseClient._calculate_retry_timeout", _low_retry_timeout) + @pytest.mark.respx(base_url=base_url) + def test_overwrite_retry_count_header( + self, client: Kernel, 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.post("/browsers").mock(side_effect=retry_handler) + + response = client.browsers.with_raw_response.create(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") + # Delete in case our environment has any proxy env vars set + monkeypatch.delenv("HTTP_PROXY", raising=False) + monkeypatch.delenv("ALL_PROXY", raising=False) + monkeypatch.delenv("NO_PROXY", raising=False) + monkeypatch.delenv("http_proxy", raising=False) + monkeypatch.delenv("https_proxy", raising=False) + monkeypatch.delenv("all_proxy", raising=False) + monkeypatch.delenv("no_proxy", raising=False) + + 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, client: Kernel) -> 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 = 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, client: Kernel) -> 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: + 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 TestAsyncKernel: + @pytest.mark.respx(base_url=base_url) + async def test_raw_response(self, respx_mock: MockRouter, async_client: AsyncKernel) -> None: + respx_mock.post("/foo").mock(return_value=httpx.Response(200, json={"foo": "bar"})) + + 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) + async def test_raw_response_for_binary(self, respx_mock: MockRouter, async_client: AsyncKernel) -> None: + respx_mock.post("/foo").mock( + return_value=httpx.Response(200, headers={"Content-Type": "application/binary"}, content='{"foo": "bar"}') + ) + + 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, async_client: AsyncKernel) -> None: + copied = async_client.copy() + assert id(copied) != id(async_client) + + copied = async_client.copy(api_key="another My API Key") + assert copied.api_key == "another My API Key" + assert async_client.api_key == "My API Key" + + def test_copy_default_options(self, async_client: AsyncKernel) -> None: + # options that have a default are overridden correctly + copied = async_client.copy(max_retries=7) + assert copied.max_retries == 7 + 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(async_client.timeout, httpx.Timeout) + copied = async_client.copy(timeout=None) + assert copied.timeout is None + assert isinstance(async_client.timeout, httpx.Timeout) + + async def test_copy_default_headers(self) -> None: + client = AsyncKernel( + base_url=base_url, api_key=api_key, _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"}) + await client.close() + + async def test_copy_default_query(self) -> None: + client = AsyncKernel( + base_url=base_url, api_key=api_key, _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"}) + + await client.close() + + def test_copy_signature(self, async_client: AsyncKernel) -> 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. + async_client.__init__, # type: ignore[misc] + ) + copy_signature = inspect.signature(async_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, async_client: AsyncKernel) -> None: + options = FinalRequestOptions(method="get", url="/foo") + + def build_request(options: FinalRequestOptions) -> None: + client_copy = async_client.copy() + client_copy._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. + "kernel/_legacy_response.py", + "kernel/_response.py", + # pydantic.BaseModel.model_dump || pydantic.BaseModel.dict leak memory for some reason. + "kernel/_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, async_client: AsyncKernel) -> None: + request = async_client._build_request(FinalRequestOptions(method="get", url="/foo")) + timeout = httpx.Timeout(**request.extensions["timeout"]) # type: ignore + assert timeout == DEFAULT_TIMEOUT + + request = async_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 = AsyncKernel( + base_url=base_url, api_key=api_key, _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) + + 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: + client = AsyncKernel( + base_url=base_url, api_key=api_key, _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) + + await client.close() + + # no timeout given to the httpx client should not use the httpx default + async with httpx.AsyncClient() as http_client: + client = AsyncKernel( + base_url=base_url, api_key=api_key, _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 + + 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 = AsyncKernel( + base_url=base_url, api_key=api_key, _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 + + 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: + AsyncKernel( + base_url=base_url, + api_key=api_key, + _strict_response_validation=True, + http_client=cast(Any, http_client), + ) + + async def test_default_headers_option(self) -> None: + test_client = AsyncKernel( + base_url=base_url, api_key=api_key, _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" + + test_client2 = AsyncKernel( + base_url=base_url, + api_key=api_key, + _strict_response_validation=True, + default_headers={ + "X-Foo": "stainless", + "X-Stainless-Lang": "my-overriding-header", + }, + ) + 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" + + await test_client.close() + await test_client2.close() + + def test_validate_headers(self) -> None: + client = AsyncKernel(base_url=base_url, api_key=api_key, _strict_response_validation=True) + request = client._build_request(FinalRequestOptions(method="get", url="/foo")) + assert request.headers.get("Authorization") == f"Bearer {api_key}" + + with pytest.raises(KernelError): + with update_env(**{"KERNEL_API_KEY": Omit()}): + client2 = AsyncKernel(base_url=base_url, api_key=None, _strict_response_validation=True) + _ = client2 + + async def test_default_query_option(self) -> None: + client = AsyncKernel( + base_url=base_url, api_key=api_key, _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"} + + await client.close() + + async def test_hardcoded_query_params_in_url(self, async_client: AsyncKernel) -> None: + request = async_client._build_request(FinalRequestOptions(method="get", url="/foo?beta=true")) + url = httpx.URL(request.url) + assert dict(url.params) == {"beta": "true"} + + request = async_client._build_request( + FinalRequestOptions( + method="get", + url="/foo?beta=true", + params={"limit": "10", "page": "abc"}, + ) + ) + url = httpx.URL(request.url) + assert dict(url.params) == {"beta": "true", "limit": "10", "page": "abc"} + + request = async_client._build_request( + FinalRequestOptions( + method="get", + url="/files/a%2Fb?beta=true", + params={"limit": "10"}, + ) + ) + assert request.url.raw_path == b"/files/a%2Fb?beta=true&limit=10" + + def test_request_extra_json(self, client: Kernel) -> None: + request = 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 = 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 = 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, client: Kernel) -> None: + request = 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 = 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, client: Kernel) -> None: + request = 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 = 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 = 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: AsyncKernel) -> 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_binary_content_upload(self, respx_mock: MockRouter, async_client: AsyncKernel) -> None: + respx_mock.post("/upload").mock(side_effect=mirror_request_content) + + file_content = b"Hello, this is a test file." + + response = await async_client.post( + "/upload", + content=file_content, + cast_to=httpx.Response, + options={"headers": {"Content-Type": "application/octet-stream"}}, + ) + + assert response.status_code == 200 + assert response.request.headers["Content-Type"] == "application/octet-stream" + assert response.content == file_content + + async def test_binary_content_upload_with_asynciterator(self) -> None: + file_content = b"Hello, this is a test file." + counter = Counter() + iterator = _make_async_iterator([file_content], counter=counter) + + async def mock_handler(request: httpx.Request) -> httpx.Response: + assert counter.value == 0, "the request body should not have been read" + return httpx.Response(200, content=await request.aread()) + + async with AsyncKernel( + base_url=base_url, + api_key=api_key, + _strict_response_validation=True, + http_client=httpx.AsyncClient(transport=MockTransport(handler=mock_handler)), + ) as client: + response = await client.post( + "/upload", + content=iterator, + cast_to=httpx.Response, + options={"headers": {"Content-Type": "application/octet-stream"}}, + ) + + assert response.status_code == 200 + assert response.request.headers["Content-Type"] == "application/octet-stream" + assert response.content == file_content + assert counter.value == 1 + + @pytest.mark.respx(base_url=base_url) + async def test_binary_content_upload_with_body_is_deprecated( + self, respx_mock: MockRouter, async_client: AsyncKernel + ) -> None: + respx_mock.post("/upload").mock(side_effect=mirror_request_content) + + file_content = b"Hello, this is a test file." + + with pytest.deprecated_call( + match="Passing raw bytes as `body` is deprecated and will be removed in a future version. Please pass raw bytes via the `content` parameter instead." + ): + response = await async_client.post( + "/upload", + body=file_content, + cast_to=httpx.Response, + options={"headers": {"Content-Type": "application/octet-stream"}}, + ) + + assert response.status_code == 200 + assert response.request.headers["Content-Type"] == "application/octet-stream" + assert response.content == file_content + + @pytest.mark.respx(base_url=base_url) + async def test_basic_union_response(self, respx_mock: MockRouter, async_client: AsyncKernel) -> 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 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, async_client: AsyncKernel) -> 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 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 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, async_client: AsyncKernel + ) -> 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 async_client.get("/foo", cast_to=Model) + assert isinstance(response, Model) + assert response.foo == 2 + + async def test_base_url_setter(self) -> None: + client = AsyncKernel( + base_url="https://example.com/from_init", api_key=api_key, _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/" + + await client.close() + + async def test_base_url_env(self) -> None: + with update_env(KERNEL_BASE_URL="http://localhost:5000/from/env"): + client = AsyncKernel(api_key=api_key, _strict_response_validation=True) + assert client.base_url == "http://localhost:5000/from/env/" + + # explicit environment arg requires explicitness + with update_env(KERNEL_BASE_URL="http://localhost:5000/from/env"): + with pytest.raises(ValueError, match=r"you must pass base_url=None"): + AsyncKernel(api_key=api_key, _strict_response_validation=True, environment="production") + + client = AsyncKernel( + base_url=None, api_key=api_key, _strict_response_validation=True, environment="production" + ) + assert str(client.base_url).startswith("https://api.onkernel.com/") + + await client.close() + + @pytest.mark.parametrize( + "client", + [ + AsyncKernel( + base_url="http://localhost:5000/custom/path/", api_key=api_key, _strict_response_validation=True + ), + AsyncKernel( + base_url="http://localhost:5000/custom/path/", + api_key=api_key, + _strict_response_validation=True, + http_client=httpx.AsyncClient(), + ), + ], + ids=["standard", "custom http client"], + ) + async def test_base_url_trailing_slash(self, client: AsyncKernel) -> None: + request = client._build_request( + FinalRequestOptions( + method="post", + url="/foo", + json_data={"foo": "bar"}, + ), + ) + assert request.url == "http://localhost:5000/custom/path/foo" + await client.close() + + @pytest.mark.parametrize( + "client", + [ + AsyncKernel( + base_url="http://localhost:5000/custom/path/", api_key=api_key, _strict_response_validation=True + ), + AsyncKernel( + base_url="http://localhost:5000/custom/path/", + api_key=api_key, + _strict_response_validation=True, + http_client=httpx.AsyncClient(), + ), + ], + ids=["standard", "custom http client"], + ) + async def test_base_url_no_trailing_slash(self, client: AsyncKernel) -> None: + request = client._build_request( + FinalRequestOptions( + method="post", + url="/foo", + json_data={"foo": "bar"}, + ), + ) + assert request.url == "http://localhost:5000/custom/path/foo" + await client.close() + + @pytest.mark.parametrize( + "client", + [ + AsyncKernel( + base_url="http://localhost:5000/custom/path/", api_key=api_key, _strict_response_validation=True + ), + AsyncKernel( + base_url="http://localhost:5000/custom/path/", + api_key=api_key, + _strict_response_validation=True, + http_client=httpx.AsyncClient(), + ), + ], + ids=["standard", "custom http client"], + ) + async def test_absolute_request_url(self, client: AsyncKernel) -> None: + request = client._build_request( + FinalRequestOptions( + method="post", + url="https://myapi.com/foo", + json_data={"foo": "bar"}, + ), + ) + assert request.url == "https://myapi.com/foo" + await client.close() + + async def test_copied_client_does_not_close_http(self) -> None: + test_client = AsyncKernel(base_url=base_url, api_key=api_key, _strict_response_validation=True) + assert not test_client.is_closed() + + copied = test_client.copy() + assert copied is not test_client + + del copied + + await asyncio.sleep(0.2) + assert not test_client.is_closed() + + async def test_client_context_manager(self) -> None: + test_client = AsyncKernel(base_url=base_url, api_key=api_key, _strict_response_validation=True) + async with test_client as c2: + assert c2 is test_client + assert not c2.is_closed() + assert not test_client.is_closed() + assert test_client.is_closed() + + @pytest.mark.respx(base_url=base_url) + async def test_client_response_validation_error(self, respx_mock: MockRouter, async_client: AsyncKernel) -> 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 async_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"): + AsyncKernel( + base_url=base_url, api_key=api_key, _strict_response_validation=True, max_retries=cast(Any, None) + ) + + @pytest.mark.respx(base_url=base_url) + 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 = AsyncKernel(base_url=base_url, api_key=api_key, _strict_response_validation=True) + + with pytest.raises(APIResponseValidationError): + await strict_client.get("/foo", cast_to=Model) + + non_strict_client = AsyncKernel(base_url=base_url, api_key=api_key, _strict_response_validation=False) + + 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", + [ + [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)) + async def test_parse_retry_after_header( + self, remaining_retries: int, retry_after: str, timeout: float, async_client: AsyncKernel + ) -> None: + headers = httpx.Headers({"retry-after": retry_after}) + options = FinalRequestOptions(method="get", url="/foo", max_retries=3) + calculated = async_client._calculate_retry_timeout(remaining_retries, options, headers) + assert calculated == pytest.approx(timeout, 0.5 * 0.875) # pyright: ignore[reportUnknownMemberType] + + @mock.patch("kernel._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: AsyncKernel) -> None: + respx_mock.post("/browsers").mock(side_effect=httpx.TimeoutException("Test timeout error")) + + with pytest.raises(APITimeoutError): + await async_client.browsers.with_streaming_response.create().__aenter__() + + assert _get_open_connections(async_client) == 0 + + @mock.patch("kernel._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: AsyncKernel) -> None: + respx_mock.post("/browsers").mock(return_value=httpx.Response(500)) + + with pytest.raises(APIStatusError): + await async_client.browsers.with_streaming_response.create().__aenter__() + assert _get_open_connections(async_client) == 0 + + @pytest.mark.parametrize("failures_before_success", [0, 2, 4]) + @mock.patch("kernel._base_client.BaseClient._calculate_retry_timeout", _low_retry_timeout) + @pytest.mark.respx(base_url=base_url) + @pytest.mark.parametrize("failure_mode", ["status", "exception"]) + async def test_retries_taken( + self, + async_client: AsyncKernel, + 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.post("/browsers").mock(side_effect=retry_handler) + + response = await client.browsers.with_raw_response.create() + + 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("kernel._base_client.BaseClient._calculate_retry_timeout", _low_retry_timeout) + @pytest.mark.respx(base_url=base_url) + async def test_omit_retry_count_header( + self, async_client: AsyncKernel, 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.post("/browsers").mock(side_effect=retry_handler) + + response = await client.browsers.with_raw_response.create(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("kernel._base_client.BaseClient._calculate_retry_timeout", _low_retry_timeout) + @pytest.mark.respx(base_url=base_url) + async def test_overwrite_retry_count_header( + self, async_client: AsyncKernel, 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.post("/browsers").mock(side_effect=retry_handler) + + response = await client.browsers.with_raw_response.create(extra_headers={"x-stainless-retry-count": "42"}) + + assert response.http_request.headers.get("x-stainless-retry-count") == "42" + + 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 + monkeypatch.setenv("HTTPS_PROXY", "https://example.org") + # Delete in case our environment has any proxy env vars set + monkeypatch.delenv("HTTP_PROXY", raising=False) + monkeypatch.delenv("ALL_PROXY", raising=False) + monkeypatch.delenv("NO_PROXY", raising=False) + monkeypatch.delenv("http_proxy", raising=False) + monkeypatch.delenv("https_proxy", raising=False) + monkeypatch.delenv("all_proxy", raising=False) + monkeypatch.delenv("no_proxy", raising=False) + + 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, async_client: AsyncKernel) -> 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 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, async_client: AsyncKernel) -> 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 async_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_extract_files.py b/tests/test_extract_files.py new file mode 100644 index 00000000..54ef03af --- /dev/null +++ b/tests/test_extract_files.py @@ -0,0 +1,91 @@ +from __future__ import annotations + +from typing import Sequence + +import pytest + +from kernel._types import FileTypes, ArrayFormat +from kernel._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": [{}, {}]} + + +def test_top_level_file_array() -> None: + query = {"files": [b"file one", b"file two"], "title": "hello"} + assert extract_files(query, paths=[["files", ""]]) == [("files[]", b"file one"), ("files[]", b"file two")] + assert query == {"title": "hello"} + + +@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 + + +@pytest.mark.parametrize( + "array_format,expected_top_level,expected_nested", + [ + ("brackets", [("files[]", b"a"), ("files[]", b"b")], [("items[][file]", b"a"), ("items[][file]", b"b")]), + ("repeat", [("files", b"a"), ("files", b"b")], [("items[file]", b"a"), ("items[file]", b"b")]), + ("comma", [("files", b"a"), ("files", b"b")], [("items[file]", b"a"), ("items[file]", b"b")]), + ("indices", [("files[0]", b"a"), ("files[1]", b"b")], [("items[0][file]", b"a"), ("items[1][file]", b"b")]), + ], +) +def test_array_format_controls_file_field_names( + array_format: ArrayFormat, + expected_top_level: list[tuple[str, FileTypes]], + expected_nested: list[tuple[str, FileTypes]], +) -> None: + top_level = {"files": [b"a", b"b"]} + assert extract_files(top_level, paths=[["files", ""]], array_format=array_format) == expected_top_level + + nested = {"items": [{"file": b"a"}, {"file": b"b"}]} + assert extract_files(nested, paths=[["items", "", "file"]], array_format=array_format) == expected_nested diff --git a/tests/test_files.py b/tests/test_files.py new file mode 100644 index 00000000..2bbbef20 --- /dev/null +++ b/tests/test_files.py @@ -0,0 +1,148 @@ +from pathlib import Path + +import anyio +import pytest +from dirty_equals import IsDict, IsList, IsBytes, IsTuple + +from kernel._files import to_httpx_files, deepcopy_with_paths, async_to_httpx_files +from kernel._utils import extract_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 + } + ) + + +def assert_different_identities(obj1: object, obj2: object) -> None: + assert obj1 == obj2 + assert obj1 is not obj2 + + +class TestDeepcopyWithPaths: + def test_copies_top_level_dict(self) -> None: + original = {"file": b"data", "other": "value"} + result = deepcopy_with_paths(original, [["file"]]) + assert_different_identities(result, original) + + def test_file_value_is_same_reference(self) -> None: + file_bytes = b"contents" + original = {"file": file_bytes} + result = deepcopy_with_paths(original, [["file"]]) + assert_different_identities(result, original) + assert result["file"] is file_bytes + + def test_list_popped_wholesale(self) -> None: + files = [b"f1", b"f2"] + original = {"files": files, "title": "t"} + result = deepcopy_with_paths(original, [["files", ""]]) + assert_different_identities(result, original) + result_files = result["files"] + assert isinstance(result_files, list) + assert_different_identities(result_files, files) + + def test_nested_array_path_copies_list_and_elements(self) -> None: + elem1 = {"file": b"f1", "extra": 1} + elem2 = {"file": b"f2", "extra": 2} + original = {"items": [elem1, elem2]} + result = deepcopy_with_paths(original, [["items", "", "file"]]) + assert_different_identities(result, original) + result_items = result["items"] + assert isinstance(result_items, list) + assert_different_identities(result_items, original["items"]) + assert_different_identities(result_items[0], elem1) + assert_different_identities(result_items[1], elem2) + + def test_empty_paths_returns_same_object(self) -> None: + original = {"foo": "bar"} + result = deepcopy_with_paths(original, []) + assert result is original + + def test_multiple_paths(self) -> None: + f1 = b"file1" + f2 = b"file2" + original = {"a": f1, "b": f2, "c": "unchanged"} + result = deepcopy_with_paths(original, [["a"], ["b"]]) + assert_different_identities(result, original) + assert result["a"] is f1 + assert result["b"] is f2 + assert result["c"] is original["c"] + + def test_extract_files_does_not_mutate_original_top_level(self) -> None: + file_bytes = b"contents" + original = {"file": file_bytes, "other": "value"} + + copied = deepcopy_with_paths(original, [["file"]]) + extracted = extract_files(copied, paths=[["file"]]) + + assert extracted == [("file", file_bytes)] + assert original == {"file": file_bytes, "other": "value"} + assert copied == {"other": "value"} + + def test_extract_files_does_not_mutate_original_nested_array_path(self) -> None: + file1 = b"f1" + file2 = b"f2" + original = { + "items": [ + {"file": file1, "extra": 1}, + {"file": file2, "extra": 2}, + ], + "title": "example", + } + + copied = deepcopy_with_paths(original, [["items", "", "file"]]) + extracted = extract_files(copied, paths=[["items", "", "file"]]) + + assert [entry for _, entry in extracted] == [file1, file2] + assert original == { + "items": [ + {"file": file1, "extra": 1}, + {"file": file2, "extra": 2}, + ], + "title": "example", + } + assert copied == { + "items": [ + {"extra": 1}, + {"extra": 2}, + ], + "title": "example", + } diff --git a/tests/test_models.py b/tests/test_models.py new file mode 100644 index 00000000..c3922db2 --- /dev/null +++ b/tests/test_models.py @@ -0,0 +1,1017 @@ +import json +from typing import TYPE_CHECKING, Any, Dict, List, Union, Iterable, Optional, cast +from datetime import datetime, timezone +from collections import deque +from typing_extensions import Literal, Annotated, TypedDict, TypeAliasType + +import pytest +import pydantic +from pydantic import Field + +from kernel._utils import PropertyInfo +from kernel._compat import PYDANTIC_V1, parse_obj, model_dump, model_json +from kernel._models import DISCRIMINATOR_CACHE, BaseModel, EagerIterable, 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_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: + 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_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 + 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 PYDANTIC_V1: + 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 PYDANTIC_V1: + 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_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()) == {} + 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 PYDANTIC_V1: + 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 PYDANTIC_V1: + 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_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: + 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_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: + 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 DISCRIMINATOR_CACHE.get(UnionType) + + 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 = DISCRIMINATOR_CACHE.get(UnionType) + 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 DISCRIMINATOR_CACHE.get(UnionType) is discriminator + + +@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 + + 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(PYDANTIC_V1, 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(PYDANTIC_V1, 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" + + +# NOTE: Workaround for Pydantic Iterable behavior. +# Iterable fields are replaced with a ValidatorIterator and may be consumed +# during serialization, which can cause subsequent dumps to return empty data. +# See: https://github.com/pydantic/pydantic/issues/9541 +@pytest.mark.parametrize( + "data, expected_validated", + [ + ([1, 2, 3], [1, 2, 3]), + ((1, 2, 3), (1, 2, 3)), + (set([1, 2, 3]), set([1, 2, 3])), + (iter([1, 2, 3]), [1, 2, 3]), + ([], []), + ((x for x in [1, 2, 3]), [1, 2, 3]), + (map(lambda x: x, [1, 2, 3]), [1, 2, 3]), + (frozenset([1, 2, 3]), frozenset([1, 2, 3])), + (deque([1, 2, 3]), deque([1, 2, 3])), + ], + ids=["list", "tuple", "set", "iterator", "empty", "generator", "map", "frozenset", "deque"], +) +@pytest.mark.skipif(PYDANTIC_V1, reason="this is only supported in pydantic v2") +def test_iterable_construction(data: Iterable[int], expected_validated: Iterable[int]) -> None: + class TypeWithIterable(TypedDict): + items: EagerIterable[int] + + class Model(BaseModel): + data: TypeWithIterable + + m = Model.model_validate({"data": {"items": data}}) + assert m.data["items"] == expected_validated + + # Verify repeated dumps don't lose data (the original bug) + assert m.model_dump()["data"]["items"] == list(expected_validated) + assert m.model_dump()["data"]["items"] == list(expected_validated) + + +@pytest.mark.skipif(PYDANTIC_V1, reason="this is only supported in pydantic v2") +def test_iterable_construction_str_falls_back_to_list() -> None: + # str is iterable (over chars), but str(list_of_chars) produces the list's repr + # rather than reconstructing a string from items. We special-case str to fall + # back to list instead of attempting reconstruction. + class TypeWithIterable(TypedDict): + items: EagerIterable[str] + + class Model(BaseModel): + data: TypeWithIterable + + m = Model.model_validate({"data": {"items": "hello"}}) + + # falls back to list of chars rather than calling str(["h", "e", "l", "l", "o"]) + assert m.data["items"] == ["h", "e", "l", "l", "o"] + assert m.model_dump()["data"]["items"] == ["h", "e", "l", "l", "o"] diff --git a/tests/test_qs.py b/tests/test_qs.py new file mode 100644 index 00000000..78ae641d --- /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 kernel._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 00000000..7186db81 --- /dev/null +++ b/tests/test_required_args.py @@ -0,0 +1,111 @@ +from __future__ import annotations + +import pytest + +from kernel._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 00000000..bf62a9b1 --- /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 kernel import Kernel, BaseModel, AsyncKernel +from kernel._response import ( + APIResponse, + BaseAPIResponse, + AsyncAPIResponse, + BinaryAPIResponse, + AsyncBinaryAPIResponse, + extract_response_type, +) +from kernel._streaming import Stream +from kernel._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: Kernel) -> 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 kernel import BaseModel`", + ): + response.parse(to=PydanticModel) + + +@pytest.mark.asyncio +async def test_async_response_parse_mismatched_basemodel(async_client: AsyncKernel) -> 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 kernel import BaseModel`", + ): + await response.parse(to=PydanticModel) + + +def test_response_parse_custom_stream(client: Kernel) -> 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: AsyncKernel) -> 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: Kernel) -> 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: AsyncKernel) -> 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: Kernel) -> 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: AsyncKernel) -> 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: Kernel, 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: AsyncKernel, 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: Kernel) -> 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: AsyncKernel) -> 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 00000000..4b8e4e48 --- /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 kernel import Kernel, AsyncKernel +from kernel._streaming import Stream, AsyncStream, ServerSentEvent + + +@pytest.mark.asyncio +@pytest.mark.parametrize("sync", [True, False], ids=["sync", "async"]) +async def test_basic(sync: bool, client: Kernel, async_client: AsyncKernel) -> 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: Kernel, async_client: AsyncKernel) -> 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: Kernel, async_client: AsyncKernel) -> 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: Kernel, async_client: AsyncKernel) -> 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: Kernel, async_client: AsyncKernel) -> 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: Kernel, async_client: AsyncKernel) -> 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: Kernel, async_client: AsyncKernel) -> 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: Kernel, async_client: AsyncKernel) -> 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: Kernel, + async_client: AsyncKernel, +) -> 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: Kernel, + async_client: AsyncKernel, +) -> 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: Kernel, + async_client: AsyncKernel, +) -> 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 00000000..68ca9b26 --- /dev/null +++ b/tests/test_transform.py @@ -0,0 +1,460 @@ +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 kernel._types import Base64FileInput, omit, not_given +from kernel._utils import ( + PropertyInfo, + transform as _transform, + parse_datetime, + async_transform as _async_transform, +) +from kernel._compat import PYDANTIC_V1 +from kernel._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 = "+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] + + 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_V1: + params = await transform(model, Any, use_async) + else: + with pytest.warns(UserWarning): + 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_V1: + params = await transform(model, Any, use_async) + else: + with pytest.warns(UserWarning): + 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) == {} + + +@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) == {} diff --git a/tests/test_utils/test_datetime_parse.py b/tests/test_utils/test_datetime_parse.py new file mode 100644 index 00000000..f6265329 --- /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 kernel._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/test_utils/test_json.py b/tests/test_utils/test_json.py new file mode 100644 index 00000000..20e385b9 --- /dev/null +++ b/tests/test_utils/test_json.py @@ -0,0 +1,126 @@ +from __future__ import annotations + +import datetime +from typing import Union + +import pydantic + +from kernel import _compat +from kernel._utils._json import openapi_dumps + + +class TestOpenapiDumps: + def test_basic(self) -> None: + data = {"key": "value", "number": 42} + json_bytes = openapi_dumps(data) + assert json_bytes == b'{"key":"value","number":42}' + + def test_datetime_serialization(self) -> None: + dt = datetime.datetime(2023, 1, 1, 12, 0, 0) + data = {"datetime": dt} + json_bytes = openapi_dumps(data) + assert json_bytes == b'{"datetime":"2023-01-01T12:00:00"}' + + def test_pydantic_model_serialization(self) -> None: + class User(pydantic.BaseModel): + first_name: str + last_name: str + age: int + + model_instance = User(first_name="John", last_name="Kramer", age=83) + data = {"model": model_instance} + json_bytes = openapi_dumps(data) + assert json_bytes == b'{"model":{"first_name":"John","last_name":"Kramer","age":83}}' + + def test_pydantic_model_with_default_values(self) -> None: + class User(pydantic.BaseModel): + name: str + role: str = "user" + active: bool = True + score: int = 0 + + model_instance = User(name="Alice") + data = {"model": model_instance} + json_bytes = openapi_dumps(data) + assert json_bytes == b'{"model":{"name":"Alice"}}' + + def test_pydantic_model_with_default_values_overridden(self) -> None: + class User(pydantic.BaseModel): + name: str + role: str = "user" + active: bool = True + + model_instance = User(name="Bob", role="admin", active=False) + data = {"model": model_instance} + json_bytes = openapi_dumps(data) + assert json_bytes == b'{"model":{"name":"Bob","role":"admin","active":false}}' + + def test_pydantic_model_with_alias(self) -> None: + class User(pydantic.BaseModel): + first_name: str = pydantic.Field(alias="firstName") + last_name: str = pydantic.Field(alias="lastName") + + model_instance = User(firstName="John", lastName="Doe") + data = {"model": model_instance} + json_bytes = openapi_dumps(data) + assert json_bytes == b'{"model":{"firstName":"John","lastName":"Doe"}}' + + def test_pydantic_model_with_alias_and_default(self) -> None: + class User(pydantic.BaseModel): + user_name: str = pydantic.Field(alias="userName") + user_role: str = pydantic.Field(default="member", alias="userRole") + is_active: bool = pydantic.Field(default=True, alias="isActive") + + model_instance = User(userName="charlie") + data = {"model": model_instance} + json_bytes = openapi_dumps(data) + assert json_bytes == b'{"model":{"userName":"charlie"}}' + + model_with_overrides = User(userName="diana", userRole="admin", isActive=False) + data = {"model": model_with_overrides} + json_bytes = openapi_dumps(data) + assert json_bytes == b'{"model":{"userName":"diana","userRole":"admin","isActive":false}}' + + def test_pydantic_model_with_nested_models_and_defaults(self) -> None: + class Address(pydantic.BaseModel): + street: str + city: str = "Unknown" + + class User(pydantic.BaseModel): + name: str + address: Address + verified: bool = False + + if _compat.PYDANTIC_V1: + # to handle forward references in Pydantic v1 + User.update_forward_refs(**locals()) # type: ignore[reportDeprecated] + + address = Address(street="123 Main St") + user = User(name="Diana", address=address) + data = {"user": user} + json_bytes = openapi_dumps(data) + assert json_bytes == b'{"user":{"name":"Diana","address":{"street":"123 Main St"}}}' + + address_with_city = Address(street="456 Oak Ave", city="Boston") + user_verified = User(name="Eve", address=address_with_city, verified=True) + data = {"user": user_verified} + json_bytes = openapi_dumps(data) + assert ( + json_bytes == b'{"user":{"name":"Eve","address":{"street":"456 Oak Ave","city":"Boston"},"verified":true}}' + ) + + def test_pydantic_model_with_optional_fields(self) -> None: + class User(pydantic.BaseModel): + name: str + email: Union[str, None] + phone: Union[str, None] + + model_with_none = User(name="Eve", email=None, phone=None) + data = {"model": model_with_none} + json_bytes = openapi_dumps(data) + assert json_bytes == b'{"model":{"name":"Eve","email":null,"phone":null}}' + + model_with_values = User(name="Frank", email="frank@example.com", phone=None) + data = {"model": model_with_values} + json_bytes = openapi_dumps(data) + assert json_bytes == b'{"model":{"name":"Frank","email":"frank@example.com","phone":null}}' diff --git a/tests/test_utils/test_path.py b/tests/test_utils/test_path.py new file mode 100644 index 00000000..80b86802 --- /dev/null +++ b/tests/test_utils/test_path.py @@ -0,0 +1,89 @@ +from __future__ import annotations + +from typing import Any + +import pytest + +from kernel._utils._path import path_template + + +@pytest.mark.parametrize( + "template, kwargs, expected", + [ + ("/v1/{id}", dict(id="abc"), "/v1/abc"), + ("/v1/{a}/{b}", dict(a="x", b="y"), "/v1/x/y"), + ("/v1/{a}{b}/path/{c}?val={d}#{e}", dict(a="x", b="y", c="z", d="u", e="v"), "/v1/xy/path/z?val=u#v"), + ("/{w}/{w}", dict(w="echo"), "/echo/echo"), + ("/v1/static", {}, "/v1/static"), + ("", {}, ""), + ("/v1/?q={n}&count=10", dict(n=42), "/v1/?q=42&count=10"), + ("/v1/{v}", dict(v=None), "/v1/null"), + ("/v1/{v}", dict(v=True), "/v1/true"), + ("/v1/{v}", dict(v=False), "/v1/false"), + ("/v1/{v}", dict(v=".hidden"), "/v1/.hidden"), # dot prefix ok + ("/v1/{v}", dict(v="file.txt"), "/v1/file.txt"), # dot in middle ok + ("/v1/{v}", dict(v="..."), "/v1/..."), # triple dot ok + ("/v1/{a}{b}", dict(a=".", b="txt"), "/v1/.txt"), # dot var combining with adjacent to be ok + ("/items?q={v}#{f}", dict(v=".", f=".."), "/items?q=.#.."), # dots in query/fragment are fine + ( + "/v1/{a}?query={b}", + dict(a="../../other/endpoint", b="a&bad=true"), + "/v1/..%2F..%2Fother%2Fendpoint?query=a%26bad%3Dtrue", + ), + ("/v1/{val}", dict(val="a/b/c"), "/v1/a%2Fb%2Fc"), + ("/v1/{val}", dict(val="a/b/c?query=value"), "/v1/a%2Fb%2Fc%3Fquery=value"), + ("/v1/{val}", dict(val="a/b/c?query=value&bad=true"), "/v1/a%2Fb%2Fc%3Fquery=value&bad=true"), + ("/v1/{val}", dict(val="%20"), "/v1/%2520"), # escapes escape sequences in input + # Query: slash and ? are safe, # is not + ("/items?q={v}", dict(v="a/b"), "/items?q=a/b"), + ("/items?q={v}", dict(v="a?b"), "/items?q=a?b"), + ("/items?q={v}", dict(v="a#b"), "/items?q=a%23b"), + ("/items?q={v}", dict(v="a b"), "/items?q=a%20b"), + # Fragment: slash and ? are safe + ("/docs#{v}", dict(v="a/b"), "/docs#a/b"), + ("/docs#{v}", dict(v="a?b"), "/docs#a?b"), + # Path: slash, ? and # are all encoded + ("/v1/{v}", dict(v="a/b"), "/v1/a%2Fb"), + ("/v1/{v}", dict(v="a?b"), "/v1/a%3Fb"), + ("/v1/{v}", dict(v="a#b"), "/v1/a%23b"), + # same var encoded differently by component + ( + "/v1/{v}?q={v}#{v}", + dict(v="a/b?c#d"), + "/v1/a%2Fb%3Fc%23d?q=a/b?c%23d#a/b?c%23d", + ), + ("/v1/{val}", dict(val="x?admin=true"), "/v1/x%3Fadmin=true"), # query injection + ("/v1/{val}", dict(val="x#admin"), "/v1/x%23admin"), # fragment injection + ], +) +def test_interpolation(template: str, kwargs: dict[str, Any], expected: str) -> None: + assert path_template(template, **kwargs) == expected + + +def test_missing_kwarg_raises_key_error() -> None: + with pytest.raises(KeyError, match="org_id"): + path_template("/v1/{org_id}") + + +@pytest.mark.parametrize( + "template, kwargs", + [ + ("{a}/path", dict(a=".")), + ("{a}/path", dict(a="..")), + ("/v1/{a}", dict(a=".")), + ("/v1/{a}", dict(a="..")), + ("/v1/{a}/path", dict(a=".")), + ("/v1/{a}/path", dict(a="..")), + ("/v1/{a}{b}", dict(a=".", b=".")), # adjacent vars → ".." + ("/v1/{a}.", dict(a=".")), # var + static → ".." + ("/v1/{a}{b}", dict(a="", b=".")), # empty + dot → "." + ("/v1/%2e/{x}", dict(x="ok")), # encoded dot in static text + ("/v1/%2e./{x}", dict(x="ok")), # mixed encoded ".." in static + ("/v1/.%2E/{x}", dict(x="ok")), # mixed encoded ".." in static + ("/v1/{v}?q=1", dict(v="..")), + ("/v1/{v}#frag", dict(v="..")), + ], +) +def test_dot_segment_rejected(template: str, kwargs: dict[str, Any]) -> None: + with pytest.raises(ValueError, match="dot-segment"): + path_template(template, **kwargs) diff --git a/tests/test_utils/test_proxy.py b/tests/test_utils/test_proxy.py new file mode 100644 index 00000000..8c9c8ae3 --- /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 kernel._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 00000000..3b18d48a --- /dev/null +++ b/tests/test_utils/test_typing.py @@ -0,0 +1,73 @@ +from __future__ import annotations + +from typing import Generic, TypeVar, cast + +from kernel._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 00000000..3147457a --- /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 kernel._types import Omit, NoneType +from kernel._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 kernel._compat import PYDANTIC_V1, field_outer_type, get_model_fields +from kernel._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_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), + 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)