diff --git a/.devcontainer/Dockerfile b/.devcontainer/Dockerfile new file mode 100644 index 0000000..ff261ba --- /dev/null +++ b/.devcontainer/Dockerfile @@ -0,0 +1,9 @@ +ARG VARIANT="3.9" +FROM mcr.microsoft.com/vscode/devcontainers/python:0-${VARIANT} + +USER vscode + +RUN curl -sSf https://rye.astral.sh/get | RYE_VERSION="0.44.0" RYE_INSTALL_OPTION="--yes" bash +ENV PATH=/home/vscode/.rye/shims:$PATH + +RUN echo "[[ -d .venv ]] && source .venv/bin/activate || export PATH=\$PATH" >> /home/vscode/.bashrc diff --git a/.devcontainer/devcontainer.json b/.devcontainer/devcontainer.json new file mode 100644 index 0000000..c17fdc1 --- /dev/null +++ b/.devcontainer/devcontainer.json @@ -0,0 +1,43 @@ +// For format details, see https://aka.ms/devcontainer.json. For config options, see the +// README at: https://github.com/devcontainers/templates/tree/main/src/debian +{ + "name": "Debian", + "build": { + "dockerfile": "Dockerfile", + "context": ".." + }, + + "postStartCommand": "rye sync --all-features", + + "customizations": { + "vscode": { + "extensions": [ + "ms-python.python" + ], + "settings": { + "terminal.integrated.shell.linux": "/bin/bash", + "python.pythonPath": ".venv/bin/python", + "python.defaultInterpreterPath": ".venv/bin/python", + "python.typeChecking": "basic", + "terminal.integrated.env.linux": { + "PATH": "/home/vscode/.rye/shims:${env:PATH}" + } + } + } + }, + "features": { + "ghcr.io/devcontainers/features/node:1": {} + } + + // Features to add to the dev container. More info: https://containers.dev/features. + // "features": {}, + + // Use 'forwardPorts' to make a list of ports inside the container available locally. + // "forwardPorts": [], + + // Configure tool-specific properties. + // "customizations": {}, + + // Uncomment to connect as root instead. More info: https://aka.ms/dev-containers-non-root. + // "remoteUser": "root" +} diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..6db5033 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,66 @@ +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: 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 + + - name: Run tests + run: ./scripts/test + + 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: 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 diff --git a/.github/workflows/publish-pypi.yml b/.github/workflows/publish-pypi.yml new file mode 100644 index 0000000..feca96c --- /dev/null +++ b/.github/workflows/publish-pypi.yml @@ -0,0 +1,37 @@ +# 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/hypeman-python/actions/workflows/publish-pypi.yml +name: Publish PyPI +on: + workflow_dispatch: + + release: + types: [published] + +jobs: + publish: + name: publish + runs-on: ubuntu-latest + environment: + name: pypi + url: https://pypi.org/p/hypeman + permissions: + contents: read + id-token: write + + 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: Build distributions + run: rye build --clean + + - name: Publish to PyPI + uses: pypa/gh-action-pypi-publish@dc37677b2e1c63e2034f94d8a5b11f265b73ba33 # release/v1 diff --git a/.github/workflows/release-doctor.yml b/.github/workflows/release-doctor.yml new file mode 100644 index 0000000..505166f --- /dev/null +++ b/.github/workflows/release-doctor.yml @@ -0,0 +1,22 @@ +name: Release Doctor +on: + pull_request: + branches: + - main + workflow_dispatch: + +jobs: + release_doctor: + name: release doctor + runs-on: ubuntu-latest + if: github.repository == 'kernel/hypeman-python' && (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: + ADMIN_APP_ID: ${{ secrets.ADMIN_APP_ID }} + ADMIN_APP_PRIVATE_KEY: ${{ secrets.ADMIN_APP_PRIVATE_KEY }} diff --git a/.github/workflows/stlc-promote.yml b/.github/workflows/stlc-promote.yml new file mode 100644 index 0000000..1c9865b --- /dev/null +++ b/.github/workflows/stlc-promote.yml @@ -0,0 +1,158 @@ +name: Promote SDK changes + +# Staging is the generator's integration history. Production `next` is the +# developer-facing queue for the next release. This workflow combines the +# latest released state with validated staging changes, then advances `next`. +# Release automation maintains the single versioned PR from `next` to `main`. +on: + push: + branches: [main] + workflow_dispatch: {} + +permissions: + contents: read + +jobs: + promote: + if: github.repository == 'kernel/hypeman-python-staging' + runs-on: ${{ vars.STLC_RUNNER || 'ubuntu-latest' }} + concurrency: + group: stlc-promote + cancel-in-progress: true + steps: + - name: Check out staging + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + with: + fetch-depth: 0 + persist-credentials: false + + - name: Mint production token + id: production-token + uses: actions/create-github-app-token@d72941d797fd3113feb6b93fd0dec494b13a2547 # v1 + with: + app-id: ${{ secrets.ADMIN_APP_ID }} + private-key: ${{ secrets.ADMIN_APP_PRIVATE_KEY }} + owner: kernel + repositories: hypeman-python + permission-contents: write + permission-pull-requests: write + permission-workflows: write + + - name: Fetch production branches + id: production + env: + GH_TOKEN: ${{ steps.production-token.outputs.token }} + PRODUCTION_REPO: kernel/hypeman-python + run: | + set -euo pipefail + git remote add production \ + "https://x-access-token:${GH_TOKEN}@github.com/${PRODUCTION_REPO}.git" + if git ls-remote --exit-code --heads production main >/dev/null 2>&1; then + git fetch production main + echo "has_main=true" >> "$GITHUB_OUTPUT" + else + echo "has_main=false" >> "$GITHUB_OUTPUT" + fi + if git ls-remote --exit-code --heads production next >/dev/null 2>&1; then + git fetch production next + echo "has_next=true" >> "$GITHUB_OUTPUT" + else + echo "has_next=false" >> "$GITHUB_OUTPUT" + fi + + - name: Prepare the next release branch + env: + APP_SLUG: ${{ steps.production-token.outputs.app-slug }} + GH_TOKEN: ${{ steps.production-token.outputs.token }} + HAS_MAIN: ${{ steps.production.outputs.has_main }} + HAS_NEXT: ${{ steps.production.outputs.has_next }} + PRODUCTION_REPO: kernel/hypeman-python + run: | + set -euo pipefail + bot_id=$(gh api "/users/${APP_SLUG}[bot]" --jq .id) + git config user.name "${APP_SLUG}[bot]" + git config user.email "${bot_id}+${APP_SLUG}[bot]@users.noreply.github.com" + + open_conflict_pr() { + source_ref=$1 + source_name=$2 + advance_next=$3 + conflict_branch=stlc/promotion-conflict + + git merge --abort + existing=$(gh pr list --repo "$PRODUCTION_REPO" --base next \ + --head "$conflict_branch" --state open --json url --jq '.[0].url // ""') + if [ -n "$existing" ]; then + echo "::error title=SDK promotion blocked::Resolve the existing recovery PR: $existing" + exit 1 + fi + + if [ "$advance_next" = "true" ]; then + git push production HEAD:refs/heads/next + fi + git push production "$source_ref:refs/heads/$conflict_branch" --force + + body=$(mktemp) + printf '%s\n' \ + '## SDK promotion conflict' \ + '' \ + "The automated promotion could not merge $source_name into the pending next release." \ + '' \ + 'Resolve the conflicts on this branch, validate the SDK, mark this PR ready, and merge it with a merge commit.' \ + '' \ + 'After merging, rerun the staging Promote SDK changes workflow to include any newer generated changes.' \ + > "$body" + recovery_url=$(gh pr create --repo "$PRODUCTION_REPO" --draft \ + --base next --head "$conflict_branch" \ + --title 'chore: resolve SDK promotion conflict' --body-file "$body") + echo "::error title=SDK promotion conflict::Resolve the recovery PR: $recovery_url" + exit 1 + } + + if [ "$HAS_MAIN" != "true" ]; then + # The Python production repository started empty. Seed a minimal + # default branch so release-please can open the first next -> main + # release PR while preserving staging as a parent of that release. + git checkout --orphan stlc/bootstrap-main + git rm -rf . + git checkout origin/main -- \ + .github/workflows/release-please.yml \ + .release-please-manifest.json \ + release-please-config.json + git commit -m 'chore: initialize SDK release history' + git push production HEAD:refs/heads/main + git fetch production main + fi + + if [ "$HAS_NEXT" = "true" ]; then + git checkout -B stlc/promote-next production/next + else + git checkout -B stlc/promote-next production/main + fi + + if ! git merge-base --is-ancestor production/main HEAD; then + if ! git merge --no-edit production/main; then + open_conflict_pr production/main 'production main' false + fi + fi + if ! git merge-base --is-ancestor origin/main HEAD; then + merge_args=(--no-edit) + if ! git merge-base production/main origin/main >/dev/null 2>&1; then + merge_args+=(--allow-unrelated-histories) + fi + if ! git merge "${merge_args[@]}" origin/main; then + open_conflict_pr origin/main 'validated staging changes' true + fi + fi + + if [ "$HAS_NEXT" = "true" ]; then + git merge-base --is-ancestor production/next HEAD + fi + + - name: Update the pending release + env: + GH_TOKEN: ${{ steps.production-token.outputs.token }} + run: | + set -euo pipefail + git push production HEAD:refs/heads/next + echo "Updated production next; the versioned release PR will be opened or refreshed." diff --git a/.github/workflows/stlc-sync.yml b/.github/workflows/stlc-sync.yml new file mode 100644 index 0000000..61fea25 --- /dev/null +++ b/.github/workflows/stlc-sync.yml @@ -0,0 +1,115 @@ +name: Sync SDK repos + +# Keeps production and staging on one fast-forward-only history. Optional +# dispatch tokens make the polling loop eager; the scheduled back-sync is the +# safety backstop when those secrets are absent. +on: + schedule: + - cron: '7,37 * * * *' + workflow_dispatch: {} + repository_dispatch: + types: [prod-released] + release: + types: [published] + push: + branches: [main] + +jobs: + back-sync: + if: >- + github.repository == 'kernel/hypeman-python-staging' && + (github.event_name == 'schedule' || github.event_name == 'workflow_dispatch' || github.event_name == 'repository_dispatch') + runs-on: ${{ vars.STLC_RUNNER || 'ubuntu-latest' }} + permissions: + contents: write + concurrency: + group: stlc-back-sync + cancel-in-progress: true + steps: + - name: Check out staging + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + with: + fetch-depth: 0 + + - name: Fetch production main + id: production + run: | + git remote add production "https://github.com/kernel/hypeman-python.git" + if git ls-remote --exit-code --heads production main >/dev/null 2>&1; then + git -c "http.https://github.com/.extraheader=" fetch production main + echo "has_main=true" >> "$GITHUB_OUTPUT" + else + echo "Production is still empty; promotion will initialize it." + echo "has_main=false" >> "$GITHUB_OUTPUT" + fi + + - name: Check whether production has content staging lacks + id: diff + if: steps.production.outputs.has_main == 'true' + run: | + MERGED=$(git merge-tree --write-tree origin/main production/main) || MERGED=conflict + STAGING_TREE=$(git rev-parse 'origin/main^{tree}') + if [ "$MERGED" = "$STAGING_TREE" ]; then + echo "Staging already has production's content. Nothing to pull back." + echo "behind=false" >> "$GITHUB_OUTPUT" + else + echo "behind=true" >> "$GITHUB_OUTPUT" + fi + + - name: Sync production to staging + if: steps.production.outputs.has_main == 'true' && steps.diff.outputs.behind == 'true' + run: | + if ! git merge-base --is-ancestor origin/main production/main; then + echo "::error title=Back-sync blocked::staging main is not an ancestor of production/main." + exit 1 + fi + git push origin production/main:refs/heads/main + + notify-back-sync: + if: >- + github.repository == 'kernel/hypeman-python' && + (github.event_name == 'release' || github.event_name == 'workflow_dispatch') + runs-on: ${{ vars.STLC_RUNNER || 'ubuntu-latest' }} + permissions: + contents: read + steps: + - name: Dispatch back-sync to staging + env: + DISPATCH_TOKEN: ${{ secrets.STAGING_DISPATCH_TOKEN }} + REF_NAME: ${{ github.ref_name }} + run: | + set -euo pipefail + if [ -z "${DISPATCH_TOKEN:-}" ]; then + echo "::notice::STAGING_DISPATCH_TOKEN not configured; the scheduled back-sync remains active." + exit 0 + fi + payload=$(jq -n --arg ref "$REF_NAME" '{event_type:"prod-released",client_payload:{ref:$ref}}') + curl --fail-with-body -sS -X POST -H "Authorization: Bearer $DISPATCH_TOKEN" -H "Accept: application/vnd.github+json" -H "X-GitHub-Api-Version: 2022-11-28" "https://api.github.com/repos/kernel/hypeman-python-staging/dispatches" -d "$payload" + + seal-dispatch: + if: github.repository == 'kernel/hypeman-python-staging' && github.event_name == 'push' + runs-on: ${{ vars.STLC_RUNNER || 'ubuntu-latest' }} + permissions: + contents: read + concurrency: + group: seal-dispatch-${{ github.ref }} + cancel-in-progress: false + steps: + - name: Dispatch tracking sync + env: + DISPATCH_TOKEN: ${{ secrets.CONFIG_DISPATCH_TOKEN }} + HEAD_MSG: ${{ github.event.head_commit.message }} + HEAD_AUTHOR_NAME: ${{ github.event.head_commit.author.name }} + SHA: ${{ github.sha }} + run: | + set -euo pipefail + if printf '%s' "$HEAD_MSG" | grep -q 'Stainless-Generated-From' || [ "$HEAD_AUTHOR_NAME" = "stlc-bot" ]; then + echo "Generated commit; skipping tracking dispatch." + exit 0 + fi + if [ -z "${DISPATCH_TOKEN:-}" ]; then + echo "::notice::CONFIG_DISPATCH_TOKEN not configured; the config repo's scheduled sync remains active." + exit 0 + fi + payload=$(jq -n --arg sha "$SHA" --arg repo "${{ github.repository }}" '{event_type:"seal-custom-code",client_payload:{target:"all",sha:$sha,repo:$repo}}') + curl --fail-with-body -sS -X POST -H "Authorization: Bearer $DISPATCH_TOKEN" -H "Accept: application/vnd.github+json" -H "X-GitHub-Api-Version: 2022-11-28" "https://api.github.com/repos/kernel/hypeman/dispatches" -d "$payload" diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..3824f4c --- /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 0000000..43077b2 --- /dev/null +++ b/.python-version @@ -0,0 +1 @@ +3.9.18 diff --git a/.release-please-manifest.json b/.release-please-manifest.json index 1332969..3d2ac0b 100644 --- a/.release-please-manifest.json +++ b/.release-please-manifest.json @@ -1,3 +1,3 @@ { - ".": "0.0.1" + ".": "0.1.0" } \ No newline at end of file diff --git a/.stats.yml b/.stats.yml new file mode 100644 index 0000000..2814bb7 --- /dev/null +++ b/.stats.yml @@ -0,0 +1 @@ +configured_endpoints: 62 diff --git a/.vscode/settings.json b/.vscode/settings.json new file mode 100644 index 0000000..5b01030 --- /dev/null +++ b/.vscode/settings.json @@ -0,0 +1,3 @@ +{ + "python.analysis.importFormat": "relative", +} diff --git a/Brewfile b/Brewfile new file mode 100644 index 0000000..492ca37 --- /dev/null +++ b/Brewfile @@ -0,0 +1,2 @@ +brew "rye" + diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..93e968f --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,23 @@ +# Changelog + +## [0.1.0](https://github.com/kernel/hypeman-python/compare/v0.0.1...v0.1.0) (2026-08-17) + + +### Features + +* add exec and copy WebSocket APIs ([654958e](https://github.com/kernel/hypeman-python/commit/654958e88c2dd079dd0127cf6cf31cd990b3a768)) +* add Python SDK WebSocket primitives ([103ef77](https://github.com/kernel/hypeman-python/commit/103ef77803b0377c65c72f0e86a4213a2c86a341)) +* Use PyPI trusted publishing ([894fcad](https://github.com/kernel/hypeman-python/commit/894fcad5ef7b8c2132cfef42934d80dad85b3ef9)) + + +### Bug Fixes + +* bootstrap empty Python production repo ([4cf851e](https://github.com/kernel/hypeman-python/commit/4cf851e21bfe1e48a6b4ba4f8ec4417f4f4d5e52)) +* bound WebSocket frames before dispatch ([caf7196](https://github.com/kernel/hypeman-python/commit/caf71961faafb465db0d8f7c00de1d40e56ba731)) +* harden Python release gates ([870bee9](https://github.com/kernel/hypeman-python/commit/870bee9282b5ceb1ffc30ac9bbeb19a3e422cc33)) +* tighten WebSocket API contracts ([dec9f14](https://github.com/kernel/hypeman-python/commit/dec9f14361d7e6dda73da2e7ee29896cd621b0d8)) + + +### Chores + +* initialize SDK release history ([677f579](https://github.com/kernel/hypeman-python/commit/677f579d0e5a5d45d4457bfe8ed5d923e03da0a6)) diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md new file mode 100644 index 0000000..3b16d1c --- /dev/null +++ b/CONTRIBUTING.md @@ -0,0 +1,116 @@ +## 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/hypeman/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/hypeman-python.git +``` + +Alternatively, you can build from source and install the wheel file: + +Building this package will create two files in the `dist/` directory, a `.tar.gz` containing the source files and a `.whl` that can be used to install the package efficiently. + +To create a distributable version of the library, all you have to do is run this command: + +```sh +$ rye build +# or +$ python -m build +``` + +Then to install: + +```sh +$ pip install ./path-to-wheel-file.whl +``` + +## Running tests + +```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 + +Use the [`Publish PyPI` GitHub workflow](https://www.github.com/kernel/hypeman-python/actions/workflows/publish-pypi.yml) to publish or retry a release. It authenticates through PyPI Trusted Publishing and the `pypi` GitHub environment; no API token is required. diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..d8dff65 --- /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 Hypeman + + 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 new file mode 100644 index 0000000..09f3c4b --- /dev/null +++ b/README.md @@ -0,0 +1,424 @@ +# Hypeman Python API library + + +[![PyPI version](https://img.shields.io/pypi/v/hypeman.svg?label=pypi%20(stable))](https://pypi.org/project/hypeman/) + +The Hypeman Python library provides convenient access to the Hypeman 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 full API of this library can be found in [api.md](api.md). + +## Installation + +```sh +# install from PyPI +pip install hypeman +``` + +## Usage + +The full API of this library can be found in [api.md](api.md). + +```python +import os +from hypeman import Hypeman + +client = Hypeman( + api_key=os.environ.get("HYPEMAN_API_KEY"), # This is the default and can be omitted +) + +response = client.health.check() +print(response.status) +``` + +While you can provide an `api_key` keyword argument, +we recommend using [python-dotenv](https://pypi.org/project/python-dotenv/) +to add `HYPEMAN_API_KEY="My API Key"` to your `.env` file +so that your API Key is not stored in source control. + +## Async usage + +Simply import `AsyncHypeman` instead of `Hypeman` and use `await` with each API call: + +```python +import os +import asyncio +from hypeman import AsyncHypeman + +client = AsyncHypeman( + api_key=os.environ.get("HYPEMAN_API_KEY"), # This is the default and can be omitted +) + + +async def main() -> None: + response = await client.health.check() + print(response.status) + + +asyncio.run(main()) +``` + +Functionality between the synchronous and asynchronous clients is otherwise identical. + +## Exec and file copy + +The custom WebSocket APIs live under `hypeman.lib` and use the generated client's +`base_url` and `api_key`: + +```python +from hypeman import Hypeman +from hypeman.lib import cp_from_instance, cp_to_instance, exec + +client = Hypeman() + +result = exec(client, "instance-id", ["sh", "-lc", "printf hello"]) +print(result.output.decode()) +print(result.exit_code) + +cp_to_instance(client, "instance-id", "./config", "/app/config") +cp_from_instance(client, "instance-id", "/app/result.json", "./downloads") +``` + +`ExecResult.output` contains the server's merged stdout and stderr stream. The +current protocol doesn't identify which stream produced each byte. Exec requests +are dispatched once and aren't retried. + +The async equivalents are `exec_async`, `cp_to_instance_async`, and +`cp_from_instance_async`. + +### 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 hypeman[aiohttp] +``` + +Then you can enable it by instantiating the client with `http_client=DefaultAioHttpClient()`: + +```python +import os +import asyncio +from hypeman import DefaultAioHttpClient +from hypeman import AsyncHypeman + + +async def main() -> None: + async with AsyncHypeman( + api_key=os.environ.get("HYPEMAN_API_KEY"), # This is the default and can be omitted + http_client=DefaultAioHttpClient(), + ) as client: + response = await client.health.check() + print(response.status) + + +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`. + +## Nested params + +Nested parameters are dictionaries, typed using `TypedDict`, for example: + +```python +from hypeman import Hypeman + +client = Hypeman() + +image = client.images.create( + name="docker.io/library/nginx:latest", + credentials={}, +) +print(image.credentials) +``` + +## 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 hypeman import Hypeman + +client = Hypeman() + +client.builds.create( + source=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 `hypeman.APIConnectionError` is raised. + +When the API returns a non-success status code (that is, 4xx or 5xx +response), a subclass of `hypeman.APIStatusError` is raised, containing `status_code` and `response` properties. + +All errors inherit from `hypeman.APIError`. + +```python +import hypeman +from hypeman import Hypeman + +client = Hypeman() + +try: + client.health.check() +except hypeman.APIConnectionError as e: + print("The server could not be reached") + print(e.__cause__) # an underlying Exception, likely raised within httpx. +except hypeman.RateLimitError as e: + print("A 429 status code was received; we should back off a bit.") +except hypeman.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 hypeman import Hypeman + +# Configure the default for all requests: +client = Hypeman( + # default is 2 + max_retries=0, +) + +# Or, configure per-request: +client.with_options(max_retries=5).health.check() +``` + +### 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 hypeman import Hypeman + +# Configure the default for all requests: +client = Hypeman( + # 20 seconds (default is 1 minute) + timeout=20.0, +) + +# More granular control: +client = Hypeman( + timeout=httpx.Timeout(60.0, read=5.0, write=10.0, connect=2.0), +) + +# Override per-request: +client.with_options(timeout=5.0).health.check() +``` + +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 `HYPEMAN_LOG` to `info`. + +```shell +$ export HYPEMAN_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 hypeman import Hypeman + +client = Hypeman() +response = client.health.with_raw_response.check() +print(response.headers.get("X-My-Header")) + +health = response.parse() # get the object that `health.check()` would have returned +print(health.status) +``` + +These methods return an [`APIResponse`](https://github.com/kernel/hypeman-python/tree/main/src/hypeman/_response.py) object. + +The async client returns an [`AsyncAPIResponse`](https://github.com/kernel/hypeman-python/tree/main/src/hypeman/_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.health.with_streaming_response.check() 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 hypeman import Hypeman, DefaultHttpxClient + +client = Hypeman( + # Or use the `HYPEMAN_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 hypeman import Hypeman + +with Hypeman() 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/hypeman-python/issues) with questions, bugs, or suggestions. + +### Determining the installed version + +If you've upgraded to the latest version but aren't seeing any new features you were expecting then your python environment is likely still using an older version. + +You can determine the version that is being used at runtime with: + +```py +import hypeman + +print(hypeman.__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 0000000..94a5b00 --- /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 Hypeman, 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 0000000..9b4aea6 --- /dev/null +++ b/api.md @@ -0,0 +1,274 @@ +# Shared Types + +```python +from hypeman.types import SnapshotCompressionConfig +``` + +# Health + +Types: + +```python +from hypeman.types import HealthCheckResponse +``` + +Methods: + +- client.health.check() -> HealthCheckResponse + +# Capabilities + +Types: + +```python +from hypeman.types import ( + Capabilities, + CapabilitiesDefaultRuntime, + CapabilitiesHost, + CapabilitiesImages, + CapabilitiesNetwork, + CapabilitiesRuntime, + CapabilitiesServer, +) +``` + +Methods: + +- client.capabilities.get() -> Capabilities + +# Images + +Types: + +```python +from hypeman.types import Image, ImageListResponse +``` + +Methods: + +- client.images.create(\*\*params) -> Image +- client.images.list(\*\*params) -> ImageListResponse +- client.images.delete(name) -> None +- client.images.get(name) -> Image + +# Instances + +Types: + +```python +from hypeman.types import ( + AutoStandbyPolicy, + AutoStandbyStatus, + HealthCheck, + HealthCheckExec, + HealthCheckHTTP, + HealthCheckTcp, + Instance, + InstanceHealthStatus, + InstanceStats, + PathInfo, + PortMapping, + RestartPolicy, + RestartStatus, + SetSnapshotScheduleRequest, + SnapshotPolicy, + SnapshotSchedule, + SnapshotScheduleRetention, + StandbyInstanceRequest, + VolumeMount, + WaitForStateResponse, + InstanceListResponse, + InstanceLogsResponse, +) +``` + +Methods: + +- client.instances.create(\*\*params) -> Instance +- client.instances.update(id, \*\*params) -> Instance +- client.instances.list(\*\*params) -> InstanceListResponse +- client.instances.delete(id) -> None +- client.instances.fork(id, \*\*params) -> Instance +- client.instances.get(id) -> Instance +- client.instances.logs(id, \*\*params) -> str +- client.instances.restore(id) -> Instance +- client.instances.standby(id, \*\*params) -> Instance +- client.instances.start(id, \*\*params) -> Instance +- client.instances.stat(id, \*\*params) -> PathInfo +- client.instances.stats(id) -> InstanceStats +- client.instances.stop(id) -> Instance +- client.instances.wait(id, \*\*params) -> WaitForStateResponse + +## AutoStandby + +Methods: + +- client.instances.auto_standby.hold(id) -> AutoStandbyStatus +- client.instances.auto_standby.status(id) -> AutoStandbyStatus + +## Volumes + +Methods: + +- client.instances.volumes.attach(volume_id, \*, id, \*\*params) -> Instance +- client.instances.volumes.detach(volume_id, \*, id) -> Instance + +## Snapshots + +Methods: + +- client.instances.snapshots.create(id, \*\*params) -> Snapshot +- client.instances.snapshots.restore(snapshot_id, \*, id, \*\*params) -> Instance + +## SnapshotSchedule + +Methods: + +- client.instances.snapshot_schedule.update(id, \*\*params) -> SnapshotSchedule +- client.instances.snapshot_schedule.delete(id) -> None +- client.instances.snapshot_schedule.get(id) -> SnapshotSchedule + +# Snapshots + +Types: + +```python +from hypeman.types import Snapshot, SnapshotKind, SnapshotListResponse +``` + +Methods: + +- client.snapshots.list(\*\*params) -> SnapshotListResponse +- client.snapshots.delete(snapshot_id) -> None +- client.snapshots.fork(snapshot_id, \*\*params) -> Instance +- client.snapshots.get(snapshot_id) -> Snapshot + +# Volumes + +Types: + +```python +from hypeman.types import Volume, VolumeAttachment, VolumeListResponse +``` + +Methods: + +- client.volumes.create(\*\*params) -> Volume +- client.volumes.list(\*\*params) -> VolumeListResponse +- client.volumes.delete(id) -> None +- client.volumes.create_from_archive(body, \*\*params) -> Volume +- client.volumes.get(id) -> Volume + +# Devices + +Types: + +```python +from hypeman.types import ( + AvailableDevice, + Device, + DeviceType, + DeviceListResponse, + DeviceListAvailableResponse, +) +``` + +Methods: + +- client.devices.create(\*\*params) -> Device +- client.devices.retrieve(id) -> Device +- client.devices.list(\*\*params) -> DeviceListResponse +- client.devices.delete(id) -> None +- client.devices.list_available() -> DeviceListAvailableResponse + +# Ingresses + +Types: + +```python +from hypeman.types import Ingress, IngressMatch, IngressRule, IngressTarget, IngressListResponse +``` + +Methods: + +- client.ingresses.create(\*\*params) -> Ingress +- client.ingresses.list(\*\*params) -> IngressListResponse +- client.ingresses.delete(id) -> None +- client.ingresses.get(id) -> Ingress + +# Resources + +Types: + +```python +from hypeman.types import ( + DiskBreakdown, + GPUProfile, + GPUResourceStatus, + MemoryReclaimAction, + MemoryReclaimRequest, + MemoryReclaimResponse, + PassthroughDevice, + ResourceAllocation, + ResourceStatus, + Resources, +) +``` + +Methods: + +- client.resources.get() -> Resources +- client.resources.reclaim_memory(\*\*params) -> MemoryReclaimResponse + +# Builders + +Types: + +```python +from hypeman.types import Builder, BuilderStatus, BuilderListResponse +``` + +Methods: + +- client.builders.create(\*\*params) -> Builder +- client.builders.list(\*\*params) -> BuilderListResponse +- client.builders.delete(id) -> None +- client.builders.get(id) -> Builder +- client.builders.prune(id) -> Builder + +# Builds + +Types: + +```python +from hypeman.types import ( + Build, + BuildEvent, + BuildPolicy, + BuildProvenance, + BuildStatus, + BuildListResponse, +) +``` + +Methods: + +- client.builds.create(\*\*params) -> Build +- client.builds.list(\*\*params) -> BuildListResponse +- client.builds.cancel(id) -> None +- client.builds.events(id, \*\*params) -> BuildEvent +- client.builds.get(id) -> Build + +# Pushes + +Types: + +```python +from hypeman.types import CreatePushRequest, Push, PushCredentials, PushStatus, PushListResponse +``` + +Methods: + +- client.pushes.create(\*\*params) -> Push +- client.pushes.list() -> PushListResponse +- client.pushes.get(id) -> Push diff --git a/bin/check-release-environment b/bin/check-release-environment new file mode 100644 index 0000000..1712312 --- /dev/null +++ b/bin/check-release-environment @@ -0,0 +1,25 @@ +#!/usr/bin/env bash + +errors=() + +if [ -z "${ADMIN_APP_ID}" ]; then + errors+=("The ADMIN_APP_ID secret has not been set.") +fi + +if [ -z "${ADMIN_APP_PRIVATE_KEY}" ]; then + errors+=("The ADMIN_APP_PRIVATE_KEY secret has not been set.") +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/examples/.keep b/examples/.keep new file mode 100644 index 0000000..d8c73e9 --- /dev/null +++ b/examples/.keep @@ -0,0 +1,4 @@ +File generated from our OpenAPI spec by Stainless. + +This directory can be used to store example files demonstrating usage of this SDK. +It is ignored by Stainless code generation and its content (other than this keep file) won't be touched. \ No newline at end of file diff --git a/examples/exec_and_copy.py b/examples/exec_and_copy.py new file mode 100644 index 0000000..fe49359 --- /dev/null +++ b/examples/exec_and_copy.py @@ -0,0 +1,10 @@ +from hypeman import Hypeman +from hypeman.lib import exec, cp_to_instance, cp_from_instance + +client = Hypeman() +instance_id = "instance-id" + +cp_to_instance(client, instance_id, "./input.txt", "/tmp/input.txt") +result = exec(client, instance_id, ["cat", "/tmp/input.txt"]) +print(result.output.decode(), end="") +cp_from_instance(client, instance_id, "/tmp/input.txt", "./downloads") diff --git a/noxfile.py b/noxfile.py new file mode 100644 index 0000000..53bca7f --- /dev/null +++ b/noxfile.py @@ -0,0 +1,9 @@ +import nox + + +@nox.session(reuse_venv=True, name="test-pydantic-v1") +def test_pydantic_v1(session: nox.Session) -> None: + session.install("-r", "requirements-dev.lock") + session.install("pydantic<2") + + session.run("pytest", "--showlocals", "--ignore=tests/functional", *session.posargs) diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..93006eb --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,270 @@ +[project] +name = "hypeman" +version = "0.1.0" +description = "The official Python library for the hypeman API" +dynamic = ["readme"] +license = "Apache-2.0" +authors = [ +{ name = "Hypeman", 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", + "websockets>=13.1, <16", +] + +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/hypeman-python" +Repository = "https://github.com/kernel/hypeman-python" + +[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 hypeman'" + +typecheck = { chain = [ + "typecheck:pyright", + "typecheck:mypy" +]} +"typecheck:pyright" = "pyright" +"typecheck:verify-types" = "pyright --verifytypes hypeman --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/hypeman"] + +[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/hypeman-python/tree/main/\g<2>)' + +[tool.pytest.ini_options] +testpaths = ["tests"] +addopts = "--tb=short -n auto" +xfail_strict = true +asyncio_mode = "auto" +asyncio_default_fixture_loop_scope = "session" +filterwarnings = [ + "error" +] + +[tool.pyright] +# this enables practically every flag given by pyright. +# there are a couple of flags that are still disabled by +# default in strict mode as they are experimental and niche. +typeCheckingMode = "strict" +pythonVersion = "3.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/hypeman/_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 = ["hypeman", "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/requirements-dev.lock b/requirements-dev.lock new file mode 100644 index 0000000..bce8780 --- /dev/null +++ b/requirements-dev.lock @@ -0,0 +1,151 @@ +# 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 hypeman +aiosignal==1.4.0 + # via aiohttp +annotated-types==0.7.0 + # via pydantic +anyio==4.12.1 + # via httpx + # via hypeman +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 hypeman +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 hypeman + # via respx +httpx-aiohttp==0.1.12 + # via hypeman +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 hypeman +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 hypeman +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 hypeman + # 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 +websockets==15.0.1 + # via hypeman +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 0000000..96deb74 --- /dev/null +++ b/requirements.lock @@ -0,0 +1,78 @@ +# 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 hypeman +aiosignal==1.4.0 + # via aiohttp +annotated-types==0.7.0 + # via pydantic +anyio==4.12.1 + # via httpx + # via hypeman +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 hypeman +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 hypeman +httpx-aiohttp==0.1.12 + # via hypeman +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 hypeman +pydantic-core==2.41.5 + # via pydantic +sniffio==1.3.1 + # via hypeman +typing-extensions==4.15.0 + # via aiosignal + # via anyio + # via exceptiongroup + # via hypeman + # via multidict + # via pydantic + # via pydantic-core + # via typing-inspection +typing-inspection==0.4.2 + # via pydantic +websockets==15.0.1 + # via hypeman +yarl==1.22.0 + # via aiohttp diff --git a/scripts/bootstrap b/scripts/bootstrap new file mode 100755 index 0000000..fe8451e --- /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 0000000..667ec2d --- /dev/null +++ b/scripts/format @@ -0,0 +1,8 @@ +#!/usr/bin/env bash + +set -e + +cd "$(dirname "$0")/.." + +echo "==> Running formatters" +rye run format diff --git a/scripts/lint b/scripts/lint new file mode 100755 index 0000000..d0d9a7f --- /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 hypeman' diff --git a/scripts/test b/scripts/test new file mode 100755 index 0000000..39729d0 --- /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 0000000..0cf2bd2 --- /dev/null +++ b/scripts/utils/ruffen-docs.py @@ -0,0 +1,167 @@ +# fork of https://github.com/asottile/blacken-docs adapted for ruff +from __future__ import annotations + +import re +import sys +import argparse +import textwrap +import contextlib +import subprocess +from typing import Match, Optional, Sequence, Generator, NamedTuple, cast + +MD_RE = re.compile( + r"(?P^(?P *)```\s*python\n)" r"(?P.*?)" r"(?P^(?P=indent)```\s*$)", + re.DOTALL | re.MULTILINE, +) +MD_PYCON_RE = re.compile( + r"(?P^(?P *)```\s*pycon\n)" r"(?P.*?)" r"(?P^(?P=indent)```.*$)", + re.DOTALL | re.MULTILINE, +) +PYCON_PREFIX = ">>> " +PYCON_CONTINUATION_PREFIX = "..." +PYCON_CONTINUATION_RE = re.compile( + rf"^{re.escape(PYCON_CONTINUATION_PREFIX)}( |$)", +) +DEFAULT_LINE_LENGTH = 100 + + +class CodeBlockError(NamedTuple): + offset: int + exc: Exception + + +def format_str( + src: str, +) -> tuple[str, Sequence[CodeBlockError]]: + errors: list[CodeBlockError] = [] + + @contextlib.contextmanager + def _collect_error(match: Match[str]) -> Generator[None, None, None]: + try: + yield + except Exception as e: + errors.append(CodeBlockError(match.start(), e)) + + def _md_match(match: Match[str]) -> str: + code = textwrap.dedent(match["code"]) + with _collect_error(match): + code = format_code_block(code) + code = textwrap.indent(code, match["indent"]) + return f"{match['before']}{code}{match['after']}" + + def _pycon_match(match: Match[str]) -> str: + code = "" + fragment = cast(Optional[str], None) + + def finish_fragment() -> None: + nonlocal code + nonlocal fragment + + if fragment is not None: + with _collect_error(match): + fragment = format_code_block(fragment) + fragment_lines = fragment.splitlines() + code += f"{PYCON_PREFIX}{fragment_lines[0]}\n" + for line in fragment_lines[1:]: + # Skip blank lines to handle Black adding a blank above + # functions within blocks. A blank line would end the REPL + # continuation prompt. + # + # >>> if True: + # ... def f(): + # ... pass + # ... + if line: + code += f"{PYCON_CONTINUATION_PREFIX} {line}\n" + if fragment_lines[-1].startswith(" "): + code += f"{PYCON_CONTINUATION_PREFIX}\n" + fragment = None + + indentation = None + for line in match["code"].splitlines(): + orig_line, line = line, line.lstrip() + if indentation is None and line: + indentation = len(orig_line) - len(line) + continuation_match = PYCON_CONTINUATION_RE.match(line) + if continuation_match and fragment is not None: + fragment += line[continuation_match.end() :] + "\n" + else: + finish_fragment() + if line.startswith(PYCON_PREFIX): + fragment = line[len(PYCON_PREFIX) :] + "\n" + else: + code += orig_line[indentation:] + "\n" + finish_fragment() + return code + + def _md_pycon_match(match: Match[str]) -> str: + code = _pycon_match(match) + code = textwrap.indent(code, match["indent"]) + return f"{match['before']}{code}{match['after']}" + + src = MD_RE.sub(_md_match, src) + src = MD_PYCON_RE.sub(_md_pycon_match, src) + return src, errors + + +def format_code_block(code: str) -> str: + return subprocess.check_output( + [ + sys.executable, + "-m", + "ruff", + "format", + "--stdin-filename=script.py", + f"--line-length={DEFAULT_LINE_LENGTH}", + ], + encoding="utf-8", + input=code, + ) + + +def format_file( + filename: str, + skip_errors: bool, +) -> int: + with open(filename, encoding="UTF-8") as f: + contents = f.read() + new_contents, errors = format_str(contents) + for error in errors: + lineno = contents[: error.offset].count("\n") + 1 + print(f"{filename}:{lineno}: code block parse error {error.exc}") + if errors and not skip_errors: + return 1 + if contents != new_contents: + print(f"{filename}: Rewriting...") + with open(filename, "w", encoding="UTF-8") as f: + f.write(new_contents) + return 0 + else: + return 0 + + +def main(argv: Sequence[str] | None = None) -> int: + parser = argparse.ArgumentParser() + parser.add_argument( + "-l", + "--line-length", + type=int, + default=DEFAULT_LINE_LENGTH, + ) + parser.add_argument( + "-S", + "--skip-string-normalization", + action="store_true", + ) + parser.add_argument("-E", "--skip-errors", action="store_true") + parser.add_argument("filenames", nargs="*") + args = parser.parse_args(argv) + + retv = 0 + for filename in args.filenames: + retv |= format_file(filename, skip_errors=args.skip_errors) + return retv + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/scripts/utils/upload-artifact.sh b/scripts/utils/upload-artifact.sh new file mode 100755 index 0000000..23bca9f --- /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/hypeman-python-staging/$SHA/$FILENAME'\033[0m" +else + echo -e "\033[31mFailed to upload artifact.\033[0m" + exit 1 +fi diff --git a/src/hypeman/__init__.py b/src/hypeman/__init__.py new file mode 100644 index 0000000..0060d62 --- /dev/null +++ b/src/hypeman/__init__.py @@ -0,0 +1,92 @@ +# 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 Client, Stream, Hypeman, Timeout, Transport, AsyncClient, AsyncStream, AsyncHypeman, 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, + HypemanError, + 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", + "HypemanError", + "APIError", + "APIStatusError", + "APITimeoutError", + "APIConnectionError", + "APIResponseValidationError", + "BadRequestError", + "AuthenticationError", + "PermissionDeniedError", + "NotFoundError", + "ConflictError", + "UnprocessableEntityError", + "RateLimitError", + "InternalServerError", + "Timeout", + "RequestOptions", + "Client", + "AsyncClient", + "Stream", + "AsyncStream", + "Hypeman", + "AsyncHypeman", + "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. +# hypeman._exceptions.NotFoundError -> hypeman.NotFoundError +__locals = locals() +for __name in __all__: + if not __name.startswith("__"): + try: + __locals[__name].__module__ = "hypeman" + except (TypeError, AttributeError): + # Some of our exported symbols are builtins which we can't set attributes for. + pass diff --git a/src/hypeman/_base_client.py b/src/hypeman/_base_client.py new file mode 100644 index 0000000..44da2ab --- /dev/null +++ b/src/hypeman/_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 `hypeman.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/hypeman/_client.py b/src/hypeman/_client.py new file mode 100644 index 0000000..a819ffd --- /dev/null +++ b/src/hypeman/_client.py @@ -0,0 +1,884 @@ +# 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, Mapping +from typing_extensions import Self, 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 HypemanError, APIStatusError +from ._base_client import ( + DEFAULT_MAX_RETRIES, + SyncAPIClient, + AsyncAPIClient, +) + +if TYPE_CHECKING: + from .resources import ( + builds, + health, + images, + pushes, + devices, + volumes, + builders, + ingresses, + instances, + resources, + snapshots, + capabilities, + ) + from .resources.builds import BuildsResource, AsyncBuildsResource + from .resources.health import HealthResource, AsyncHealthResource + from .resources.images import ImagesResource, AsyncImagesResource + from .resources.pushes import PushesResource, AsyncPushesResource + from .resources.devices import DevicesResource, AsyncDevicesResource + from .resources.volumes import VolumesResource, AsyncVolumesResource + from .resources.builders import BuildersResource, AsyncBuildersResource + from .resources.ingresses import IngressesResource, AsyncIngressesResource + from .resources.resources import ResourcesResource, AsyncResourcesResource + from .resources.snapshots import SnapshotsResource, AsyncSnapshotsResource + from .resources.capabilities import CapabilitiesResource, AsyncCapabilitiesResource + from .resources.instances.instances import InstancesResource, AsyncInstancesResource + +__all__ = ["Timeout", "Transport", "ProxiesTypes", "RequestOptions", "Hypeman", "AsyncHypeman", "Client", "AsyncClient"] + + +class Hypeman(SyncAPIClient): + # client options + api_key: str + + def __init__( + self, + *, + api_key: str | None = None, + base_url: str | httpx.URL | None = None, + 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 Hypeman client instance. + + This automatically infers the `api_key` argument from the `HYPEMAN_API_KEY` environment variable if it is not provided. + """ + if api_key is None: + api_key = os.environ.get("HYPEMAN_API_KEY") + if api_key is None: + raise HypemanError( + "The api_key client option must be set either by passing api_key to the client or by setting the HYPEMAN_API_KEY environment variable" + ) + self.api_key = api_key + + if base_url is None: + base_url = os.environ.get("HYPEMAN_BASE_URL") + if base_url is None: + base_url = f"http://localhost:4973" + + custom_headers_env = os.environ.get("HYPEMAN_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 health(self) -> HealthResource: + from .resources.health import HealthResource + + return HealthResource(self) + + @cached_property + def capabilities(self) -> CapabilitiesResource: + from .resources.capabilities import CapabilitiesResource + + return CapabilitiesResource(self) + + @cached_property + def images(self) -> ImagesResource: + from .resources.images import ImagesResource + + return ImagesResource(self) + + @cached_property + def instances(self) -> InstancesResource: + from .resources.instances import InstancesResource + + return InstancesResource(self) + + @cached_property + def snapshots(self) -> SnapshotsResource: + from .resources.snapshots import SnapshotsResource + + return SnapshotsResource(self) + + @cached_property + def volumes(self) -> VolumesResource: + from .resources.volumes import VolumesResource + + return VolumesResource(self) + + @cached_property + def devices(self) -> DevicesResource: + from .resources.devices import DevicesResource + + return DevicesResource(self) + + @cached_property + def ingresses(self) -> IngressesResource: + from .resources.ingresses import IngressesResource + + return IngressesResource(self) + + @cached_property + def resources(self) -> ResourcesResource: + from .resources.resources import ResourcesResource + + return ResourcesResource(self) + + @cached_property + def builders(self) -> BuildersResource: + from .resources.builders import BuildersResource + + return BuildersResource(self) + + @cached_property + def builds(self) -> BuildsResource: + from .resources.builds import BuildsResource + + return BuildsResource(self) + + @cached_property + def pushes(self) -> PushesResource: + from .resources.pushes import PushesResource + + return PushesResource(self) + + @cached_property + def with_raw_response(self) -> HypemanWithRawResponse: + return HypemanWithRawResponse(self) + + @cached_property + def with_streaming_response(self) -> HypemanWithStreamedResponse: + return HypemanWithStreamedResponse(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", + **self._custom_headers, + } + + def copy( + self, + *, + api_key: str | 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, + base_url=base_url or self.base_url, + timeout=self.timeout if isinstance(timeout, NotGiven) else timeout, + http_client=http_client, + max_retries=max_retries if is_given(max_retries) else self.max_retries, + default_headers=headers, + default_query=params, + **_extra_kwargs, + ) + + # Alias for `copy` for nicer inline usage, e.g. + # client.with_options(timeout=10).foo.create(...) + with_options = copy + + @override + def _make_status_error( + self, + err_msg: str, + *, + body: object, + response: httpx.Response, + ) -> APIStatusError: + if response.status_code == 400: + return _exceptions.BadRequestError(err_msg, response=response, body=body) + + if response.status_code == 401: + return _exceptions.AuthenticationError(err_msg, response=response, body=body) + + if response.status_code == 403: + return _exceptions.PermissionDeniedError(err_msg, response=response, body=body) + + if response.status_code == 404: + return _exceptions.NotFoundError(err_msg, response=response, body=body) + + if response.status_code == 409: + return _exceptions.ConflictError(err_msg, response=response, body=body) + + if response.status_code == 422: + return _exceptions.UnprocessableEntityError(err_msg, response=response, body=body) + + if response.status_code == 429: + return _exceptions.RateLimitError(err_msg, response=response, body=body) + + if response.status_code >= 500: + return _exceptions.InternalServerError(err_msg, response=response, body=body) + return APIStatusError(err_msg, response=response, body=body) + + +class AsyncHypeman(AsyncAPIClient): + # client options + api_key: str + + def __init__( + self, + *, + api_key: str | None = None, + base_url: str | httpx.URL | None = None, + 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 AsyncHypeman client instance. + + This automatically infers the `api_key` argument from the `HYPEMAN_API_KEY` environment variable if it is not provided. + """ + if api_key is None: + api_key = os.environ.get("HYPEMAN_API_KEY") + if api_key is None: + raise HypemanError( + "The api_key client option must be set either by passing api_key to the client or by setting the HYPEMAN_API_KEY environment variable" + ) + self.api_key = api_key + + if base_url is None: + base_url = os.environ.get("HYPEMAN_BASE_URL") + if base_url is None: + base_url = f"http://localhost:4973" + + custom_headers_env = os.environ.get("HYPEMAN_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 health(self) -> AsyncHealthResource: + from .resources.health import AsyncHealthResource + + return AsyncHealthResource(self) + + @cached_property + def capabilities(self) -> AsyncCapabilitiesResource: + from .resources.capabilities import AsyncCapabilitiesResource + + return AsyncCapabilitiesResource(self) + + @cached_property + def images(self) -> AsyncImagesResource: + from .resources.images import AsyncImagesResource + + return AsyncImagesResource(self) + + @cached_property + def instances(self) -> AsyncInstancesResource: + from .resources.instances import AsyncInstancesResource + + return AsyncInstancesResource(self) + + @cached_property + def snapshots(self) -> AsyncSnapshotsResource: + from .resources.snapshots import AsyncSnapshotsResource + + return AsyncSnapshotsResource(self) + + @cached_property + def volumes(self) -> AsyncVolumesResource: + from .resources.volumes import AsyncVolumesResource + + return AsyncVolumesResource(self) + + @cached_property + def devices(self) -> AsyncDevicesResource: + from .resources.devices import AsyncDevicesResource + + return AsyncDevicesResource(self) + + @cached_property + def ingresses(self) -> AsyncIngressesResource: + from .resources.ingresses import AsyncIngressesResource + + return AsyncIngressesResource(self) + + @cached_property + def resources(self) -> AsyncResourcesResource: + from .resources.resources import AsyncResourcesResource + + return AsyncResourcesResource(self) + + @cached_property + def builders(self) -> AsyncBuildersResource: + from .resources.builders import AsyncBuildersResource + + return AsyncBuildersResource(self) + + @cached_property + def builds(self) -> AsyncBuildsResource: + from .resources.builds import AsyncBuildsResource + + return AsyncBuildsResource(self) + + @cached_property + def pushes(self) -> AsyncPushesResource: + from .resources.pushes import AsyncPushesResource + + return AsyncPushesResource(self) + + @cached_property + def with_raw_response(self) -> AsyncHypemanWithRawResponse: + return AsyncHypemanWithRawResponse(self) + + @cached_property + def with_streaming_response(self) -> AsyncHypemanWithStreamedResponse: + return AsyncHypemanWithStreamedResponse(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()}", + **self._custom_headers, + } + + def copy( + self, + *, + api_key: str | 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, + base_url=base_url or self.base_url, + timeout=self.timeout if isinstance(timeout, NotGiven) else timeout, + http_client=http_client, + max_retries=max_retries if is_given(max_retries) else self.max_retries, + default_headers=headers, + default_query=params, + **_extra_kwargs, + ) + + # Alias for `copy` for nicer inline usage, e.g. + # client.with_options(timeout=10).foo.create(...) + with_options = copy + + @override + def _make_status_error( + self, + err_msg: str, + *, + body: object, + response: httpx.Response, + ) -> APIStatusError: + if response.status_code == 400: + return _exceptions.BadRequestError(err_msg, response=response, body=body) + + if response.status_code == 401: + return _exceptions.AuthenticationError(err_msg, response=response, body=body) + + if response.status_code == 403: + return _exceptions.PermissionDeniedError(err_msg, response=response, body=body) + + if response.status_code == 404: + return _exceptions.NotFoundError(err_msg, response=response, body=body) + + if response.status_code == 409: + return _exceptions.ConflictError(err_msg, response=response, body=body) + + if response.status_code == 422: + return _exceptions.UnprocessableEntityError(err_msg, response=response, body=body) + + if response.status_code == 429: + return _exceptions.RateLimitError(err_msg, response=response, body=body) + + if response.status_code >= 500: + return _exceptions.InternalServerError(err_msg, response=response, body=body) + return APIStatusError(err_msg, response=response, body=body) + + +class HypemanWithRawResponse: + _client: Hypeman + + def __init__(self, client: Hypeman) -> None: + self._client = client + + @cached_property + def health(self) -> health.HealthResourceWithRawResponse: + from .resources.health import HealthResourceWithRawResponse + + return HealthResourceWithRawResponse(self._client.health) + + @cached_property + def capabilities(self) -> capabilities.CapabilitiesResourceWithRawResponse: + from .resources.capabilities import CapabilitiesResourceWithRawResponse + + return CapabilitiesResourceWithRawResponse(self._client.capabilities) + + @cached_property + def images(self) -> images.ImagesResourceWithRawResponse: + from .resources.images import ImagesResourceWithRawResponse + + return ImagesResourceWithRawResponse(self._client.images) + + @cached_property + def instances(self) -> instances.InstancesResourceWithRawResponse: + from .resources.instances import InstancesResourceWithRawResponse + + return InstancesResourceWithRawResponse(self._client.instances) + + @cached_property + def snapshots(self) -> snapshots.SnapshotsResourceWithRawResponse: + from .resources.snapshots import SnapshotsResourceWithRawResponse + + return SnapshotsResourceWithRawResponse(self._client.snapshots) + + @cached_property + def volumes(self) -> volumes.VolumesResourceWithRawResponse: + from .resources.volumes import VolumesResourceWithRawResponse + + return VolumesResourceWithRawResponse(self._client.volumes) + + @cached_property + def devices(self) -> devices.DevicesResourceWithRawResponse: + from .resources.devices import DevicesResourceWithRawResponse + + return DevicesResourceWithRawResponse(self._client.devices) + + @cached_property + def ingresses(self) -> ingresses.IngressesResourceWithRawResponse: + from .resources.ingresses import IngressesResourceWithRawResponse + + return IngressesResourceWithRawResponse(self._client.ingresses) + + @cached_property + def resources(self) -> resources.ResourcesResourceWithRawResponse: + from .resources.resources import ResourcesResourceWithRawResponse + + return ResourcesResourceWithRawResponse(self._client.resources) + + @cached_property + def builders(self) -> builders.BuildersResourceWithRawResponse: + from .resources.builders import BuildersResourceWithRawResponse + + return BuildersResourceWithRawResponse(self._client.builders) + + @cached_property + def builds(self) -> builds.BuildsResourceWithRawResponse: + from .resources.builds import BuildsResourceWithRawResponse + + return BuildsResourceWithRawResponse(self._client.builds) + + @cached_property + def pushes(self) -> pushes.PushesResourceWithRawResponse: + from .resources.pushes import PushesResourceWithRawResponse + + return PushesResourceWithRawResponse(self._client.pushes) + + +class AsyncHypemanWithRawResponse: + _client: AsyncHypeman + + def __init__(self, client: AsyncHypeman) -> None: + self._client = client + + @cached_property + def health(self) -> health.AsyncHealthResourceWithRawResponse: + from .resources.health import AsyncHealthResourceWithRawResponse + + return AsyncHealthResourceWithRawResponse(self._client.health) + + @cached_property + def capabilities(self) -> capabilities.AsyncCapabilitiesResourceWithRawResponse: + from .resources.capabilities import AsyncCapabilitiesResourceWithRawResponse + + return AsyncCapabilitiesResourceWithRawResponse(self._client.capabilities) + + @cached_property + def images(self) -> images.AsyncImagesResourceWithRawResponse: + from .resources.images import AsyncImagesResourceWithRawResponse + + return AsyncImagesResourceWithRawResponse(self._client.images) + + @cached_property + def instances(self) -> instances.AsyncInstancesResourceWithRawResponse: + from .resources.instances import AsyncInstancesResourceWithRawResponse + + return AsyncInstancesResourceWithRawResponse(self._client.instances) + + @cached_property + def snapshots(self) -> snapshots.AsyncSnapshotsResourceWithRawResponse: + from .resources.snapshots import AsyncSnapshotsResourceWithRawResponse + + return AsyncSnapshotsResourceWithRawResponse(self._client.snapshots) + + @cached_property + def volumes(self) -> volumes.AsyncVolumesResourceWithRawResponse: + from .resources.volumes import AsyncVolumesResourceWithRawResponse + + return AsyncVolumesResourceWithRawResponse(self._client.volumes) + + @cached_property + def devices(self) -> devices.AsyncDevicesResourceWithRawResponse: + from .resources.devices import AsyncDevicesResourceWithRawResponse + + return AsyncDevicesResourceWithRawResponse(self._client.devices) + + @cached_property + def ingresses(self) -> ingresses.AsyncIngressesResourceWithRawResponse: + from .resources.ingresses import AsyncIngressesResourceWithRawResponse + + return AsyncIngressesResourceWithRawResponse(self._client.ingresses) + + @cached_property + def resources(self) -> resources.AsyncResourcesResourceWithRawResponse: + from .resources.resources import AsyncResourcesResourceWithRawResponse + + return AsyncResourcesResourceWithRawResponse(self._client.resources) + + @cached_property + def builders(self) -> builders.AsyncBuildersResourceWithRawResponse: + from .resources.builders import AsyncBuildersResourceWithRawResponse + + return AsyncBuildersResourceWithRawResponse(self._client.builders) + + @cached_property + def builds(self) -> builds.AsyncBuildsResourceWithRawResponse: + from .resources.builds import AsyncBuildsResourceWithRawResponse + + return AsyncBuildsResourceWithRawResponse(self._client.builds) + + @cached_property + def pushes(self) -> pushes.AsyncPushesResourceWithRawResponse: + from .resources.pushes import AsyncPushesResourceWithRawResponse + + return AsyncPushesResourceWithRawResponse(self._client.pushes) + + +class HypemanWithStreamedResponse: + _client: Hypeman + + def __init__(self, client: Hypeman) -> None: + self._client = client + + @cached_property + def health(self) -> health.HealthResourceWithStreamingResponse: + from .resources.health import HealthResourceWithStreamingResponse + + return HealthResourceWithStreamingResponse(self._client.health) + + @cached_property + def capabilities(self) -> capabilities.CapabilitiesResourceWithStreamingResponse: + from .resources.capabilities import CapabilitiesResourceWithStreamingResponse + + return CapabilitiesResourceWithStreamingResponse(self._client.capabilities) + + @cached_property + def images(self) -> images.ImagesResourceWithStreamingResponse: + from .resources.images import ImagesResourceWithStreamingResponse + + return ImagesResourceWithStreamingResponse(self._client.images) + + @cached_property + def instances(self) -> instances.InstancesResourceWithStreamingResponse: + from .resources.instances import InstancesResourceWithStreamingResponse + + return InstancesResourceWithStreamingResponse(self._client.instances) + + @cached_property + def snapshots(self) -> snapshots.SnapshotsResourceWithStreamingResponse: + from .resources.snapshots import SnapshotsResourceWithStreamingResponse + + return SnapshotsResourceWithStreamingResponse(self._client.snapshots) + + @cached_property + def volumes(self) -> volumes.VolumesResourceWithStreamingResponse: + from .resources.volumes import VolumesResourceWithStreamingResponse + + return VolumesResourceWithStreamingResponse(self._client.volumes) + + @cached_property + def devices(self) -> devices.DevicesResourceWithStreamingResponse: + from .resources.devices import DevicesResourceWithStreamingResponse + + return DevicesResourceWithStreamingResponse(self._client.devices) + + @cached_property + def ingresses(self) -> ingresses.IngressesResourceWithStreamingResponse: + from .resources.ingresses import IngressesResourceWithStreamingResponse + + return IngressesResourceWithStreamingResponse(self._client.ingresses) + + @cached_property + def resources(self) -> resources.ResourcesResourceWithStreamingResponse: + from .resources.resources import ResourcesResourceWithStreamingResponse + + return ResourcesResourceWithStreamingResponse(self._client.resources) + + @cached_property + def builders(self) -> builders.BuildersResourceWithStreamingResponse: + from .resources.builders import BuildersResourceWithStreamingResponse + + return BuildersResourceWithStreamingResponse(self._client.builders) + + @cached_property + def builds(self) -> builds.BuildsResourceWithStreamingResponse: + from .resources.builds import BuildsResourceWithStreamingResponse + + return BuildsResourceWithStreamingResponse(self._client.builds) + + @cached_property + def pushes(self) -> pushes.PushesResourceWithStreamingResponse: + from .resources.pushes import PushesResourceWithStreamingResponse + + return PushesResourceWithStreamingResponse(self._client.pushes) + + +class AsyncHypemanWithStreamedResponse: + _client: AsyncHypeman + + def __init__(self, client: AsyncHypeman) -> None: + self._client = client + + @cached_property + def health(self) -> health.AsyncHealthResourceWithStreamingResponse: + from .resources.health import AsyncHealthResourceWithStreamingResponse + + return AsyncHealthResourceWithStreamingResponse(self._client.health) + + @cached_property + def capabilities(self) -> capabilities.AsyncCapabilitiesResourceWithStreamingResponse: + from .resources.capabilities import AsyncCapabilitiesResourceWithStreamingResponse + + return AsyncCapabilitiesResourceWithStreamingResponse(self._client.capabilities) + + @cached_property + def images(self) -> images.AsyncImagesResourceWithStreamingResponse: + from .resources.images import AsyncImagesResourceWithStreamingResponse + + return AsyncImagesResourceWithStreamingResponse(self._client.images) + + @cached_property + def instances(self) -> instances.AsyncInstancesResourceWithStreamingResponse: + from .resources.instances import AsyncInstancesResourceWithStreamingResponse + + return AsyncInstancesResourceWithStreamingResponse(self._client.instances) + + @cached_property + def snapshots(self) -> snapshots.AsyncSnapshotsResourceWithStreamingResponse: + from .resources.snapshots import AsyncSnapshotsResourceWithStreamingResponse + + return AsyncSnapshotsResourceWithStreamingResponse(self._client.snapshots) + + @cached_property + def volumes(self) -> volumes.AsyncVolumesResourceWithStreamingResponse: + from .resources.volumes import AsyncVolumesResourceWithStreamingResponse + + return AsyncVolumesResourceWithStreamingResponse(self._client.volumes) + + @cached_property + def devices(self) -> devices.AsyncDevicesResourceWithStreamingResponse: + from .resources.devices import AsyncDevicesResourceWithStreamingResponse + + return AsyncDevicesResourceWithStreamingResponse(self._client.devices) + + @cached_property + def ingresses(self) -> ingresses.AsyncIngressesResourceWithStreamingResponse: + from .resources.ingresses import AsyncIngressesResourceWithStreamingResponse + + return AsyncIngressesResourceWithStreamingResponse(self._client.ingresses) + + @cached_property + def resources(self) -> resources.AsyncResourcesResourceWithStreamingResponse: + from .resources.resources import AsyncResourcesResourceWithStreamingResponse + + return AsyncResourcesResourceWithStreamingResponse(self._client.resources) + + @cached_property + def builders(self) -> builders.AsyncBuildersResourceWithStreamingResponse: + from .resources.builders import AsyncBuildersResourceWithStreamingResponse + + return AsyncBuildersResourceWithStreamingResponse(self._client.builders) + + @cached_property + def builds(self) -> builds.AsyncBuildsResourceWithStreamingResponse: + from .resources.builds import AsyncBuildsResourceWithStreamingResponse + + return AsyncBuildsResourceWithStreamingResponse(self._client.builds) + + @cached_property + def pushes(self) -> pushes.AsyncPushesResourceWithStreamingResponse: + from .resources.pushes import AsyncPushesResourceWithStreamingResponse + + return AsyncPushesResourceWithStreamingResponse(self._client.pushes) + + +Client = Hypeman + +AsyncClient = AsyncHypeman diff --git a/src/hypeman/_compat.py b/src/hypeman/_compat.py new file mode 100644 index 0000000..e6690a4 --- /dev/null +++ b/src/hypeman/_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/hypeman/_constants.py b/src/hypeman/_constants.py new file mode 100644 index 0000000..6ddf2c7 --- /dev/null +++ b/src/hypeman/_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/hypeman/_exceptions.py b/src/hypeman/_exceptions.py new file mode 100644 index 0000000..b023027 --- /dev/null +++ b/src/hypeman/_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 HypemanError(Exception): + pass + + +class APIError(HypemanError): + 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/hypeman/_files.py b/src/hypeman/_files.py new file mode 100644 index 0000000..b1eb618 --- /dev/null +++ b/src/hypeman/_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/hypeman-python/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/hypeman/_models.py b/src/hypeman/_models.py new file mode 100644 index 0000000..8c5ab26 --- /dev/null +++ b/src/hypeman/_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/hypeman/_qs.py b/src/hypeman/_qs.py new file mode 100644 index 0000000..4127c19 --- /dev/null +++ b/src/hypeman/_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/hypeman/_resource.py b/src/hypeman/_resource.py new file mode 100644 index 0000000..8816fb4 --- /dev/null +++ b/src/hypeman/_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 Hypeman, AsyncHypeman + + +class SyncAPIResource: + _client: Hypeman + + def __init__(self, client: Hypeman) -> 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: AsyncHypeman + + def __init__(self, client: AsyncHypeman) -> 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/hypeman/_response.py b/src/hypeman/_response.py new file mode 100644 index 0000000..d14f11f --- /dev/null +++ b/src/hypeman/_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 HypemanError, 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 hypeman 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 hypeman 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 hypeman 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 `hypeman._streaming` for reference", + ) + + +class StreamAlreadyConsumed(HypemanError): + """ + 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/hypeman/_streaming.py b/src/hypeman/_streaming.py new file mode 100644 index 0000000..24c8101 --- /dev/null +++ b/src/hypeman/_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 Hypeman, AsyncHypeman + 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: Hypeman, + 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: AsyncHypeman, + 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/hypeman/_types.py b/src/hypeman/_types.py new file mode 100644 index 0000000..ebcce08 --- /dev/null +++ b/src/hypeman/_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 hypeman 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/hypeman/_utils/__init__.py b/src/hypeman/_utils/__init__.py new file mode 100644 index 0000000..1c090e5 --- /dev/null +++ b/src/hypeman/_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/hypeman/_utils/_compat.py b/src/hypeman/_utils/_compat.py new file mode 100644 index 0000000..2c70b29 --- /dev/null +++ b/src/hypeman/_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/hypeman/_utils/_datetime_parse.py b/src/hypeman/_utils/_datetime_parse.py new file mode 100644 index 0000000..7cb9d9e --- /dev/null +++ b/src/hypeman/_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/hypeman/_utils/_json.py b/src/hypeman/_utils/_json.py new file mode 100644 index 0000000..6058421 --- /dev/null +++ b/src/hypeman/_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/hypeman/_utils/_logs.py b/src/hypeman/_utils/_logs.py new file mode 100644 index 0000000..8b581dd --- /dev/null +++ b/src/hypeman/_utils/_logs.py @@ -0,0 +1,25 @@ +import os +import logging + +logger: logging.Logger = logging.getLogger("hypeman") +httpx_logger: logging.Logger = logging.getLogger("httpx") + + +def _basic_config() -> None: + # e.g. [2023-10-05 14:12:26 - hypeman._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("HYPEMAN_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/hypeman/_utils/_path.py b/src/hypeman/_utils/_path.py new file mode 100644 index 0000000..4d6e1e4 --- /dev/null +++ b/src/hypeman/_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/hypeman/_utils/_proxy.py b/src/hypeman/_utils/_proxy.py new file mode 100644 index 0000000..0f239a3 --- /dev/null +++ b/src/hypeman/_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/hypeman/_utils/_reflection.py b/src/hypeman/_utils/_reflection.py new file mode 100644 index 0000000..89aa712 --- /dev/null +++ b/src/hypeman/_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/hypeman/_utils/_resources_proxy.py b/src/hypeman/_utils/_resources_proxy.py new file mode 100644 index 0000000..824fb03 --- /dev/null +++ b/src/hypeman/_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 `hypeman.resources` module. + + This is used so that we can lazily import `hypeman.resources` only when + needed *and* so that users can just import `hypeman` and reference `hypeman.resources` + """ + + @override + def __load__(self) -> Any: + import importlib + + mod = importlib.import_module("hypeman.resources") + return mod + + +resources = ResourcesProxy().__as_proxied__() diff --git a/src/hypeman/_utils/_streams.py b/src/hypeman/_utils/_streams.py new file mode 100644 index 0000000..f4a0208 --- /dev/null +++ b/src/hypeman/_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/hypeman/_utils/_sync.py b/src/hypeman/_utils/_sync.py new file mode 100644 index 0000000..f6027c1 --- /dev/null +++ b/src/hypeman/_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/hypeman/_utils/_transform.py b/src/hypeman/_utils/_transform.py new file mode 100644 index 0000000..5207549 --- /dev/null +++ b/src/hypeman/_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/hypeman/_utils/_typing.py b/src/hypeman/_utils/_typing.py new file mode 100644 index 0000000..193109f --- /dev/null +++ b/src/hypeman/_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/hypeman/_utils/_utils.py b/src/hypeman/_utils/_utils.py new file mode 100644 index 0000000..199cd23 --- /dev/null +++ b/src/hypeman/_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/hypeman/_version.py b/src/hypeman/_version.py new file mode 100644 index 0000000..3aa5989 --- /dev/null +++ b/src/hypeman/_version.py @@ -0,0 +1,4 @@ +# File generated from our OpenAPI spec by Stainless. See CONTRIBUTING.md for details. + +__title__ = "hypeman" +__version__ = "0.1.0" # x-release-please-version diff --git a/src/hypeman/lib/.keep b/src/hypeman/lib/.keep new file mode 100644 index 0000000..5e2c99f --- /dev/null +++ b/src/hypeman/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/hypeman/lib/__init__.py b/src/hypeman/lib/__init__.py new file mode 100644 index 0000000..66956c6 --- /dev/null +++ b/src/hypeman/lib/__init__.py @@ -0,0 +1,34 @@ +"""Manually maintained APIs for Hypeman's WebSocket endpoints.""" + +from .cp import ( + CopyCallbacks, + CopyProtocolError, + cp_to_instance, + cp_from_instance, + cp_to_instance_async, + cp_from_instance_async, +) +from ._ws import ( + SyncWebSocket, + AsyncWebSocket, + SyncWebSocketConnector, + AsyncWebSocketConnector, +) +from .exec import ExecResult, ExecProtocolError, exec, exec_async + +__all__ = [ + "AsyncWebSocket", + "AsyncWebSocketConnector", + "CopyCallbacks", + "CopyProtocolError", + "ExecProtocolError", + "ExecResult", + "SyncWebSocket", + "SyncWebSocketConnector", + "cp_from_instance", + "cp_from_instance_async", + "cp_to_instance", + "cp_to_instance_async", + "exec", + "exec_async", +] diff --git a/src/hypeman/lib/_ws.py b/src/hypeman/lib/_ws.py new file mode 100644 index 0000000..2e3dffe --- /dev/null +++ b/src/hypeman/lib/_ws.py @@ -0,0 +1,132 @@ +from __future__ import annotations + +from types import TracebackType +from typing import TypeVar, Protocol, cast +from urllib.parse import urlsplit, urlunsplit + +from websockets.sync.client import connect as websocket_connect +from websockets.asyncio.client import connect as async_websocket_connect + +__all__ = [ + "AsyncWebSocket", + "AsyncWebSocketConnector", + "ClientConfig", + "SyncWebSocket", + "SyncWebSocketConnector", +] + + +MAX_INBOUND_MESSAGE_SIZE = 2**20 + + +class ClientConfig(Protocol): + """The generated client settings used by the custom WebSocket APIs.""" + + api_key: str + + @property + def base_url(self) -> object: ... + + +class SyncWebSocket(Protocol): + def send(self, message: bytes | str) -> None: ... + + def recv(self) -> bytes | str: ... + + def close(self) -> None: ... + + def __enter__(self: _SyncWebSocketT) -> _SyncWebSocketT: ... + + def __exit__( + self, + exc_type: type[BaseException] | None, + exc_value: BaseException | None, + traceback: TracebackType | None, + ) -> bool | None: ... + + +_SyncWebSocketT = TypeVar("_SyncWebSocketT", bound=SyncWebSocket) + + +class SyncWebSocketConnector(Protocol): + def __call__(self, url: str, *, additional_headers: dict[str, str], max_size: int) -> SyncWebSocket: ... + + +class AsyncWebSocket(Protocol): + async def send(self, message: bytes | str) -> None: ... + + async def recv(self) -> bytes | str: ... + + async def close(self) -> None: ... + + async def __aenter__(self: _AsyncWebSocketT) -> _AsyncWebSocketT: ... + + async def __aexit__( + self, + exc_type: type[BaseException] | None, + exc_value: BaseException | None, + traceback: TracebackType | None, + ) -> bool | None: ... + + +_AsyncWebSocketT = TypeVar("_AsyncWebSocketT", bound=AsyncWebSocket) + + +class AsyncWebSocketContext(Protocol): + async def __aenter__(self) -> AsyncWebSocket: ... + + async def __aexit__( + self, + exc_type: type[BaseException] | None, + exc_value: BaseException | None, + traceback: TracebackType | None, + ) -> bool | None: ... + + +class AsyncWebSocketConnector(Protocol): + def __call__(self, url: str, *, additional_headers: dict[str, str], max_size: int) -> AsyncWebSocketContext: ... + + +def sync_connect( + url: str, + *, + additional_headers: dict[str, str], + max_size: int = MAX_INBOUND_MESSAGE_SIZE, +) -> SyncWebSocket: + return cast(SyncWebSocket, websocket_connect(url, additional_headers=additional_headers, max_size=max_size)) + + +def async_connect( + url: str, + *, + additional_headers: dict[str, str], + max_size: int = MAX_INBOUND_MESSAGE_SIZE, +) -> AsyncWebSocketContext: + return cast( + AsyncWebSocketContext, + async_websocket_connect(url, additional_headers=additional_headers, max_size=max_size), + ) + + +def connection_settings( + client: ClientConfig, + instance_id: str, + endpoint: str, +) -> tuple[str, dict[str, str]]: + if not instance_id or "/" in instance_id or "\\" in instance_id or ".." in instance_id: + raise ValueError("instance_id must not be empty or contain path traversal sequences") + + parsed = urlsplit(str(client.base_url)) + if parsed.scheme == "https": + scheme = "wss" + elif parsed.scheme == "http": + scheme = "ws" + else: + raise ValueError("client.base_url must use http or https") + if not parsed.netloc: + raise ValueError("client.base_url must include a host") + + prefix = parsed.path.rstrip("/") + path = f"{prefix}/instances/{instance_id}/{endpoint}" + url = urlunsplit((scheme, parsed.netloc, path, parsed.query, "")) + return url, {"Authorization": f"Bearer {client.api_key}"} diff --git a/src/hypeman/lib/cp.py b/src/hypeman/lib/cp.py new file mode 100644 index 0000000..227e28d --- /dev/null +++ b/src/hypeman/lib/cp.py @@ -0,0 +1,555 @@ +from __future__ import annotations + +import os +import json +import stat +import asyncio +import tempfile +from uuid import uuid4 +from typing import BinaryIO, cast +from pathlib import Path, PurePosixPath +from dataclasses import dataclass +from collections.abc import Callable + +from ._ws import ( + MAX_INBOUND_MESSAGE_SIZE, + ClientConfig, + SyncWebSocket, + AsyncWebSocket, + SyncWebSocketConnector, + AsyncWebSocketConnector, + sync_connect, + async_connect, + connection_settings, +) + +__all__ = [ + "CopyCallbacks", + "CopyProtocolError", + "cp_from_instance", + "cp_from_instance_async", + "cp_to_instance", + "cp_to_instance_async", +] + +_CHUNK_SIZE = 32 * 1024 + + +class CopyProtocolError(RuntimeError): + """The cp WebSocket sent an invalid or incomplete transfer.""" + + +@dataclass(frozen=True) +class CopyCallbacks: + """Optional per-file transfer callbacks. + + ``on_progress`` receives the bytes copied for the current file, not the + aggregate across a directory. + """ + + on_file_start: Callable[[str, int], None] | None = None + on_progress: Callable[[int], None] | None = None + on_file_end: Callable[[str], None] | None = None + + +@dataclass(frozen=True) +class _UploadEntry: + source: Path + guest_path: str + is_dir: bool + mode: int + uid: int | None + gid: int | None + size: int + + +def _upload_entries( + source: Path, + guest_path: str, + *, + mode: int | None, + archive: bool, + follow_symlinks: bool, +) -> list[_UploadEntry]: + if mode is not None and (isinstance(mode, bool) or mode < 0 or mode > 0o7777): + raise ValueError("mode must be between 0 and 0o7777") + + entries: list[_UploadEntry] = [] + visited: set[tuple[int, int]] = set() + + def visit(local_path: Path, remote_path: str, root: bool = False) -> None: + try: + link_info = local_path.lstat() + except OSError as exc: + raise OSError(f"cannot stat upload source {local_path}") from exc + + is_link = stat.S_ISLNK(link_info.st_mode) + try: + info = local_path.stat() if is_link else link_info + except OSError as exc: + raise OSError(f"cannot follow upload symlink {local_path}") from exc + + is_dir = stat.S_ISDIR(info.st_mode) + entry_mode = mode if root and mode is not None else stat.S_IMODE(info.st_mode) + uid = int(getattr(info, "st_uid", 0)) if archive else None + gid = int(getattr(info, "st_gid", 0)) if archive else None + entries.append( + _UploadEntry( + source=local_path, + guest_path=remote_path, + is_dir=is_dir, + mode=entry_mode, + uid=uid, + gid=gid, + size=0 if is_dir else info.st_size, + ) + ) + if not is_dir: + return + + # The copy-to protocol has no symlink frame. Match the existing SDKs by + # following file links. Directory links are represented as empty + # directories unless callers explicitly opt into traversal. + if is_link and not follow_symlinks: + return + identity = (info.st_dev, info.st_ino) + if identity in visited: + entries.pop() + return + visited.add(identity) + for child in sorted(local_path.iterdir(), key=lambda item: item.name): + visit(child, str(PurePosixPath(remote_path) / child.name)) + + visit(source, guest_path, root=True) + return entries + + +def _request(entry: _UploadEntry) -> str: + payload = { + "direction": "to", + "guest_path": entry.guest_path, + "is_dir": entry.is_dir, + "mode": entry.mode, + } + if entry.uid is not None: + payload["uid"] = entry.uid + if entry.gid is not None: + payload["gid"] = entry.gid + return json.dumps(payload, separators=(",", ":")) + + +def _parse_message(frame: str) -> dict[str, object]: + try: + decoded = cast(object, json.loads(frame)) + except json.JSONDecodeError as exc: + raise CopyProtocolError("cp sent malformed JSON") from exc + if not isinstance(decoded, dict): + raise CopyProtocolError("cp sent an invalid control frame") + message = cast(dict[str, object], decoded) + if not isinstance(message.get("type"), str): + raise CopyProtocolError("cp sent an invalid control frame") + return message + + +def _integer_field( + message: dict[str, object], name: str, default: int | None = None, maximum: int | None = None +) -> int: + value = message.get(name, default) + if isinstance(value, bool) or not isinstance(value, int) or value < 0 or (maximum is not None and value > maximum): + raise CopyProtocolError(f"cp header {name} has an invalid integer value") + return value + + +def _check_upload_result(frame: bytes | str, expected_size: int) -> None: + if not isinstance(frame, str): + raise CopyProtocolError("cp upload expected a result control frame") + message = _parse_message(frame) + message_type = message["type"] + if message_type == "error": + detail = message.get("message") + raise CopyProtocolError(f"copy failed: {detail if isinstance(detail, str) else 'unknown server error'}") + if message_type != "result" or not isinstance(message.get("success"), bool): + raise CopyProtocolError("cp upload expected a result control frame") + if not message["success"]: + detail = message.get("error") + raise CopyProtocolError(f"copy failed: {detail if isinstance(detail, str) else 'unknown server error'}") + bytes_written = message.get("bytes_written", 0) + if isinstance(bytes_written, bool) or not isinstance(bytes_written, int) or bytes_written != expected_size: + raise CopyProtocolError(f"cp upload wrote {bytes_written!r} bytes; expected {expected_size}") + + +def _copy_file_sync(websocket: SyncWebSocket, entry: _UploadEntry, callbacks: CopyCallbacks | None) -> None: + if callbacks and callbacks.on_file_start: + callbacks.on_file_start(str(entry.source), entry.size) + copied = 0 + with entry.source.open("rb") as source: + while chunk := source.read(_CHUNK_SIZE): + websocket.send(chunk) + copied += len(chunk) + if callbacks and callbacks.on_progress: + callbacks.on_progress(copied) + websocket.send('{"type":"end"}') + try: + result = websocket.recv() + except Exception as exc: + raise CopyProtocolError("cp upload ended before the result frame") from exc + _check_upload_result(result, entry.size) + if callbacks and callbacks.on_file_end: + callbacks.on_file_end(str(entry.source)) + + +def cp_to_instance( + client: ClientConfig, + instance_id: str, + src_path: str | os.PathLike[str], + dst_path: str, + *, + mode: int | None = None, + archive: bool = False, + follow_symlinks: bool = False, + callbacks: CopyCallbacks | None = None, + connector: SyncWebSocketConnector = sync_connect, +) -> None: + """Copy a local file or directory into a running instance.""" + + entries = _upload_entries( + Path(src_path), + dst_path, + mode=mode, + archive=archive, + follow_symlinks=follow_symlinks, + ) + url, headers = connection_settings(client, instance_id, "cp") + for entry in entries: + with connector(url, additional_headers=headers, max_size=MAX_INBOUND_MESSAGE_SIZE) as websocket: + websocket.send(_request(entry)) + if entry.is_dir: + websocket.send('{"type":"end"}') + try: + result = websocket.recv() + except Exception as exc: + raise CopyProtocolError("cp upload ended before the result frame") from exc + _check_upload_result(result, 0) + else: + _copy_file_sync(websocket, entry, callbacks) + + +async def _copy_file_async(websocket: AsyncWebSocket, entry: _UploadEntry, callbacks: CopyCallbacks | None) -> None: + if callbacks and callbacks.on_file_start: + callbacks.on_file_start(str(entry.source), entry.size) + copied = 0 + source = await asyncio.to_thread(entry.source.open, "rb") + try: + while chunk := await asyncio.to_thread(source.read, _CHUNK_SIZE): + await websocket.send(chunk) + copied += len(chunk) + if callbacks and callbacks.on_progress: + callbacks.on_progress(copied) + finally: + await asyncio.to_thread(source.close) + await websocket.send('{"type":"end"}') + try: + result = await websocket.recv() + except Exception as exc: + raise CopyProtocolError("cp upload ended before the result frame") from exc + _check_upload_result(result, entry.size) + if callbacks and callbacks.on_file_end: + callbacks.on_file_end(str(entry.source)) + + +async def cp_to_instance_async( + client: ClientConfig, + instance_id: str, + src_path: str | os.PathLike[str], + dst_path: str, + *, + mode: int | None = None, + archive: bool = False, + follow_symlinks: bool = False, + callbacks: CopyCallbacks | None = None, + connector: AsyncWebSocketConnector = async_connect, +) -> None: + """Asynchronous counterpart to :func:`cp_to_instance`.""" + + entries = await asyncio.to_thread( + _upload_entries, + Path(src_path), + dst_path, + mode=mode, + archive=archive, + follow_symlinks=follow_symlinks, + ) + url, headers = connection_settings(client, instance_id, "cp") + for entry in entries: + async with connector(url, additional_headers=headers, max_size=MAX_INBOUND_MESSAGE_SIZE) as websocket: + await websocket.send(_request(entry)) + if entry.is_dir: + await websocket.send('{"type":"end"}') + try: + result = await websocket.recv() + except Exception as exc: + raise CopyProtocolError("cp upload ended before the result frame") from exc + _check_upload_result(result, 0) + else: + await _copy_file_async(websocket, entry, callbacks) + + +@dataclass +class _DownloadHeader: + path: str + target: Path + mode: int + is_dir: bool + is_symlink: bool + link_target: str + size: int + mtime: int + uid: int + gid: int + + +class _DownloadState: + def __init__(self, destination: Path, archive: bool, callbacks: CopyCallbacks | None) -> None: + destination.mkdir(parents=True, exist_ok=True) + self.root = destination.resolve() + self.archive = archive + self.callbacks = callbacks + self.header: _DownloadHeader | None = None + self.file: BinaryIO | None = None + self.temp_path: Path | None = None + self.bytes_received = 0 + self.directories: list[_DownloadHeader] = [] + self.complete = False + + def abort(self) -> None: + if self.file is not None: + self.file.close() + self.file = None + if self.temp_path is not None: + self.temp_path.unlink(missing_ok=True) + self.temp_path = None + + def consume(self, frame: bytes | str) -> None: + if isinstance(frame, bytes): + self._data(frame) + return + message = _parse_message(frame) + message_type = message["type"] + if message_type == "header": + self._start(message) + elif message_type == "end": + self._end(message) + elif message_type == "error": + detail = message.get("message") + path = message.get("path") + location = f" at {path}" if isinstance(path, str) and path else "" + raise CopyProtocolError( + f"copy failed{location}: {detail if isinstance(detail, str) else 'unknown server error'}" + ) + else: + raise CopyProtocolError(f"cp download sent unexpected {message_type!r} frame") + + def _safe_target(self, server_path: object) -> Path: + if not isinstance(server_path, str) or not server_path or "\\" in server_path: + raise CopyProtocolError("cp header path must be a non-empty relative POSIX path") + relative = PurePosixPath(server_path) + if relative.is_absolute() or any(part in ("", ".", "..") for part in relative.parts): + raise CopyProtocolError(f"cp path escapes destination: {server_path}") + target = self.root.joinpath(*relative.parts) + self._safe_parents(target.parent) + return target + + def _safe_parents(self, parent: Path) -> None: + try: + relative = parent.relative_to(self.root) + except ValueError as exc: + raise CopyProtocolError("cp path escapes destination") from exc + current = self.root + for part in relative.parts: + current /= part + if current.is_symlink(): + raise CopyProtocolError(f"cp path traverses local symlink: {current}") + parent.mkdir(parents=True, exist_ok=True) + + def _start(self, message: dict[str, object]) -> None: + if self.header is not None: + raise CopyProtocolError("cp sent a new header before ending the previous entry") + target = self._safe_target(message.get("path")) + mode = _integer_field(message, "mode", maximum=0o777) + size = _integer_field(message, "size") + mtime = _integer_field(message, "mtime") + uid = _integer_field(message, "uid", 0) + gid = _integer_field(message, "gid", 0) + is_dir = message.get("is_dir") + is_symlink = message.get("is_symlink", False) + if not isinstance(is_dir, bool) or not isinstance(is_symlink, bool) or (is_dir and is_symlink): + raise CopyProtocolError("cp header has invalid entry type flags") + link_target = message.get("link_target", "") + if not isinstance(link_target, str): + raise CopyProtocolError("cp header link_target must be a string") + + header = _DownloadHeader( + path=str(message["path"]), + target=target, + mode=mode, + is_dir=is_dir, + is_symlink=is_symlink, + link_target=link_target, + size=size, + mtime=mtime, + uid=uid, + gid=gid, + ) + self.header = header + self.bytes_received = 0 + if header.is_dir: + if target.is_symlink() or (target.exists() and not target.is_dir()): + raise CopyProtocolError(f"cp cannot replace local path with directory: {target}") + target.mkdir(parents=True, exist_ok=True) + self.directories.append(header) + elif header.is_symlink: + self._create_symlink(header) + else: + if target.is_symlink() or (target.exists() and target.is_dir()): + raise CopyProtocolError(f"cp refuses unsafe local file target: {target}") + descriptor, temp_name = tempfile.mkstemp(prefix=".hypeman-cp-", dir=target.parent) + self.temp_path = Path(temp_name) + self.file = os.fdopen(descriptor, "wb") + if self.callbacks and self.callbacks.on_file_start: + self.callbacks.on_file_start(header.path, header.size) + + def _create_symlink(self, header: _DownloadHeader) -> None: + target = PurePosixPath(header.link_target) + if not header.link_target or "\\" in header.link_target or target.is_absolute(): + raise CopyProtocolError(f"cp sent unsafe symlink target: {header.link_target}") + resolved = header.target.parent.joinpath(*target.parts).resolve(strict=False) + try: + resolved.relative_to(self.root) + except ValueError as exc: + raise CopyProtocolError(f"cp symlink target escapes destination: {header.link_target}") from exc + temp = header.target.parent / f".hypeman-cp-link-{uuid4().hex}" + try: + temp.symlink_to(header.link_target) + os.replace(temp, header.target) + finally: + temp.unlink(missing_ok=True) + self._chown(header.target, header, follow_symlinks=False) + + def _data(self, data: bytes) -> None: + if self.header is None or self.file is None or self.header.is_dir or self.header.is_symlink: + raise CopyProtocolError("cp sent binary data without a regular file header") + if self.bytes_received + len(data) > self.header.size: + raise CopyProtocolError(f"cp sent more bytes than declared for {self.header.path}") + written = self.file.write(data) + if written != len(data): + raise OSError(f"short local write for {self.header.path}") + self.bytes_received += written + if self.callbacks and self.callbacks.on_progress: + self.callbacks.on_progress(self.bytes_received) + + def _end(self, message: dict[str, object]) -> None: + if self.header is None: + raise CopyProtocolError("cp sent an end frame without a header") + final = message.get("final") + if not isinstance(final, bool): + raise CopyProtocolError("cp end final must be a boolean") + header = self.header + if not header.is_dir and not header.is_symlink: + if self.file is None or self.temp_path is None: + raise CopyProtocolError("cp regular file state is incomplete") + if self.bytes_received != header.size: + raise CopyProtocolError( + f"cp received {self.bytes_received} bytes for {header.path}; expected {header.size}" + ) + self.file.close() + self.file = None + self.temp_path.chmod(header.mode) + if header.mtime: + os.utime(self.temp_path, (header.mtime, header.mtime)) + self._chown(self.temp_path, header, follow_symlinks=True) + os.replace(self.temp_path, header.target) + self.temp_path = None + if self.callbacks and self.callbacks.on_file_end: + self.callbacks.on_file_end(header.path) + self.header = None + self.bytes_received = 0 + if final: + for directory in reversed(self.directories): + if directory.mtime: + os.utime(directory.target, (directory.mtime, directory.mtime)) + self._chown(directory.target, directory, follow_symlinks=True) + directory.target.chmod(directory.mode) + self.complete = True + + def _chown(self, path: Path, header: _DownloadHeader, *, follow_symlinks: bool) -> None: + if not self.archive or not hasattr(os, "chown"): + return + try: + os.chown(path, header.uid, header.gid, follow_symlinks=follow_symlinks) + except OSError: + pass + + +def _download_request(src_path: str, follow_symlinks: bool) -> str: + return json.dumps( + {"direction": "from", "guest_path": src_path, "follow_links": follow_symlinks}, + separators=(",", ":"), + ) + + +def cp_from_instance( + client: ClientConfig, + instance_id: str, + src_path: str, + dst_path: str | os.PathLike[str], + *, + follow_symlinks: bool = False, + archive: bool = False, + callbacks: CopyCallbacks | None = None, + connector: SyncWebSocketConnector = sync_connect, +) -> None: + """Copy a guest file or directory into a safely-contained local directory.""" + + url, headers = connection_settings(client, instance_id, "cp") + state = _DownloadState(Path(dst_path), archive, callbacks) + try: + with connector(url, additional_headers=headers, max_size=MAX_INBOUND_MESSAGE_SIZE) as websocket: + websocket.send(_download_request(src_path, follow_symlinks)) + while not state.complete: + try: + frame = websocket.recv() + except Exception as exc: + raise CopyProtocolError("cp download ended before the final marker") from exc + state.consume(frame) + except BaseException: + state.abort() + raise + + +async def cp_from_instance_async( + client: ClientConfig, + instance_id: str, + src_path: str, + dst_path: str | os.PathLike[str], + *, + follow_symlinks: bool = False, + archive: bool = False, + callbacks: CopyCallbacks | None = None, + connector: AsyncWebSocketConnector = async_connect, +) -> None: + """Asynchronous counterpart to :func:`cp_from_instance`.""" + + url, headers = connection_settings(client, instance_id, "cp") + state = _DownloadState(Path(dst_path), archive, callbacks) + try: + async with connector(url, additional_headers=headers, max_size=MAX_INBOUND_MESSAGE_SIZE) as websocket: + await websocket.send(_download_request(src_path, follow_symlinks)) + while not state.complete: + try: + frame = await websocket.recv() + except Exception as exc: + raise CopyProtocolError("cp download ended before the final marker") from exc + state.consume(frame) + except BaseException: + state.abort() + raise diff --git a/src/hypeman/lib/exec.py b/src/hypeman/lib/exec.py new file mode 100644 index 0000000..f8bcf90 --- /dev/null +++ b/src/hypeman/lib/exec.py @@ -0,0 +1,250 @@ +from __future__ import annotations + +import json +from typing import Union, Protocol, cast, runtime_checkable +from dataclasses import dataclass +from collections.abc import Mapping, Iterable, AsyncIterable +from typing_extensions import TypeAlias + +from ._ws import ( + MAX_INBOUND_MESSAGE_SIZE, + ClientConfig, + SyncWebSocketConnector, + AsyncWebSocketConnector, + sync_connect, + async_connect, + connection_settings, +) + +__all__ = ["ExecProtocolError", "ExecResult", "exec", "exec_async"] + + +@runtime_checkable +class _BinaryReader(Protocol): + def read(self, size: int = -1) -> bytes: ... + + +Stdin: TypeAlias = Union[bytes, bytearray, memoryview, _BinaryReader, Iterable[bytes]] +AsyncStdin: TypeAlias = Union[Stdin, AsyncIterable[bytes]] + + +class ExecProtocolError(RuntimeError): + """The exec WebSocket closed or sent an invalid control frame.""" + + +@dataclass(frozen=True) +class ExecResult: + """Result of an exec session. + + The server protocol combines stdout and stderr into ``output``; it doesn't carry + channel metadata that would allow the SDK to split them. + """ + + output: bytes + exit_code: int + + +@dataclass(frozen=True) +class _ExecRequest: + command: list[str] + tty: bool + env: Mapping[str, str] | None + cwd: str | None + timeout: int | None + wait_for_agent: int | None + rows: int | None + cols: int | None + resize: tuple[tuple[int, int], ...] + + def encode(self) -> str: + payload: dict[str, object] = {"command": self.command, "tty": self.tty} + for key, value in ( + ("env", dict(self.env) if self.env is not None else None), + ("cwd", self.cwd), + ("timeout", self.timeout), + ("wait_for_agent", self.wait_for_agent), + ("rows", self.rows), + ("cols", self.cols), + ): + if value is not None: + payload[key] = value + return json.dumps(payload, separators=(",", ":")) + + +def _request( + command: Iterable[str], + *, + cwd: str | None, + env: Mapping[str, str] | None, + timeout: int | None, + wait_for_agent: int | None, + tty: bool, + rows: int | None, + cols: int | None, + resize: Iterable[tuple[int, int]], +) -> _ExecRequest: + if isinstance(command, str): + raise ValueError("command must be an argument sequence, not a string") + argv = list(command) + if not argv: + raise ValueError("command must contain at least one string argument") + for name, value in (("timeout", timeout), ("wait_for_agent", wait_for_agent)): + if value is not None and (isinstance(value, bool) or value < 0): + raise ValueError(f"{name} must be a non-negative number of seconds") + for name, value in (("rows", rows), ("cols", cols)): + if value is not None and (isinstance(value, bool) or value <= 0): + raise ValueError(f"{name} must be positive") + if (rows is not None or cols is not None) and not tty: + raise ValueError("rows and cols require tty=True") + resize_events = tuple(resize) + for resize_rows, resize_cols in resize_events: + if any(isinstance(value, bool) or value <= 0 for value in (resize_rows, resize_cols)): + raise ValueError("resize dimensions must be positive integers") + if resize_events and not tty: + raise ValueError("resize requires tty=True") + return _ExecRequest(argv, tty, env, cwd, timeout, wait_for_agent, rows, cols, resize_events) + + +def _stdin_chunks(stdin: Stdin | None) -> Iterable[bytes]: + if stdin is None: + return () + if isinstance(stdin, (bytes, bytearray, memoryview)): + data = bytes(stdin) + return (data,) if data else () + if isinstance(stdin, _BinaryReader): + + def read_chunks() -> Iterable[bytes]: + while chunk := stdin.read(32 * 1024): + yield chunk + + return read_chunks() + return stdin + + +def _exit_code(frame: str) -> int: + try: + payload = cast(object, json.loads(frame)) + except json.JSONDecodeError as exc: + raise ExecProtocolError("exec sent malformed JSON control frame") from exc + if not isinstance(payload, dict): + raise ExecProtocolError("exec sent an unexpected control frame") + control = cast(dict[str, object], payload) + if set(control) != {"exitCode"}: + raise ExecProtocolError("exec sent an unexpected control frame") + exit_code = control["exitCode"] + if isinstance(exit_code, bool) or not isinstance(exit_code, int): + raise ExecProtocolError("exec exitCode must be an integer") + return exit_code + + +def exec( + client: ClientConfig, + instance_id: str, + command: Iterable[str], + *, + cwd: str | None = None, + env: Mapping[str, str] | None = None, + timeout: int | None = None, + wait_for_agent: int | None = None, + tty: bool = False, + rows: int | None = None, + cols: int | None = None, + stdin: Stdin | None = None, + resize: Iterable[tuple[int, int]] = (), + connector: SyncWebSocketConnector = sync_connect, +) -> ExecResult: + """Execute a command and collect its merged stdout/stderr bytes. + + The request is dispatched once and is never retried. ``stdin`` is sent as binary + WebSocket frames. The protocol has no stdin EOF frame, so commands must stop + reading based on their input, another condition, or ``timeout``. TTY resize + tuples are ``(rows, cols)``. + """ + + request = _request( + command, + cwd=cwd, + env=env, + timeout=timeout, + wait_for_agent=wait_for_agent, + tty=tty, + rows=rows, + cols=cols, + resize=resize, + ) + url, headers = connection_settings(client, instance_id, "exec") + output = bytearray() + with connector(url, additional_headers=headers, max_size=MAX_INBOUND_MESSAGE_SIZE) as websocket: + websocket.send(request.encode()) + for chunk in _stdin_chunks(stdin): + if chunk: + websocket.send(chunk) + for resize_rows, resize_cols in request.resize: + websocket.send(json.dumps({"resize": {"rows": resize_rows, "cols": resize_cols}}, separators=(",", ":"))) + + while True: + try: + frame = websocket.recv() + except Exception as exc: + raise ExecProtocolError("exec stream ended before an exitCode control frame") from exc + if isinstance(frame, bytes): + output.extend(frame) + continue + return ExecResult(output=bytes(output), exit_code=_exit_code(frame)) + + +async def exec_async( + client: ClientConfig, + instance_id: str, + command: Iterable[str], + *, + cwd: str | None = None, + env: Mapping[str, str] | None = None, + timeout: int | None = None, + wait_for_agent: int | None = None, + tty: bool = False, + rows: int | None = None, + cols: int | None = None, + stdin: AsyncStdin | None = None, + resize: Iterable[tuple[int, int]] = (), + connector: AsyncWebSocketConnector = async_connect, +) -> ExecResult: + """Asynchronous counterpart to :func:`exec`; requests are never retried.""" + + request = _request( + command, + cwd=cwd, + env=env, + timeout=timeout, + wait_for_agent=wait_for_agent, + tty=tty, + rows=rows, + cols=cols, + resize=resize, + ) + url, headers = connection_settings(client, instance_id, "exec") + output = bytearray() + async with connector(url, additional_headers=headers, max_size=MAX_INBOUND_MESSAGE_SIZE) as websocket: + await websocket.send(request.encode()) + if isinstance(stdin, AsyncIterable): + async for chunk in stdin: + if chunk: + await websocket.send(chunk) + else: + for chunk in _stdin_chunks(stdin): + if chunk: + await websocket.send(chunk) + for resize_rows, resize_cols in request.resize: + await websocket.send( + json.dumps({"resize": {"rows": resize_rows, "cols": resize_cols}}, separators=(",", ":")) + ) + + while True: + try: + frame = await websocket.recv() + except Exception as exc: + raise ExecProtocolError("exec stream ended before an exitCode control frame") from exc + if isinstance(frame, bytes): + output.extend(frame) + continue + return ExecResult(output=bytes(output), exit_code=_exit_code(frame)) diff --git a/src/hypeman/py.typed b/src/hypeman/py.typed new file mode 100644 index 0000000..e69de29 diff --git a/src/hypeman/resources/__init__.py b/src/hypeman/resources/__init__.py new file mode 100644 index 0000000..d2c109e --- /dev/null +++ b/src/hypeman/resources/__init__.py @@ -0,0 +1,173 @@ +# File generated from our OpenAPI spec by Stainless. See CONTRIBUTING.md for details. + +from .builds import ( + BuildsResource, + AsyncBuildsResource, + BuildsResourceWithRawResponse, + AsyncBuildsResourceWithRawResponse, + BuildsResourceWithStreamingResponse, + AsyncBuildsResourceWithStreamingResponse, +) +from .health import ( + HealthResource, + AsyncHealthResource, + HealthResourceWithRawResponse, + AsyncHealthResourceWithRawResponse, + HealthResourceWithStreamingResponse, + AsyncHealthResourceWithStreamingResponse, +) +from .images import ( + ImagesResource, + AsyncImagesResource, + ImagesResourceWithRawResponse, + AsyncImagesResourceWithRawResponse, + ImagesResourceWithStreamingResponse, + AsyncImagesResourceWithStreamingResponse, +) +from .pushes import ( + PushesResource, + AsyncPushesResource, + PushesResourceWithRawResponse, + AsyncPushesResourceWithRawResponse, + PushesResourceWithStreamingResponse, + AsyncPushesResourceWithStreamingResponse, +) +from .devices import ( + DevicesResource, + AsyncDevicesResource, + DevicesResourceWithRawResponse, + AsyncDevicesResourceWithRawResponse, + DevicesResourceWithStreamingResponse, + AsyncDevicesResourceWithStreamingResponse, +) +from .volumes import ( + VolumesResource, + AsyncVolumesResource, + VolumesResourceWithRawResponse, + AsyncVolumesResourceWithRawResponse, + VolumesResourceWithStreamingResponse, + AsyncVolumesResourceWithStreamingResponse, +) +from .builders import ( + BuildersResource, + AsyncBuildersResource, + BuildersResourceWithRawResponse, + AsyncBuildersResourceWithRawResponse, + BuildersResourceWithStreamingResponse, + AsyncBuildersResourceWithStreamingResponse, +) +from .ingresses import ( + IngressesResource, + AsyncIngressesResource, + IngressesResourceWithRawResponse, + AsyncIngressesResourceWithRawResponse, + IngressesResourceWithStreamingResponse, + AsyncIngressesResourceWithStreamingResponse, +) +from .instances import ( + InstancesResource, + AsyncInstancesResource, + InstancesResourceWithRawResponse, + AsyncInstancesResourceWithRawResponse, + InstancesResourceWithStreamingResponse, + AsyncInstancesResourceWithStreamingResponse, +) +from .resources import ( + ResourcesResource, + AsyncResourcesResource, + ResourcesResourceWithRawResponse, + AsyncResourcesResourceWithRawResponse, + ResourcesResourceWithStreamingResponse, + AsyncResourcesResourceWithStreamingResponse, +) +from .snapshots import ( + SnapshotsResource, + AsyncSnapshotsResource, + SnapshotsResourceWithRawResponse, + AsyncSnapshotsResourceWithRawResponse, + SnapshotsResourceWithStreamingResponse, + AsyncSnapshotsResourceWithStreamingResponse, +) +from .capabilities import ( + CapabilitiesResource, + AsyncCapabilitiesResource, + CapabilitiesResourceWithRawResponse, + AsyncCapabilitiesResourceWithRawResponse, + CapabilitiesResourceWithStreamingResponse, + AsyncCapabilitiesResourceWithStreamingResponse, +) + +__all__ = [ + "HealthResource", + "AsyncHealthResource", + "HealthResourceWithRawResponse", + "AsyncHealthResourceWithRawResponse", + "HealthResourceWithStreamingResponse", + "AsyncHealthResourceWithStreamingResponse", + "CapabilitiesResource", + "AsyncCapabilitiesResource", + "CapabilitiesResourceWithRawResponse", + "AsyncCapabilitiesResourceWithRawResponse", + "CapabilitiesResourceWithStreamingResponse", + "AsyncCapabilitiesResourceWithStreamingResponse", + "ImagesResource", + "AsyncImagesResource", + "ImagesResourceWithRawResponse", + "AsyncImagesResourceWithRawResponse", + "ImagesResourceWithStreamingResponse", + "AsyncImagesResourceWithStreamingResponse", + "InstancesResource", + "AsyncInstancesResource", + "InstancesResourceWithRawResponse", + "AsyncInstancesResourceWithRawResponse", + "InstancesResourceWithStreamingResponse", + "AsyncInstancesResourceWithStreamingResponse", + "SnapshotsResource", + "AsyncSnapshotsResource", + "SnapshotsResourceWithRawResponse", + "AsyncSnapshotsResourceWithRawResponse", + "SnapshotsResourceWithStreamingResponse", + "AsyncSnapshotsResourceWithStreamingResponse", + "VolumesResource", + "AsyncVolumesResource", + "VolumesResourceWithRawResponse", + "AsyncVolumesResourceWithRawResponse", + "VolumesResourceWithStreamingResponse", + "AsyncVolumesResourceWithStreamingResponse", + "DevicesResource", + "AsyncDevicesResource", + "DevicesResourceWithRawResponse", + "AsyncDevicesResourceWithRawResponse", + "DevicesResourceWithStreamingResponse", + "AsyncDevicesResourceWithStreamingResponse", + "IngressesResource", + "AsyncIngressesResource", + "IngressesResourceWithRawResponse", + "AsyncIngressesResourceWithRawResponse", + "IngressesResourceWithStreamingResponse", + "AsyncIngressesResourceWithStreamingResponse", + "ResourcesResource", + "AsyncResourcesResource", + "ResourcesResourceWithRawResponse", + "AsyncResourcesResourceWithRawResponse", + "ResourcesResourceWithStreamingResponse", + "AsyncResourcesResourceWithStreamingResponse", + "BuildersResource", + "AsyncBuildersResource", + "BuildersResourceWithRawResponse", + "AsyncBuildersResourceWithRawResponse", + "BuildersResourceWithStreamingResponse", + "AsyncBuildersResourceWithStreamingResponse", + "BuildsResource", + "AsyncBuildsResource", + "BuildsResourceWithRawResponse", + "AsyncBuildsResourceWithRawResponse", + "BuildsResourceWithStreamingResponse", + "AsyncBuildsResourceWithStreamingResponse", + "PushesResource", + "AsyncPushesResource", + "PushesResourceWithRawResponse", + "AsyncPushesResourceWithRawResponse", + "PushesResourceWithStreamingResponse", + "AsyncPushesResourceWithStreamingResponse", +] diff --git a/src/hypeman/resources/builders.py b/src/hypeman/resources/builders.py new file mode 100644 index 0000000..7d81e92 --- /dev/null +++ b/src/hypeman/resources/builders.py @@ -0,0 +1,532 @@ +# File generated from our OpenAPI spec by Stainless. See CONTRIBUTING.md for details. + +from __future__ import annotations + +from typing import Dict + +import httpx + +from ..types import builder_list_params, builder_create_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 .._base_client import make_request_options +from ..types.builder import Builder +from ..types.builder_list_response import BuilderListResponse + +__all__ = ["BuildersResource", "AsyncBuildersResource"] + + +class BuildersResource(SyncAPIResource): + @cached_property + def with_raw_response(self) -> BuildersResourceWithRawResponse: + """ + 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/hypeman-python#accessing-raw-response-data-eg-headers + """ + return BuildersResourceWithRawResponse(self) + + @cached_property + def with_streaming_response(self) -> BuildersResourceWithStreamingResponse: + """ + An alternative to `.with_raw_response` that doesn't eagerly read the response body. + + For more information, see https://www.github.com/kernel/hypeman-python#with_streaming_response + """ + return BuildersResourceWithStreamingResponse(self) + + def create( + self, + *, + id: str | Omit = omit, + disk_size_gb: int | Omit = omit, + name: str | 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, + ) -> Builder: + """Creates a builder and its cache disk. + + One build at a time runs per builder. + + Args: + id: Optional caller-supplied identifier, auto-generated if not provided + + disk_size_gb: Cache disk size in gigabytes. Omit to use the server default. + + name: Optional non-unique display name + + tags: User-defined key-value tags. + + extra_headers: Send extra headers + + extra_query: Add additional query parameters 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( + "/builders", + body=maybe_transform( + { + "id": id, + "disk_size_gb": disk_size_gb, + "name": name, + "tags": tags, + }, + builder_create_params.BuilderCreateParams, + ), + options=make_request_options( + extra_headers=extra_headers, extra_query=extra_query, extra_body=extra_body, timeout=timeout + ), + cast_to=Builder, + ) + + def list( + self, + *, + 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, + ) -> BuilderListResponse: + """ + List builders + + Args: + tags: Filter builders by tag key-value pairs. + + extra_headers: Send extra headers + + extra_query: Add additional query parameters 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( + "/builders", + options=make_request_options( + extra_headers=extra_headers, + extra_query=extra_query, + extra_body=extra_body, + timeout=timeout, + query=maybe_transform({"tags": tags}, builder_list_params.BuilderListParams), + ), + cast_to=BuilderListResponse, + ) + + 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: + """ + Permanently deletes a builder and its cache disk. + + 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("/builders/{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 get( + 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, + ) -> Builder: + """ + Get builder details + + 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("/builders/{id}", id=id), + options=make_request_options( + extra_headers=extra_headers, extra_query=extra_query, extra_body=extra_body, timeout=timeout + ), + cast_to=Builder, + ) + + def prune( + 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, + ) -> Builder: + """Resets the builder's cache disk. + + The builder transitions to pruning, then ready. + Builder identity is preserved. + + 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("/builders/{id}/prune", id=id), + options=make_request_options( + extra_headers=extra_headers, extra_query=extra_query, extra_body=extra_body, timeout=timeout + ), + cast_to=Builder, + ) + + +class AsyncBuildersResource(AsyncAPIResource): + @cached_property + def with_raw_response(self) -> AsyncBuildersResourceWithRawResponse: + """ + 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/hypeman-python#accessing-raw-response-data-eg-headers + """ + return AsyncBuildersResourceWithRawResponse(self) + + @cached_property + def with_streaming_response(self) -> AsyncBuildersResourceWithStreamingResponse: + """ + An alternative to `.with_raw_response` that doesn't eagerly read the response body. + + For more information, see https://www.github.com/kernel/hypeman-python#with_streaming_response + """ + return AsyncBuildersResourceWithStreamingResponse(self) + + async def create( + self, + *, + id: str | Omit = omit, + disk_size_gb: int | Omit = omit, + name: str | 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, + ) -> Builder: + """Creates a builder and its cache disk. + + One build at a time runs per builder. + + Args: + id: Optional caller-supplied identifier, auto-generated if not provided + + disk_size_gb: Cache disk size in gigabytes. Omit to use the server default. + + name: Optional non-unique display name + + tags: User-defined key-value tags. + + extra_headers: Send extra headers + + extra_query: Add additional query parameters 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( + "/builders", + body=await async_maybe_transform( + { + "id": id, + "disk_size_gb": disk_size_gb, + "name": name, + "tags": tags, + }, + builder_create_params.BuilderCreateParams, + ), + options=make_request_options( + extra_headers=extra_headers, extra_query=extra_query, extra_body=extra_body, timeout=timeout + ), + cast_to=Builder, + ) + + async def list( + self, + *, + 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, + ) -> BuilderListResponse: + """ + List builders + + Args: + tags: Filter builders by tag key-value pairs. + + extra_headers: Send extra headers + + extra_query: Add additional query parameters to the request + + extra_body: Add additional JSON properties to the request + + timeout: Override the client-level default timeout for this request, in seconds + """ + return await self._get( + "/builders", + options=make_request_options( + extra_headers=extra_headers, + extra_query=extra_query, + extra_body=extra_body, + timeout=timeout, + query=await async_maybe_transform({"tags": tags}, builder_list_params.BuilderListParams), + ), + cast_to=BuilderListResponse, + ) + + 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: + """ + Permanently deletes a builder and its cache disk. + + 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("/builders/{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 get( + 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, + ) -> Builder: + """ + Get builder details + + 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("/builders/{id}", id=id), + options=make_request_options( + extra_headers=extra_headers, extra_query=extra_query, extra_body=extra_body, timeout=timeout + ), + cast_to=Builder, + ) + + async def prune( + 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, + ) -> Builder: + """Resets the builder's cache disk. + + The builder transitions to pruning, then ready. + Builder identity is preserved. + + 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("/builders/{id}/prune", id=id), + options=make_request_options( + extra_headers=extra_headers, extra_query=extra_query, extra_body=extra_body, timeout=timeout + ), + cast_to=Builder, + ) + + +class BuildersResourceWithRawResponse: + def __init__(self, builders: BuildersResource) -> None: + self._builders = builders + + self.create = to_raw_response_wrapper( + builders.create, + ) + self.list = to_raw_response_wrapper( + builders.list, + ) + self.delete = to_raw_response_wrapper( + builders.delete, + ) + self.get = to_raw_response_wrapper( + builders.get, + ) + self.prune = to_raw_response_wrapper( + builders.prune, + ) + + +class AsyncBuildersResourceWithRawResponse: + def __init__(self, builders: AsyncBuildersResource) -> None: + self._builders = builders + + self.create = async_to_raw_response_wrapper( + builders.create, + ) + self.list = async_to_raw_response_wrapper( + builders.list, + ) + self.delete = async_to_raw_response_wrapper( + builders.delete, + ) + self.get = async_to_raw_response_wrapper( + builders.get, + ) + self.prune = async_to_raw_response_wrapper( + builders.prune, + ) + + +class BuildersResourceWithStreamingResponse: + def __init__(self, builders: BuildersResource) -> None: + self._builders = builders + + self.create = to_streamed_response_wrapper( + builders.create, + ) + self.list = to_streamed_response_wrapper( + builders.list, + ) + self.delete = to_streamed_response_wrapper( + builders.delete, + ) + self.get = to_streamed_response_wrapper( + builders.get, + ) + self.prune = to_streamed_response_wrapper( + builders.prune, + ) + + +class AsyncBuildersResourceWithStreamingResponse: + def __init__(self, builders: AsyncBuildersResource) -> None: + self._builders = builders + + self.create = async_to_streamed_response_wrapper( + builders.create, + ) + self.list = async_to_streamed_response_wrapper( + builders.list, + ) + self.delete = async_to_streamed_response_wrapper( + builders.delete, + ) + self.get = async_to_streamed_response_wrapper( + builders.get, + ) + self.prune = async_to_streamed_response_wrapper( + builders.prune, + ) diff --git a/src/hypeman/resources/builds.py b/src/hypeman/resources/builds.py new file mode 100644 index 0000000..3526504 --- /dev/null +++ b/src/hypeman/resources/builds.py @@ -0,0 +1,669 @@ +# File generated from our OpenAPI spec by Stainless. See CONTRIBUTING.md for details. + +from __future__ import annotations + +from typing import Dict, Mapping, cast + +import httpx + +from ..types import build_list_params, build_create_params, build_events_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 ..types.build import Build +from .._base_client import make_request_options +from ..types.build_event import BuildEvent +from ..types.build_list_response import BuildListResponse + +__all__ = ["BuildsResource", "AsyncBuildsResource"] + + +class BuildsResource(SyncAPIResource): + @cached_property + def with_raw_response(self) -> BuildsResourceWithRawResponse: + """ + 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/hypeman-python#accessing-raw-response-data-eg-headers + """ + return BuildsResourceWithRawResponse(self) + + @cached_property + def with_streaming_response(self) -> BuildsResourceWithStreamingResponse: + """ + An alternative to `.with_raw_response` that doesn't eagerly read the response body. + + For more information, see https://www.github.com/kernel/hypeman-python#with_streaming_response + """ + return BuildsResourceWithStreamingResponse(self) + + def create( + self, + *, + source: FileTypes, + base_image_digest: str | Omit = omit, + builder_id: str | Omit = omit, + cache_scope: str | Omit = omit, + cpus: int | Omit = omit, + dockerfile: str | Omit = omit, + global_cache_key: str | Omit = omit, + image_name: str | Omit = omit, + is_admin_build: str | Omit = omit, + memory_mb: int | Omit = omit, + secrets: str | Omit = omit, + tags: str | Omit = omit, + timeout_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, + ) -> Build: + """Creates a new build job. + + Source code should be uploaded as a tar.gz archive in + the multipart form data. + + Args: + source: Source tarball (tar.gz) containing application code and optionally a Dockerfile + + base_image_digest: Optional pinned base image digest + + builder_id: Optional Builder ID whose persistent cache disk backs this build. This is the + only builder selector. One build at a time runs on a builder; builds for the + same builder are serialized. + + cache_scope: Tenant-specific cache key prefix + + cpus: Number of vCPUs for builder VM (default 2) + + dockerfile: Dockerfile content. Required if not included in the source tarball. + + global_cache_key: Global cache identifier (e.g., "node", "python", "ubuntu", "browser"). When + specified, the build will import from cache/global/{key}. Admin builds will also + export to this location. + + image_name: Custom image name for the build output. When set, the image is pushed to + {registry}/{image_name} instead of {registry}/builds/{id}. + + is_admin_build: Set to "true" to grant push access to global cache (operator-only). Admin builds + can populate the shared global cache that all tenant builds read from. + + memory_mb: Memory limit for builder VM in MB (default 2048) + + secrets: JSON array of secret references to inject during build. Each object has "id" + (required) for use with --mount=type=secret,id=... Example: [{"id": + "npm_token"}, {"id": "github_token"}] + + tags: JSON object of tags. Example: {"team":"backend","env":"staging"} + + timeout_seconds: Build timeout (default 600) + + extra_headers: Send extra headers + + extra_query: Add additional query parameters 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( + { + "source": source, + "base_image_digest": base_image_digest, + "builder_id": builder_id, + "cache_scope": cache_scope, + "cpus": cpus, + "dockerfile": dockerfile, + "global_cache_key": global_cache_key, + "image_name": image_name, + "is_admin_build": is_admin_build, + "memory_mb": memory_mb, + "secrets": secrets, + "tags": tags, + "timeout_seconds": timeout_seconds, + }, + [["source"]], + ) + files = extract_files(cast(Mapping[str, object], body), paths=[["source"]]) + # 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( + "/builds", + body=maybe_transform(body, build_create_params.BuildCreateParams), + files=files, + options=make_request_options( + extra_headers=extra_headers, extra_query=extra_query, extra_body=extra_body, timeout=timeout + ), + cast_to=Build, + ) + + def list( + self, + *, + 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, + ) -> BuildListResponse: + """ + List builds + + Args: + tags: Filter builds by tag key-value pairs. + + extra_headers: Send extra headers + + extra_query: Add additional query parameters 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( + "/builds", + options=make_request_options( + extra_headers=extra_headers, + extra_query=extra_query, + extra_body=extra_body, + timeout=timeout, + query=maybe_transform({"tags": tags}, build_list_params.BuildListParams), + ), + cast_to=BuildListResponse, + ) + + def cancel( + 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: + """ + Cancel build + + 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("/builds/{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 events( + self, + id: str, + *, + follow: 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, + ) -> Stream[BuildEvent]: + """Streams build events as Server-Sent Events. + + Events include: + + - `log`: Build log lines with timestamp and content + - `status`: Build status changes (queued→building→pushing→ready/failed) + - `heartbeat`: Keep-alive events sent every 30s to prevent connection timeouts + + Returns existing logs as events, then continues streaming if follow=true. + + Args: + follow: Continue streaming new events after initial output + + extra_headers: Send extra headers + + extra_query: Add additional query parameters 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("/builds/{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({"follow": follow}, build_events_params.BuildEventsParams), + ), + cast_to=BuildEvent, + stream=True, + stream_cls=Stream[BuildEvent], + ) + + def get( + 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, + ) -> Build: + """ + Get build details + + 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("/builds/{id}", id=id), + options=make_request_options( + extra_headers=extra_headers, extra_query=extra_query, extra_body=extra_body, timeout=timeout + ), + cast_to=Build, + ) + + +class AsyncBuildsResource(AsyncAPIResource): + @cached_property + def with_raw_response(self) -> AsyncBuildsResourceWithRawResponse: + """ + 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/hypeman-python#accessing-raw-response-data-eg-headers + """ + return AsyncBuildsResourceWithRawResponse(self) + + @cached_property + def with_streaming_response(self) -> AsyncBuildsResourceWithStreamingResponse: + """ + An alternative to `.with_raw_response` that doesn't eagerly read the response body. + + For more information, see https://www.github.com/kernel/hypeman-python#with_streaming_response + """ + return AsyncBuildsResourceWithStreamingResponse(self) + + async def create( + self, + *, + source: FileTypes, + base_image_digest: str | Omit = omit, + builder_id: str | Omit = omit, + cache_scope: str | Omit = omit, + cpus: int | Omit = omit, + dockerfile: str | Omit = omit, + global_cache_key: str | Omit = omit, + image_name: str | Omit = omit, + is_admin_build: str | Omit = omit, + memory_mb: int | Omit = omit, + secrets: str | Omit = omit, + tags: str | Omit = omit, + timeout_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, + ) -> Build: + """Creates a new build job. + + Source code should be uploaded as a tar.gz archive in + the multipart form data. + + Args: + source: Source tarball (tar.gz) containing application code and optionally a Dockerfile + + base_image_digest: Optional pinned base image digest + + builder_id: Optional Builder ID whose persistent cache disk backs this build. This is the + only builder selector. One build at a time runs on a builder; builds for the + same builder are serialized. + + cache_scope: Tenant-specific cache key prefix + + cpus: Number of vCPUs for builder VM (default 2) + + dockerfile: Dockerfile content. Required if not included in the source tarball. + + global_cache_key: Global cache identifier (e.g., "node", "python", "ubuntu", "browser"). When + specified, the build will import from cache/global/{key}. Admin builds will also + export to this location. + + image_name: Custom image name for the build output. When set, the image is pushed to + {registry}/{image_name} instead of {registry}/builds/{id}. + + is_admin_build: Set to "true" to grant push access to global cache (operator-only). Admin builds + can populate the shared global cache that all tenant builds read from. + + memory_mb: Memory limit for builder VM in MB (default 2048) + + secrets: JSON array of secret references to inject during build. Each object has "id" + (required) for use with --mount=type=secret,id=... Example: [{"id": + "npm_token"}, {"id": "github_token"}] + + tags: JSON object of tags. Example: {"team":"backend","env":"staging"} + + timeout_seconds: Build timeout (default 600) + + extra_headers: Send extra headers + + extra_query: Add additional query parameters 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( + { + "source": source, + "base_image_digest": base_image_digest, + "builder_id": builder_id, + "cache_scope": cache_scope, + "cpus": cpus, + "dockerfile": dockerfile, + "global_cache_key": global_cache_key, + "image_name": image_name, + "is_admin_build": is_admin_build, + "memory_mb": memory_mb, + "secrets": secrets, + "tags": tags, + "timeout_seconds": timeout_seconds, + }, + [["source"]], + ) + files = extract_files(cast(Mapping[str, object], body), paths=[["source"]]) + # 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( + "/builds", + body=await async_maybe_transform(body, build_create_params.BuildCreateParams), + files=files, + options=make_request_options( + extra_headers=extra_headers, extra_query=extra_query, extra_body=extra_body, timeout=timeout + ), + cast_to=Build, + ) + + async def list( + self, + *, + 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, + ) -> BuildListResponse: + """ + List builds + + Args: + tags: Filter builds by tag key-value pairs. + + extra_headers: Send extra headers + + extra_query: Add additional query parameters to the request + + extra_body: Add additional JSON properties to the request + + timeout: Override the client-level default timeout for this request, in seconds + """ + return await self._get( + "/builds", + options=make_request_options( + extra_headers=extra_headers, + extra_query=extra_query, + extra_body=extra_body, + timeout=timeout, + query=await async_maybe_transform({"tags": tags}, build_list_params.BuildListParams), + ), + cast_to=BuildListResponse, + ) + + async def cancel( + 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: + """ + Cancel build + + 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("/builds/{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 events( + self, + id: str, + *, + follow: 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, + ) -> AsyncStream[BuildEvent]: + """Streams build events as Server-Sent Events. + + Events include: + + - `log`: Build log lines with timestamp and content + - `status`: Build status changes (queued→building→pushing→ready/failed) + - `heartbeat`: Keep-alive events sent every 30s to prevent connection timeouts + + Returns existing logs as events, then continues streaming if follow=true. + + Args: + follow: Continue streaming new events after initial output + + extra_headers: Send extra headers + + extra_query: Add additional query parameters 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("/builds/{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({"follow": follow}, build_events_params.BuildEventsParams), + ), + cast_to=BuildEvent, + stream=True, + stream_cls=AsyncStream[BuildEvent], + ) + + async def get( + 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, + ) -> Build: + """ + Get build details + + 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("/builds/{id}", id=id), + options=make_request_options( + extra_headers=extra_headers, extra_query=extra_query, extra_body=extra_body, timeout=timeout + ), + cast_to=Build, + ) + + +class BuildsResourceWithRawResponse: + def __init__(self, builds: BuildsResource) -> None: + self._builds = builds + + self.create = to_raw_response_wrapper( + builds.create, + ) + self.list = to_raw_response_wrapper( + builds.list, + ) + self.cancel = to_raw_response_wrapper( + builds.cancel, + ) + self.events = to_raw_response_wrapper( + builds.events, + ) + self.get = to_raw_response_wrapper( + builds.get, + ) + + +class AsyncBuildsResourceWithRawResponse: + def __init__(self, builds: AsyncBuildsResource) -> None: + self._builds = builds + + self.create = async_to_raw_response_wrapper( + builds.create, + ) + self.list = async_to_raw_response_wrapper( + builds.list, + ) + self.cancel = async_to_raw_response_wrapper( + builds.cancel, + ) + self.events = async_to_raw_response_wrapper( + builds.events, + ) + self.get = async_to_raw_response_wrapper( + builds.get, + ) + + +class BuildsResourceWithStreamingResponse: + def __init__(self, builds: BuildsResource) -> None: + self._builds = builds + + self.create = to_streamed_response_wrapper( + builds.create, + ) + self.list = to_streamed_response_wrapper( + builds.list, + ) + self.cancel = to_streamed_response_wrapper( + builds.cancel, + ) + self.events = to_streamed_response_wrapper( + builds.events, + ) + self.get = to_streamed_response_wrapper( + builds.get, + ) + + +class AsyncBuildsResourceWithStreamingResponse: + def __init__(self, builds: AsyncBuildsResource) -> None: + self._builds = builds + + self.create = async_to_streamed_response_wrapper( + builds.create, + ) + self.list = async_to_streamed_response_wrapper( + builds.list, + ) + self.cancel = async_to_streamed_response_wrapper( + builds.cancel, + ) + self.events = async_to_streamed_response_wrapper( + builds.events, + ) + self.get = async_to_streamed_response_wrapper( + builds.get, + ) diff --git a/src/hypeman/resources/capabilities.py b/src/hypeman/resources/capabilities.py new file mode 100644 index 0000000..3209a75 --- /dev/null +++ b/src/hypeman/resources/capabilities.py @@ -0,0 +1,155 @@ +# File generated from our OpenAPI spec by Stainless. See CONTRIBUTING.md for details. + +from __future__ import annotations + +import httpx + +from .._types import Body, Query, Headers, NotGiven, not_given +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.capabilities import Capabilities + +__all__ = ["CapabilitiesResource", "AsyncCapabilitiesResource"] + + +class CapabilitiesResource(SyncAPIResource): + @cached_property + def with_raw_response(self) -> CapabilitiesResourceWithRawResponse: + """ + 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/hypeman-python#accessing-raw-response-data-eg-headers + """ + return CapabilitiesResourceWithRawResponse(self) + + @cached_property + def with_streaming_response(self) -> CapabilitiesResourceWithStreamingResponse: + """ + An alternative to `.with_raw_response` that doesn't eagerly read the response body. + + For more information, see https://www.github.com/kernel/hypeman-python#with_streaming_response + """ + return CapabilitiesResourceWithStreamingResponse(self) + + def get( + self, + *, + # Use the following arguments if you need to pass additional parameters to the API that aren't available via kwargs. + # The extra values given here take precedence over values defined on the client or passed to this method. + extra_headers: Headers | None = None, + extra_query: Query | None = None, + extra_body: Body | None = None, + timeout: float | httpx.Timeout | None | NotGiven = not_given, + ) -> Capabilities: + """ + Returns machine-readable host capabilities: server and API version, host + OS/architecture, every runtime available on this host with its per-runtime + feature IDs, the configured default runtime and whether it is available, guest + networking model and host gateway, supported image platforms, and stable + server-level feature IDs. + + Runtime-derived values reflect the actual host (for example, snapshot and + standby support on macOS is gated on the host OS version), so clients can gate + behavior on capabilities without hard-coding hypervisor knowledge. + """ + return self._get( + "/capabilities", + options=make_request_options( + extra_headers=extra_headers, extra_query=extra_query, extra_body=extra_body, timeout=timeout + ), + cast_to=Capabilities, + ) + + +class AsyncCapabilitiesResource(AsyncAPIResource): + @cached_property + def with_raw_response(self) -> AsyncCapabilitiesResourceWithRawResponse: + """ + 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/hypeman-python#accessing-raw-response-data-eg-headers + """ + return AsyncCapabilitiesResourceWithRawResponse(self) + + @cached_property + def with_streaming_response(self) -> AsyncCapabilitiesResourceWithStreamingResponse: + """ + An alternative to `.with_raw_response` that doesn't eagerly read the response body. + + For more information, see https://www.github.com/kernel/hypeman-python#with_streaming_response + """ + return AsyncCapabilitiesResourceWithStreamingResponse(self) + + async def get( + self, + *, + # Use the following arguments if you need to pass additional parameters to the API that aren't available via kwargs. + # The extra values given here take precedence over values defined on the client or passed to this method. + extra_headers: Headers | None = None, + extra_query: Query | None = None, + extra_body: Body | None = None, + timeout: float | httpx.Timeout | None | NotGiven = not_given, + ) -> Capabilities: + """ + Returns machine-readable host capabilities: server and API version, host + OS/architecture, every runtime available on this host with its per-runtime + feature IDs, the configured default runtime and whether it is available, guest + networking model and host gateway, supported image platforms, and stable + server-level feature IDs. + + Runtime-derived values reflect the actual host (for example, snapshot and + standby support on macOS is gated on the host OS version), so clients can gate + behavior on capabilities without hard-coding hypervisor knowledge. + """ + return await self._get( + "/capabilities", + options=make_request_options( + extra_headers=extra_headers, extra_query=extra_query, extra_body=extra_body, timeout=timeout + ), + cast_to=Capabilities, + ) + + +class CapabilitiesResourceWithRawResponse: + def __init__(self, capabilities: CapabilitiesResource) -> None: + self._capabilities = capabilities + + self.get = to_raw_response_wrapper( + capabilities.get, + ) + + +class AsyncCapabilitiesResourceWithRawResponse: + def __init__(self, capabilities: AsyncCapabilitiesResource) -> None: + self._capabilities = capabilities + + self.get = async_to_raw_response_wrapper( + capabilities.get, + ) + + +class CapabilitiesResourceWithStreamingResponse: + def __init__(self, capabilities: CapabilitiesResource) -> None: + self._capabilities = capabilities + + self.get = to_streamed_response_wrapper( + capabilities.get, + ) + + +class AsyncCapabilitiesResourceWithStreamingResponse: + def __init__(self, capabilities: AsyncCapabilitiesResource) -> None: + self._capabilities = capabilities + + self.get = async_to_streamed_response_wrapper( + capabilities.get, + ) diff --git a/src/hypeman/resources/devices.py b/src/hypeman/resources/devices.py new file mode 100644 index 0000000..e91c7d0 --- /dev/null +++ b/src/hypeman/resources/devices.py @@ -0,0 +1,493 @@ +# File generated from our OpenAPI spec by Stainless. See CONTRIBUTING.md for details. + +from __future__ import annotations + +from typing import Dict + +import httpx + +from ..types import device_list_params, device_create_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 .._base_client import make_request_options +from ..types.device import Device +from ..types.device_list_response import DeviceListResponse +from ..types.device_list_available_response import DeviceListAvailableResponse + +__all__ = ["DevicesResource", "AsyncDevicesResource"] + + +class DevicesResource(SyncAPIResource): + @cached_property + def with_raw_response(self) -> DevicesResourceWithRawResponse: + """ + 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/hypeman-python#accessing-raw-response-data-eg-headers + """ + return DevicesResourceWithRawResponse(self) + + @cached_property + def with_streaming_response(self) -> DevicesResourceWithStreamingResponse: + """ + An alternative to `.with_raw_response` that doesn't eagerly read the response body. + + For more information, see https://www.github.com/kernel/hypeman-python#with_streaming_response + """ + return DevicesResourceWithStreamingResponse(self) + + def create( + self, + *, + pci_address: str, + name: str | 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, + ) -> Device: + """ + Register a device for passthrough + + Args: + pci_address: PCI address of the device (required, e.g., "0000:a2:00.0") + + name: Optional globally unique device name. If not provided, a name is auto-generated + from the PCI address (e.g., "pci-0000-a2-00-0") + + tags: User-defined key-value tags. + + extra_headers: Send extra headers + + extra_query: Add additional query parameters 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( + "/devices", + body=maybe_transform( + { + "pci_address": pci_address, + "name": name, + "tags": tags, + }, + device_create_params.DeviceCreateParams, + ), + options=make_request_options( + extra_headers=extra_headers, extra_query=extra_query, extra_body=extra_body, timeout=timeout + ), + cast_to=Device, + ) + + 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, + ) -> Device: + """ + Get device details + + 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("/devices/{id}", id=id), + options=make_request_options( + extra_headers=extra_headers, extra_query=extra_query, extra_body=extra_body, timeout=timeout + ), + cast_to=Device, + ) + + def list( + self, + *, + 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, + ) -> DeviceListResponse: + """ + List registered devices + + Args: + tags: Filter devices by tag key-value pairs. + + extra_headers: Send extra headers + + extra_query: Add additional query parameters 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( + "/devices", + options=make_request_options( + extra_headers=extra_headers, + extra_query=extra_query, + extra_body=extra_body, + timeout=timeout, + query=maybe_transform({"tags": tags}, device_list_params.DeviceListParams), + ), + cast_to=DeviceListResponse, + ) + + 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: + """ + Unregister device + + 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("/devices/{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_available( + 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, + ) -> DeviceListAvailableResponse: + """Discover passthrough-capable devices on host""" + return self._get( + "/devices/available", + options=make_request_options( + extra_headers=extra_headers, extra_query=extra_query, extra_body=extra_body, timeout=timeout + ), + cast_to=DeviceListAvailableResponse, + ) + + +class AsyncDevicesResource(AsyncAPIResource): + @cached_property + def with_raw_response(self) -> AsyncDevicesResourceWithRawResponse: + """ + 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/hypeman-python#accessing-raw-response-data-eg-headers + """ + return AsyncDevicesResourceWithRawResponse(self) + + @cached_property + def with_streaming_response(self) -> AsyncDevicesResourceWithStreamingResponse: + """ + An alternative to `.with_raw_response` that doesn't eagerly read the response body. + + For more information, see https://www.github.com/kernel/hypeman-python#with_streaming_response + """ + return AsyncDevicesResourceWithStreamingResponse(self) + + async def create( + self, + *, + pci_address: str, + name: str | 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, + ) -> Device: + """ + Register a device for passthrough + + Args: + pci_address: PCI address of the device (required, e.g., "0000:a2:00.0") + + name: Optional globally unique device name. If not provided, a name is auto-generated + from the PCI address (e.g., "pci-0000-a2-00-0") + + tags: User-defined key-value tags. + + extra_headers: Send extra headers + + extra_query: Add additional query parameters 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( + "/devices", + body=await async_maybe_transform( + { + "pci_address": pci_address, + "name": name, + "tags": tags, + }, + device_create_params.DeviceCreateParams, + ), + options=make_request_options( + extra_headers=extra_headers, extra_query=extra_query, extra_body=extra_body, timeout=timeout + ), + cast_to=Device, + ) + + 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, + ) -> Device: + """ + Get device details + + 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("/devices/{id}", id=id), + options=make_request_options( + extra_headers=extra_headers, extra_query=extra_query, extra_body=extra_body, timeout=timeout + ), + cast_to=Device, + ) + + async def list( + self, + *, + 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, + ) -> DeviceListResponse: + """ + List registered devices + + Args: + tags: Filter devices by tag key-value pairs. + + extra_headers: Send extra headers + + extra_query: Add additional query parameters to the request + + extra_body: Add additional JSON properties to the request + + timeout: Override the client-level default timeout for this request, in seconds + """ + return await self._get( + "/devices", + options=make_request_options( + extra_headers=extra_headers, + extra_query=extra_query, + extra_body=extra_body, + timeout=timeout, + query=await async_maybe_transform({"tags": tags}, device_list_params.DeviceListParams), + ), + cast_to=DeviceListResponse, + ) + + 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: + """ + Unregister device + + 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("/devices/{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_available( + 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, + ) -> DeviceListAvailableResponse: + """Discover passthrough-capable devices on host""" + return await self._get( + "/devices/available", + options=make_request_options( + extra_headers=extra_headers, extra_query=extra_query, extra_body=extra_body, timeout=timeout + ), + cast_to=DeviceListAvailableResponse, + ) + + +class DevicesResourceWithRawResponse: + def __init__(self, devices: DevicesResource) -> None: + self._devices = devices + + self.create = to_raw_response_wrapper( + devices.create, + ) + self.retrieve = to_raw_response_wrapper( + devices.retrieve, + ) + self.list = to_raw_response_wrapper( + devices.list, + ) + self.delete = to_raw_response_wrapper( + devices.delete, + ) + self.list_available = to_raw_response_wrapper( + devices.list_available, + ) + + +class AsyncDevicesResourceWithRawResponse: + def __init__(self, devices: AsyncDevicesResource) -> None: + self._devices = devices + + self.create = async_to_raw_response_wrapper( + devices.create, + ) + self.retrieve = async_to_raw_response_wrapper( + devices.retrieve, + ) + self.list = async_to_raw_response_wrapper( + devices.list, + ) + self.delete = async_to_raw_response_wrapper( + devices.delete, + ) + self.list_available = async_to_raw_response_wrapper( + devices.list_available, + ) + + +class DevicesResourceWithStreamingResponse: + def __init__(self, devices: DevicesResource) -> None: + self._devices = devices + + self.create = to_streamed_response_wrapper( + devices.create, + ) + self.retrieve = to_streamed_response_wrapper( + devices.retrieve, + ) + self.list = to_streamed_response_wrapper( + devices.list, + ) + self.delete = to_streamed_response_wrapper( + devices.delete, + ) + self.list_available = to_streamed_response_wrapper( + devices.list_available, + ) + + +class AsyncDevicesResourceWithStreamingResponse: + def __init__(self, devices: AsyncDevicesResource) -> None: + self._devices = devices + + self.create = async_to_streamed_response_wrapper( + devices.create, + ) + self.retrieve = async_to_streamed_response_wrapper( + devices.retrieve, + ) + self.list = async_to_streamed_response_wrapper( + devices.list, + ) + self.delete = async_to_streamed_response_wrapper( + devices.delete, + ) + self.list_available = async_to_streamed_response_wrapper( + devices.list_available, + ) diff --git a/src/hypeman/resources/health.py b/src/hypeman/resources/health.py new file mode 100644 index 0000000..9c7755b --- /dev/null +++ b/src/hypeman/resources/health.py @@ -0,0 +1,135 @@ +# File generated from our OpenAPI spec by Stainless. See CONTRIBUTING.md for details. + +from __future__ import annotations + +import httpx + +from .._types import Body, Query, Headers, NotGiven, not_given +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.health_check_response import HealthCheckResponse + +__all__ = ["HealthResource", "AsyncHealthResource"] + + +class HealthResource(SyncAPIResource): + @cached_property + def with_raw_response(self) -> HealthResourceWithRawResponse: + """ + 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/hypeman-python#accessing-raw-response-data-eg-headers + """ + return HealthResourceWithRawResponse(self) + + @cached_property + def with_streaming_response(self) -> HealthResourceWithStreamingResponse: + """ + An alternative to `.with_raw_response` that doesn't eagerly read the response body. + + For more information, see https://www.github.com/kernel/hypeman-python#with_streaming_response + """ + return HealthResourceWithStreamingResponse(self) + + def check( + 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, + ) -> HealthCheckResponse: + """Health check""" + return self._get( + "/health", + options=make_request_options( + extra_headers=extra_headers, extra_query=extra_query, extra_body=extra_body, timeout=timeout + ), + cast_to=HealthCheckResponse, + ) + + +class AsyncHealthResource(AsyncAPIResource): + @cached_property + def with_raw_response(self) -> AsyncHealthResourceWithRawResponse: + """ + 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/hypeman-python#accessing-raw-response-data-eg-headers + """ + return AsyncHealthResourceWithRawResponse(self) + + @cached_property + def with_streaming_response(self) -> AsyncHealthResourceWithStreamingResponse: + """ + An alternative to `.with_raw_response` that doesn't eagerly read the response body. + + For more information, see https://www.github.com/kernel/hypeman-python#with_streaming_response + """ + return AsyncHealthResourceWithStreamingResponse(self) + + async def check( + 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, + ) -> HealthCheckResponse: + """Health check""" + return await self._get( + "/health", + options=make_request_options( + extra_headers=extra_headers, extra_query=extra_query, extra_body=extra_body, timeout=timeout + ), + cast_to=HealthCheckResponse, + ) + + +class HealthResourceWithRawResponse: + def __init__(self, health: HealthResource) -> None: + self._health = health + + self.check = to_raw_response_wrapper( + health.check, + ) + + +class AsyncHealthResourceWithRawResponse: + def __init__(self, health: AsyncHealthResource) -> None: + self._health = health + + self.check = async_to_raw_response_wrapper( + health.check, + ) + + +class HealthResourceWithStreamingResponse: + def __init__(self, health: HealthResource) -> None: + self._health = health + + self.check = to_streamed_response_wrapper( + health.check, + ) + + +class AsyncHealthResourceWithStreamingResponse: + def __init__(self, health: AsyncHealthResource) -> None: + self._health = health + + self.check = async_to_streamed_response_wrapper( + health.check, + ) diff --git a/src/hypeman/resources/images.py b/src/hypeman/resources/images.py new file mode 100644 index 0000000..c45e965 --- /dev/null +++ b/src/hypeman/resources/images.py @@ -0,0 +1,461 @@ +# File generated from our OpenAPI spec by Stainless. See CONTRIBUTING.md for details. + +from __future__ import annotations + +from typing import Dict + +import httpx + +from ..types import image_list_params, image_create_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 ..types.image import Image +from .._base_client import make_request_options +from ..types.image_list_response import ImageListResponse +from ..types.push_credentials_param import PushCredentialsParam + +__all__ = ["ImagesResource", "AsyncImagesResource"] + + +class ImagesResource(SyncAPIResource): + @cached_property + def with_raw_response(self) -> ImagesResourceWithRawResponse: + """ + 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/hypeman-python#accessing-raw-response-data-eg-headers + """ + return ImagesResourceWithRawResponse(self) + + @cached_property + def with_streaming_response(self) -> ImagesResourceWithStreamingResponse: + """ + An alternative to `.with_raw_response` that doesn't eagerly read the response body. + + For more information, see https://www.github.com/kernel/hypeman-python#with_streaming_response + """ + return ImagesResourceWithStreamingResponse(self) + + def create( + self, + *, + name: str, + credentials: PushCredentialsParam | Omit = omit, + platform: str | 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, + ) -> Image: + """ + Pull and convert OCI image + + Args: + name: OCI image reference (e.g., docker.io/library/nginx:latest) + + credentials: Docker-style registry credentials borrowed for one image pull or push request. + They remain in memory and are never persisted or logged. When omitted or empty, + the server's own registry credentials are used. An interrupted credentialed + operation must be retried with fresh credentials. + + platform: Target platform as os/arch[/variant] (e.g. "linux/amd64"), matching Docker + --platform. Omit for the host platform. Not a fixed enum: the os/arch[/variant] + grammar is validated server-side and invalid values return 400 invalid_platform. + Only os "linux" with arch amd64 or arm64 is accepted today. + + tags: User-defined key-value tags. + + extra_headers: Send extra headers + + extra_query: Add additional query parameters 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( + "/images", + body=maybe_transform( + { + "name": name, + "credentials": credentials, + "platform": platform, + "tags": tags, + }, + image_create_params.ImageCreateParams, + ), + options=make_request_options( + extra_headers=extra_headers, extra_query=extra_query, extra_body=extra_body, timeout=timeout + ), + cast_to=Image, + ) + + def list( + self, + *, + 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, + ) -> ImageListResponse: + """ + List images + + Args: + tags: Filter images by tag key-value pairs. + + extra_headers: Send extra headers + + extra_query: Add additional query parameters 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( + "/images", + options=make_request_options( + extra_headers=extra_headers, + extra_query=extra_query, + extra_body=extra_body, + timeout=timeout, + query=maybe_transform({"tags": tags}, image_list_params.ImageListParams), + ), + cast_to=ImageListResponse, + ) + + def delete( + 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, + ) -> None: + """ + Delete image + + 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 name: + raise ValueError(f"Expected a non-empty value for `name` but received {name!r}") + extra_headers = {"Accept": "*/*", **(extra_headers or {})} + return self._delete( + path_template("/images/{name}", name=name), + options=make_request_options( + extra_headers=extra_headers, extra_query=extra_query, extra_body=extra_body, timeout=timeout + ), + cast_to=NoneType, + ) + + def get( + 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, + ) -> Image: + """ + Get image details + + 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 name: + raise ValueError(f"Expected a non-empty value for `name` but received {name!r}") + return self._get( + path_template("/images/{name}", name=name), + options=make_request_options( + extra_headers=extra_headers, extra_query=extra_query, extra_body=extra_body, timeout=timeout + ), + cast_to=Image, + ) + + +class AsyncImagesResource(AsyncAPIResource): + @cached_property + def with_raw_response(self) -> AsyncImagesResourceWithRawResponse: + """ + 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/hypeman-python#accessing-raw-response-data-eg-headers + """ + return AsyncImagesResourceWithRawResponse(self) + + @cached_property + def with_streaming_response(self) -> AsyncImagesResourceWithStreamingResponse: + """ + An alternative to `.with_raw_response` that doesn't eagerly read the response body. + + For more information, see https://www.github.com/kernel/hypeman-python#with_streaming_response + """ + return AsyncImagesResourceWithStreamingResponse(self) + + async def create( + self, + *, + name: str, + credentials: PushCredentialsParam | Omit = omit, + platform: str | 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, + ) -> Image: + """ + Pull and convert OCI image + + Args: + name: OCI image reference (e.g., docker.io/library/nginx:latest) + + credentials: Docker-style registry credentials borrowed for one image pull or push request. + They remain in memory and are never persisted or logged. When omitted or empty, + the server's own registry credentials are used. An interrupted credentialed + operation must be retried with fresh credentials. + + platform: Target platform as os/arch[/variant] (e.g. "linux/amd64"), matching Docker + --platform. Omit for the host platform. Not a fixed enum: the os/arch[/variant] + grammar is validated server-side and invalid values return 400 invalid_platform. + Only os "linux" with arch amd64 or arm64 is accepted today. + + tags: User-defined key-value tags. + + extra_headers: Send extra headers + + extra_query: Add additional query parameters 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( + "/images", + body=await async_maybe_transform( + { + "name": name, + "credentials": credentials, + "platform": platform, + "tags": tags, + }, + image_create_params.ImageCreateParams, + ), + options=make_request_options( + extra_headers=extra_headers, extra_query=extra_query, extra_body=extra_body, timeout=timeout + ), + cast_to=Image, + ) + + async def list( + self, + *, + 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, + ) -> ImageListResponse: + """ + List images + + Args: + tags: Filter images by tag key-value pairs. + + extra_headers: Send extra headers + + extra_query: Add additional query parameters to the request + + extra_body: Add additional JSON properties to the request + + timeout: Override the client-level default timeout for this request, in seconds + """ + return await self._get( + "/images", + options=make_request_options( + extra_headers=extra_headers, + extra_query=extra_query, + extra_body=extra_body, + timeout=timeout, + query=await async_maybe_transform({"tags": tags}, image_list_params.ImageListParams), + ), + cast_to=ImageListResponse, + ) + + async def delete( + 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, + ) -> None: + """ + Delete image + + 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 name: + raise ValueError(f"Expected a non-empty value for `name` but received {name!r}") + extra_headers = {"Accept": "*/*", **(extra_headers or {})} + return await self._delete( + path_template("/images/{name}", name=name), + options=make_request_options( + extra_headers=extra_headers, extra_query=extra_query, extra_body=extra_body, timeout=timeout + ), + cast_to=NoneType, + ) + + async def get( + 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, + ) -> Image: + """ + Get image details + + 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 name: + raise ValueError(f"Expected a non-empty value for `name` but received {name!r}") + return await self._get( + path_template("/images/{name}", name=name), + options=make_request_options( + extra_headers=extra_headers, extra_query=extra_query, extra_body=extra_body, timeout=timeout + ), + cast_to=Image, + ) + + +class ImagesResourceWithRawResponse: + def __init__(self, images: ImagesResource) -> None: + self._images = images + + self.create = to_raw_response_wrapper( + images.create, + ) + self.list = to_raw_response_wrapper( + images.list, + ) + self.delete = to_raw_response_wrapper( + images.delete, + ) + self.get = to_raw_response_wrapper( + images.get, + ) + + +class AsyncImagesResourceWithRawResponse: + def __init__(self, images: AsyncImagesResource) -> None: + self._images = images + + self.create = async_to_raw_response_wrapper( + images.create, + ) + self.list = async_to_raw_response_wrapper( + images.list, + ) + self.delete = async_to_raw_response_wrapper( + images.delete, + ) + self.get = async_to_raw_response_wrapper( + images.get, + ) + + +class ImagesResourceWithStreamingResponse: + def __init__(self, images: ImagesResource) -> None: + self._images = images + + self.create = to_streamed_response_wrapper( + images.create, + ) + self.list = to_streamed_response_wrapper( + images.list, + ) + self.delete = to_streamed_response_wrapper( + images.delete, + ) + self.get = to_streamed_response_wrapper( + images.get, + ) + + +class AsyncImagesResourceWithStreamingResponse: + def __init__(self, images: AsyncImagesResource) -> None: + self._images = images + + self.create = async_to_streamed_response_wrapper( + images.create, + ) + self.list = async_to_streamed_response_wrapper( + images.list, + ) + self.delete = async_to_streamed_response_wrapper( + images.delete, + ) + self.get = async_to_streamed_response_wrapper( + images.get, + ) diff --git a/src/hypeman/resources/ingresses.py b/src/hypeman/resources/ingresses.py new file mode 100644 index 0000000..b9361bb --- /dev/null +++ b/src/hypeman/resources/ingresses.py @@ -0,0 +1,443 @@ +# File generated from our OpenAPI spec by Stainless. See CONTRIBUTING.md for details. + +from __future__ import annotations + +from typing import Dict, Iterable + +import httpx + +from ..types import ingress_list_params, ingress_create_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 .._base_client import make_request_options +from ..types.ingress import Ingress +from ..types.ingress_rule_param import IngressRuleParam +from ..types.ingress_list_response import IngressListResponse + +__all__ = ["IngressesResource", "AsyncIngressesResource"] + + +class IngressesResource(SyncAPIResource): + @cached_property + def with_raw_response(self) -> IngressesResourceWithRawResponse: + """ + 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/hypeman-python#accessing-raw-response-data-eg-headers + """ + return IngressesResourceWithRawResponse(self) + + @cached_property + def with_streaming_response(self) -> IngressesResourceWithStreamingResponse: + """ + An alternative to `.with_raw_response` that doesn't eagerly read the response body. + + For more information, see https://www.github.com/kernel/hypeman-python#with_streaming_response + """ + return IngressesResourceWithStreamingResponse(self) + + def create( + self, + *, + name: str, + rules: Iterable[IngressRuleParam], + 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, + ) -> Ingress: + """ + Create ingress + + Args: + name: Human-readable name (lowercase letters, digits, and dashes only; cannot start or + end with a dash) + + rules: Routing rules for this ingress + + tags: User-defined key-value tags. + + extra_headers: Send extra headers + + extra_query: Add additional query parameters 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( + "/ingresses", + body=maybe_transform( + { + "name": name, + "rules": rules, + "tags": tags, + }, + ingress_create_params.IngressCreateParams, + ), + options=make_request_options( + extra_headers=extra_headers, extra_query=extra_query, extra_body=extra_body, timeout=timeout + ), + cast_to=Ingress, + ) + + def list( + self, + *, + 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, + ) -> IngressListResponse: + """ + List ingresses + + Args: + tags: Filter ingresses by tag key-value pairs. + + extra_headers: Send extra headers + + extra_query: Add additional query parameters 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( + "/ingresses", + options=make_request_options( + extra_headers=extra_headers, + extra_query=extra_query, + extra_body=extra_body, + timeout=timeout, + query=maybe_transform({"tags": tags}, ingress_list_params.IngressListParams), + ), + cast_to=IngressListResponse, + ) + + 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 ingress + + 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("/ingresses/{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 get( + 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, + ) -> Ingress: + """ + Get ingress details + + 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("/ingresses/{id}", id=id), + options=make_request_options( + extra_headers=extra_headers, extra_query=extra_query, extra_body=extra_body, timeout=timeout + ), + cast_to=Ingress, + ) + + +class AsyncIngressesResource(AsyncAPIResource): + @cached_property + def with_raw_response(self) -> AsyncIngressesResourceWithRawResponse: + """ + 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/hypeman-python#accessing-raw-response-data-eg-headers + """ + return AsyncIngressesResourceWithRawResponse(self) + + @cached_property + def with_streaming_response(self) -> AsyncIngressesResourceWithStreamingResponse: + """ + An alternative to `.with_raw_response` that doesn't eagerly read the response body. + + For more information, see https://www.github.com/kernel/hypeman-python#with_streaming_response + """ + return AsyncIngressesResourceWithStreamingResponse(self) + + async def create( + self, + *, + name: str, + rules: Iterable[IngressRuleParam], + 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, + ) -> Ingress: + """ + Create ingress + + Args: + name: Human-readable name (lowercase letters, digits, and dashes only; cannot start or + end with a dash) + + rules: Routing rules for this ingress + + tags: User-defined key-value tags. + + extra_headers: Send extra headers + + extra_query: Add additional query parameters 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( + "/ingresses", + body=await async_maybe_transform( + { + "name": name, + "rules": rules, + "tags": tags, + }, + ingress_create_params.IngressCreateParams, + ), + options=make_request_options( + extra_headers=extra_headers, extra_query=extra_query, extra_body=extra_body, timeout=timeout + ), + cast_to=Ingress, + ) + + async def list( + self, + *, + 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, + ) -> IngressListResponse: + """ + List ingresses + + Args: + tags: Filter ingresses by tag key-value pairs. + + extra_headers: Send extra headers + + extra_query: Add additional query parameters to the request + + extra_body: Add additional JSON properties to the request + + timeout: Override the client-level default timeout for this request, in seconds + """ + return await self._get( + "/ingresses", + options=make_request_options( + extra_headers=extra_headers, + extra_query=extra_query, + extra_body=extra_body, + timeout=timeout, + query=await async_maybe_transform({"tags": tags}, ingress_list_params.IngressListParams), + ), + cast_to=IngressListResponse, + ) + + 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 ingress + + 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("/ingresses/{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 get( + 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, + ) -> Ingress: + """ + Get ingress details + + 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("/ingresses/{id}", id=id), + options=make_request_options( + extra_headers=extra_headers, extra_query=extra_query, extra_body=extra_body, timeout=timeout + ), + cast_to=Ingress, + ) + + +class IngressesResourceWithRawResponse: + def __init__(self, ingresses: IngressesResource) -> None: + self._ingresses = ingresses + + self.create = to_raw_response_wrapper( + ingresses.create, + ) + self.list = to_raw_response_wrapper( + ingresses.list, + ) + self.delete = to_raw_response_wrapper( + ingresses.delete, + ) + self.get = to_raw_response_wrapper( + ingresses.get, + ) + + +class AsyncIngressesResourceWithRawResponse: + def __init__(self, ingresses: AsyncIngressesResource) -> None: + self._ingresses = ingresses + + self.create = async_to_raw_response_wrapper( + ingresses.create, + ) + self.list = async_to_raw_response_wrapper( + ingresses.list, + ) + self.delete = async_to_raw_response_wrapper( + ingresses.delete, + ) + self.get = async_to_raw_response_wrapper( + ingresses.get, + ) + + +class IngressesResourceWithStreamingResponse: + def __init__(self, ingresses: IngressesResource) -> None: + self._ingresses = ingresses + + self.create = to_streamed_response_wrapper( + ingresses.create, + ) + self.list = to_streamed_response_wrapper( + ingresses.list, + ) + self.delete = to_streamed_response_wrapper( + ingresses.delete, + ) + self.get = to_streamed_response_wrapper( + ingresses.get, + ) + + +class AsyncIngressesResourceWithStreamingResponse: + def __init__(self, ingresses: AsyncIngressesResource) -> None: + self._ingresses = ingresses + + self.create = async_to_streamed_response_wrapper( + ingresses.create, + ) + self.list = async_to_streamed_response_wrapper( + ingresses.list, + ) + self.delete = async_to_streamed_response_wrapper( + ingresses.delete, + ) + self.get = async_to_streamed_response_wrapper( + ingresses.get, + ) diff --git a/src/hypeman/resources/instances/__init__.py b/src/hypeman/resources/instances/__init__.py new file mode 100644 index 0000000..98232a4 --- /dev/null +++ b/src/hypeman/resources/instances/__init__.py @@ -0,0 +1,75 @@ +# File generated from our OpenAPI spec by Stainless. See CONTRIBUTING.md for details. + +from .volumes import ( + VolumesResource, + AsyncVolumesResource, + VolumesResourceWithRawResponse, + AsyncVolumesResourceWithRawResponse, + VolumesResourceWithStreamingResponse, + AsyncVolumesResourceWithStreamingResponse, +) +from .instances import ( + InstancesResource, + AsyncInstancesResource, + InstancesResourceWithRawResponse, + AsyncInstancesResourceWithRawResponse, + InstancesResourceWithStreamingResponse, + AsyncInstancesResourceWithStreamingResponse, +) +from .snapshots import ( + SnapshotsResource, + AsyncSnapshotsResource, + SnapshotsResourceWithRawResponse, + AsyncSnapshotsResourceWithRawResponse, + SnapshotsResourceWithStreamingResponse, + AsyncSnapshotsResourceWithStreamingResponse, +) +from .auto_standby import ( + AutoStandbyResource, + AsyncAutoStandbyResource, + AutoStandbyResourceWithRawResponse, + AsyncAutoStandbyResourceWithRawResponse, + AutoStandbyResourceWithStreamingResponse, + AsyncAutoStandbyResourceWithStreamingResponse, +) +from .snapshot_schedule import ( + SnapshotScheduleResource, + AsyncSnapshotScheduleResource, + SnapshotScheduleResourceWithRawResponse, + AsyncSnapshotScheduleResourceWithRawResponse, + SnapshotScheduleResourceWithStreamingResponse, + AsyncSnapshotScheduleResourceWithStreamingResponse, +) + +__all__ = [ + "AutoStandbyResource", + "AsyncAutoStandbyResource", + "AutoStandbyResourceWithRawResponse", + "AsyncAutoStandbyResourceWithRawResponse", + "AutoStandbyResourceWithStreamingResponse", + "AsyncAutoStandbyResourceWithStreamingResponse", + "VolumesResource", + "AsyncVolumesResource", + "VolumesResourceWithRawResponse", + "AsyncVolumesResourceWithRawResponse", + "VolumesResourceWithStreamingResponse", + "AsyncVolumesResourceWithStreamingResponse", + "SnapshotsResource", + "AsyncSnapshotsResource", + "SnapshotsResourceWithRawResponse", + "AsyncSnapshotsResourceWithRawResponse", + "SnapshotsResourceWithStreamingResponse", + "AsyncSnapshotsResourceWithStreamingResponse", + "SnapshotScheduleResource", + "AsyncSnapshotScheduleResource", + "SnapshotScheduleResourceWithRawResponse", + "AsyncSnapshotScheduleResourceWithRawResponse", + "SnapshotScheduleResourceWithStreamingResponse", + "AsyncSnapshotScheduleResourceWithStreamingResponse", + "InstancesResource", + "AsyncInstancesResource", + "InstancesResourceWithRawResponse", + "AsyncInstancesResourceWithRawResponse", + "InstancesResourceWithStreamingResponse", + "AsyncInstancesResourceWithStreamingResponse", +] diff --git a/src/hypeman/resources/instances/auto_standby.py b/src/hypeman/resources/instances/auto_standby.py new file mode 100644 index 0000000..6a69659 --- /dev/null +++ b/src/hypeman/resources/instances/auto_standby.py @@ -0,0 +1,268 @@ +# File generated from our OpenAPI spec by Stainless. See CONTRIBUTING.md for details. + +from __future__ import annotations + +import httpx + +from ..._types import Body, Query, Headers, NotGiven, not_given +from ..._utils import path_template +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.auto_standby_status import AutoStandbyStatus + +__all__ = ["AutoStandbyResource", "AsyncAutoStandbyResource"] + + +class AutoStandbyResource(SyncAPIResource): + @cached_property + def with_raw_response(self) -> AutoStandbyResourceWithRawResponse: + """ + 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/hypeman-python#accessing-raw-response-data-eg-headers + """ + return AutoStandbyResourceWithRawResponse(self) + + @cached_property + def with_streaming_response(self) -> AutoStandbyResourceWithStreamingResponse: + """ + An alternative to `.with_raw_response` that doesn't eagerly read the response body. + + For more information, see https://www.github.com/kernel/hypeman-python#with_streaming_response + """ + return AutoStandbyResourceWithStreamingResponse(self) + + def hold( + 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, + ) -> AutoStandbyStatus: + """ + Places a hold that prevents the auto-standby controller from putting the + instance into standby before `hold_until`, and cancels any queued auto-standby + attempt. + + Each hold replaces the instance's previous hold, so `hold_until` always reflects + the most recent call. Holding again after the policy's `idle_timeout` is + shortened moves `hold_until` earlier. + + Callers may use this before opening a connection to a candidate-idle instance: a + 200 means it is safe to connect until `hold_until`; a 409 means the instance is + in standby (or irrevocably entering it) and must be restored first. + + Instances where auto-standby is disabled, unconfigured, or unsupported return + 200 with their current status because no auto-standby will occur. + + 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("/instances/{id}/auto-standby/hold", id=id), + options=make_request_options( + extra_headers=extra_headers, extra_query=extra_query, extra_body=extra_body, timeout=timeout + ), + cast_to=AutoStandbyStatus, + ) + + def status( + 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, + ) -> AutoStandbyStatus: + """ + Get auto-standby diagnostic 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("/instances/{id}/auto-standby/status", id=id), + options=make_request_options( + extra_headers=extra_headers, extra_query=extra_query, extra_body=extra_body, timeout=timeout + ), + cast_to=AutoStandbyStatus, + ) + + +class AsyncAutoStandbyResource(AsyncAPIResource): + @cached_property + def with_raw_response(self) -> AsyncAutoStandbyResourceWithRawResponse: + """ + 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/hypeman-python#accessing-raw-response-data-eg-headers + """ + return AsyncAutoStandbyResourceWithRawResponse(self) + + @cached_property + def with_streaming_response(self) -> AsyncAutoStandbyResourceWithStreamingResponse: + """ + An alternative to `.with_raw_response` that doesn't eagerly read the response body. + + For more information, see https://www.github.com/kernel/hypeman-python#with_streaming_response + """ + return AsyncAutoStandbyResourceWithStreamingResponse(self) + + async def hold( + 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, + ) -> AutoStandbyStatus: + """ + Places a hold that prevents the auto-standby controller from putting the + instance into standby before `hold_until`, and cancels any queued auto-standby + attempt. + + Each hold replaces the instance's previous hold, so `hold_until` always reflects + the most recent call. Holding again after the policy's `idle_timeout` is + shortened moves `hold_until` earlier. + + Callers may use this before opening a connection to a candidate-idle instance: a + 200 means it is safe to connect until `hold_until`; a 409 means the instance is + in standby (or irrevocably entering it) and must be restored first. + + Instances where auto-standby is disabled, unconfigured, or unsupported return + 200 with their current status because no auto-standby will occur. + + 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("/instances/{id}/auto-standby/hold", id=id), + options=make_request_options( + extra_headers=extra_headers, extra_query=extra_query, extra_body=extra_body, timeout=timeout + ), + cast_to=AutoStandbyStatus, + ) + + async def status( + 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, + ) -> AutoStandbyStatus: + """ + Get auto-standby diagnostic 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("/instances/{id}/auto-standby/status", id=id), + options=make_request_options( + extra_headers=extra_headers, extra_query=extra_query, extra_body=extra_body, timeout=timeout + ), + cast_to=AutoStandbyStatus, + ) + + +class AutoStandbyResourceWithRawResponse: + def __init__(self, auto_standby: AutoStandbyResource) -> None: + self._auto_standby = auto_standby + + self.hold = to_raw_response_wrapper( + auto_standby.hold, + ) + self.status = to_raw_response_wrapper( + auto_standby.status, + ) + + +class AsyncAutoStandbyResourceWithRawResponse: + def __init__(self, auto_standby: AsyncAutoStandbyResource) -> None: + self._auto_standby = auto_standby + + self.hold = async_to_raw_response_wrapper( + auto_standby.hold, + ) + self.status = async_to_raw_response_wrapper( + auto_standby.status, + ) + + +class AutoStandbyResourceWithStreamingResponse: + def __init__(self, auto_standby: AutoStandbyResource) -> None: + self._auto_standby = auto_standby + + self.hold = to_streamed_response_wrapper( + auto_standby.hold, + ) + self.status = to_streamed_response_wrapper( + auto_standby.status, + ) + + +class AsyncAutoStandbyResourceWithStreamingResponse: + def __init__(self, auto_standby: AsyncAutoStandbyResource) -> None: + self._auto_standby = auto_standby + + self.hold = async_to_streamed_response_wrapper( + auto_standby.hold, + ) + self.status = async_to_streamed_response_wrapper( + auto_standby.status, + ) diff --git a/src/hypeman/resources/instances/instances.py b/src/hypeman/resources/instances/instances.py new file mode 100644 index 0000000..e7caa0a --- /dev/null +++ b/src/hypeman/resources/instances/instances.py @@ -0,0 +1,1920 @@ +# File generated from our OpenAPI spec by Stainless. See CONTRIBUTING.md for details. + +from __future__ import annotations + +from typing import Dict, Iterable +from typing_extensions import Literal + +import httpx + +from ...types import ( + instance_fork_params, + instance_list_params, + instance_logs_params, + instance_stat_params, + instance_wait_params, + instance_start_params, + instance_create_params, + instance_update_params, + instance_standby_params, +) +from .volumes import ( + VolumesResource, + AsyncVolumesResource, + VolumesResourceWithRawResponse, + AsyncVolumesResourceWithRawResponse, + VolumesResourceWithStreamingResponse, + AsyncVolumesResourceWithStreamingResponse, +) +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 .snapshots import ( + SnapshotsResource, + AsyncSnapshotsResource, + SnapshotsResourceWithRawResponse, + AsyncSnapshotsResourceWithRawResponse, + SnapshotsResourceWithStreamingResponse, + AsyncSnapshotsResourceWithStreamingResponse, +) +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 .auto_standby import ( + AutoStandbyResource, + AsyncAutoStandbyResource, + AutoStandbyResourceWithRawResponse, + AsyncAutoStandbyResourceWithRawResponse, + AutoStandbyResourceWithStreamingResponse, + AsyncAutoStandbyResourceWithStreamingResponse, +) +from ..._base_client import make_request_options +from ...types.instance import Instance +from ...types.path_info import PathInfo +from .snapshot_schedule import ( + SnapshotScheduleResource, + AsyncSnapshotScheduleResource, + SnapshotScheduleResourceWithRawResponse, + AsyncSnapshotScheduleResourceWithRawResponse, + SnapshotScheduleResourceWithStreamingResponse, + AsyncSnapshotScheduleResourceWithStreamingResponse, +) +from ...types.instance_stats import InstanceStats +from ...types.health_check_param import HealthCheckParam +from ...types.volume_mount_param import VolumeMountParam +from ...types.restart_policy_param import RestartPolicyParam +from ...types.snapshot_policy_param import SnapshotPolicyParam +from ...types.instance_list_response import InstanceListResponse +from ...types.instance_logs_response import InstanceLogsResponse +from ...types.wait_for_state_response import WaitForStateResponse +from ...types.auto_standby_policy_param import AutoStandbyPolicyParam +from ...types.shared_params.snapshot_compression_config import SnapshotCompressionConfig + +__all__ = ["InstancesResource", "AsyncInstancesResource"] + + +class InstancesResource(SyncAPIResource): + @cached_property + def auto_standby(self) -> AutoStandbyResource: + return AutoStandbyResource(self._client) + + @cached_property + def volumes(self) -> VolumesResource: + return VolumesResource(self._client) + + @cached_property + def snapshots(self) -> SnapshotsResource: + return SnapshotsResource(self._client) + + @cached_property + def snapshot_schedule(self) -> SnapshotScheduleResource: + return SnapshotScheduleResource(self._client) + + @cached_property + def with_raw_response(self) -> InstancesResourceWithRawResponse: + """ + 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/hypeman-python#accessing-raw-response-data-eg-headers + """ + return InstancesResourceWithRawResponse(self) + + @cached_property + def with_streaming_response(self) -> InstancesResourceWithStreamingResponse: + """ + An alternative to `.with_raw_response` that doesn't eagerly read the response body. + + For more information, see https://www.github.com/kernel/hypeman-python#with_streaming_response + """ + return InstancesResourceWithStreamingResponse(self) + + def create( + self, + *, + image: str, + name: str, + auto_standby: AutoStandbyPolicyParam | Omit = omit, + cmd: SequenceNotStr[str] | Omit = omit, + credentials: Dict[str, instance_create_params.Credentials] | Omit = omit, + devices: SequenceNotStr[str] | Omit = omit, + disk_io_bps: str | Omit = omit, + entrypoint: SequenceNotStr[str] | Omit = omit, + env: Dict[str, str] | Omit = omit, + gpu: instance_create_params.GPU | Omit = omit, + health_check: HealthCheckParam | Omit = omit, + hotplug_size: str | Omit = omit, + hypervisor: Literal["cloud-hypervisor", "firecracker", "qemu", "qemu-microvm", "vz"] | Omit = omit, + network: instance_create_params.Network | Omit = omit, + overlay_size: str | Omit = omit, + platform: str | Omit = omit, + restart_policy: RestartPolicyParam | Omit = omit, + size: str | Omit = omit, + skip_guest_agent: bool | Omit = omit, + skip_kernel_headers: bool | Omit = omit, + snapshot_policy: SnapshotPolicyParam | Omit = omit, + tags: Dict[str, str] | Omit = omit, + vcpus: int | Omit = omit, + volumes: Iterable[VolumeMountParam] | 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, + ) -> Instance: + """ + Create and start instance + + Args: + image: OCI image reference + + name: Human-readable name (lowercase letters, digits, and dashes only; cannot start or + end with a dash) + + auto_standby: Linux-only automatic standby policy based on active inbound TCP connections + observed from the host conntrack table. + + cmd: Override image CMD (like docker run ). Omit to use image + default. + + credentials: Host-managed credential brokering policies keyed by guest-visible env var name. + Those guest env vars receive mock placeholder values, while the real values + remain host-scoped in the request `env` map and are only materialized on the + mediated egress path according to each credential's `source` and `inject` rules. + + devices: Device IDs or names to attach for GPU/PCI passthrough + + disk_io_bps: Disk I/O rate limit (e.g., "100MB/s", "500MB/s"). Defaults to proportional share + based on CPU allocation if configured. + + entrypoint: Override image entrypoint (like docker run --entrypoint). Omit to use image + default. + + env: Environment variables + + gpu: GPU configuration for the instance + + health_check: Workload health check policy. Health is reported separately from instance + lifecycle state. + + hotplug_size: Additional memory for hotplug (human-readable format like "3GB", "1G"). Omit to + disable hotplug memory. + + hypervisor: Hypervisor backend to use for this instance. qemu uses the architecture-native + standard board; qemu-microvm uses QEMU's minimal Linux amd64 board and does not + support PCI devices, hotplug memory, or more than eight virtio-mmio devices. + Defaults to server configuration. + + network: Network configuration for the instance + + overlay_size: Writable overlay disk size (human-readable format like "10GB", "50G") + + platform: Target platform as os/arch[/variant] (e.g. "linux/amd64"), matching Docker + --platform. Omit for the host platform. Not a fixed enum: the os/arch[/variant] + grammar is validated server-side and invalid values return 400 invalid_platform. + Only os "linux" with arch amd64 or arm64 is accepted today. + + restart_policy: Whole-instance restart supervision policy. + + size: Base memory size (human-readable format like "1GB", "512MB", "2G") + + skip_guest_agent: Skip guest-agent installation during boot. When true, the exec and stat APIs + will not work for this instance. The instance will still run, but remote command + execution will be unavailable. + + skip_kernel_headers: Skip kernel headers installation during boot for faster startup. When true, DKMS + (Dynamic Kernel Module Support) will not work, preventing compilation of + out-of-tree kernel modules (e.g., NVIDIA vGPU drivers). Recommended for + workloads that don't need kernel module compilation. + + snapshot_policy: Snapshot policy for this instance. Controls compression settings applied when + creating snapshots or entering standby, plus any default standby-only + compression delay. + + tags: User-defined key-value tags. + + vcpus: Number of virtual CPUs + + volumes: Volumes to attach to the instance at creation time + + extra_headers: Send extra headers + + extra_query: Add additional query parameters 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( + "/instances", + body=maybe_transform( + { + "image": image, + "name": name, + "auto_standby": auto_standby, + "cmd": cmd, + "credentials": credentials, + "devices": devices, + "disk_io_bps": disk_io_bps, + "entrypoint": entrypoint, + "env": env, + "gpu": gpu, + "health_check": health_check, + "hotplug_size": hotplug_size, + "hypervisor": hypervisor, + "network": network, + "overlay_size": overlay_size, + "platform": platform, + "restart_policy": restart_policy, + "size": size, + "skip_guest_agent": skip_guest_agent, + "skip_kernel_headers": skip_kernel_headers, + "snapshot_policy": snapshot_policy, + "tags": tags, + "vcpus": vcpus, + "volumes": volumes, + }, + instance_create_params.InstanceCreateParams, + ), + options=make_request_options( + extra_headers=extra_headers, extra_query=extra_query, extra_body=extra_body, timeout=timeout + ), + cast_to=Instance, + ) + + def update( + self, + id: str, + *, + auto_standby: AutoStandbyPolicyParam | Omit = omit, + env: Dict[str, str] | Omit = omit, + health_check: HealthCheckParam | Omit = omit, + restart_policy: RestartPolicyParam | 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, + ) -> Instance: + """Update mutable properties of a running instance. + + Currently supports updating + only the environment variables referenced by existing credential policies, + enabling secret/key rotation without instance restart. + + Args: + auto_standby: Linux-only automatic standby policy based on active inbound TCP connections + observed from the host conntrack table. + + env: Environment variables to update (merged with existing). Only keys referenced by + the instance's existing credential `source.env` bindings are accepted. Use this + to rotate real credential values without restarting the VM. + + health_check: Workload health check policy. Health is reported separately from instance + lifecycle state. + + restart_policy: Whole-instance restart supervision policy. + + extra_headers: Send extra headers + + extra_query: Add additional query parameters 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("/instances/{id}", id=id), + body=maybe_transform( + { + "auto_standby": auto_standby, + "env": env, + "health_check": health_check, + "restart_policy": restart_policy, + }, + instance_update_params.InstanceUpdateParams, + ), + options=make_request_options( + extra_headers=extra_headers, extra_query=extra_query, extra_body=extra_body, timeout=timeout + ), + cast_to=Instance, + ) + + def list( + self, + *, + state: Literal["Created", "Initializing", "Running", "Paused", "Shutdown", "Stopped", "Standby", "Unknown"] + | 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, + ) -> InstanceListResponse: + """ + List instances + + Args: + state: Filter instances by state (e.g., Running, Stopped) + + tags: + Filter instances by tag key-value pairs. Uses deepObject style: + ?tags[team]=backend&tags[env]=staging Multiple entries are ANDed together. All + specified key-value pairs must match. + + extra_headers: Send extra headers + + extra_query: Add additional query parameters 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( + "/instances", + options=make_request_options( + extra_headers=extra_headers, + extra_query=extra_query, + extra_body=extra_body, + timeout=timeout, + query=maybe_transform( + { + "state": state, + "tags": tags, + }, + instance_list_params.InstanceListParams, + ), + ), + cast_to=InstanceListResponse, + ) + + 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: + """ + Stop and delete 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": "*/*", **(extra_headers or {})} + return self._delete( + path_template("/instances/{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 fork( + self, + id: str, + *, + name: str, + from_running: bool | Omit = omit, + target_state: Literal["Stopped", "Standby", "Running"] | 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, + ) -> Instance: + """ + Fork an instance from stopped, standby, or running (with from_running=true) + + Args: + name: Name for the forked instance (lowercase letters, digits, and dashes only; cannot + start or end with a dash) + + from_running: Allow forking from a running source instance. When true and source is Running, + the source is put into standby, forked, then restored back to Running. + + target_state: Optional final state for the forked instance. Default is the source instance + state at fork time. For example, forking from Running defaults the fork result + to Running. + + extra_headers: Send extra headers + + extra_query: Add additional query parameters 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("/instances/{id}/fork", id=id), + body=maybe_transform( + { + "name": name, + "from_running": from_running, + "target_state": target_state, + }, + instance_fork_params.InstanceForkParams, + ), + options=make_request_options( + extra_headers=extra_headers, extra_query=extra_query, extra_body=extra_body, timeout=timeout + ), + cast_to=Instance, + ) + + def get( + 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, + ) -> Instance: + """ + Get instance details + + 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("/instances/{id}", id=id), + options=make_request_options( + extra_headers=extra_headers, extra_query=extra_query, extra_body=extra_body, timeout=timeout + ), + cast_to=Instance, + ) + + def logs( + self, + id: str, + *, + follow: bool | Omit = omit, + source: Literal["app", "vmm", "hypeman"] | Omit = omit, + tail: 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, + ) -> Stream[InstanceLogsResponse]: + """Streams instance logs as Server-Sent Events. + + Use the `source` parameter to + select which log to stream: + + - `app` (default): Guest application logs (serial console) + - `vmm`: Cloud Hypervisor VMM logs + - `hypeman`: Hypeman operations log + + Returns the last N lines (controlled by `tail` parameter), then optionally + continues streaming new lines if `follow=true`. + + Args: + follow: Continue streaming new lines after initial output + + source: + Log source to stream: + + - app: Guest application logs (serial console output) + - vmm: Cloud Hypervisor VMM logs (hypervisor stdout+stderr) + - hypeman: Hypeman operations log (actions taken on this instance) + + tail: Number of lines to return from end + + extra_headers: Send extra headers + + extra_query: Add additional query parameters 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("/instances/{id}/logs", id=id), + options=make_request_options( + extra_headers=extra_headers, + extra_query=extra_query, + extra_body=extra_body, + timeout=timeout, + query=maybe_transform( + { + "follow": follow, + "source": source, + "tail": tail, + }, + instance_logs_params.InstanceLogsParams, + ), + ), + cast_to=str, + stream=True, + stream_cls=Stream[InstanceLogsResponse], + ) + + def restore( + 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, + ) -> Instance: + """ + Restore instance from standby + + 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("/instances/{id}/restore", id=id), + options=make_request_options( + extra_headers=extra_headers, extra_query=extra_query, extra_body=extra_body, timeout=timeout + ), + cast_to=Instance, + ) + + def standby( + self, + id: str, + *, + compression: SnapshotCompressionConfig | Omit = omit, + compression_delay: 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, + ) -> Instance: + """ + Put instance in standby (pause, snapshot, delete VMM) + + Args: + compression: Compression settings for standby snapshot memory. Overrides instance defaults. + + compression_delay: Delay before standby snapshot compression begins, expressed as a Go duration + like "30s" or "5m". Overrides the instance default for this standby operation + 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("/instances/{id}/standby", id=id), + body=maybe_transform( + { + "compression": compression, + "compression_delay": compression_delay, + }, + instance_standby_params.InstanceStandbyParams, + ), + options=make_request_options( + extra_headers=extra_headers, extra_query=extra_query, extra_body=extra_body, timeout=timeout + ), + cast_to=Instance, + ) + + def start( + self, + id: str, + *, + cmd: SequenceNotStr[str] | Omit = omit, + entrypoint: 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, + ) -> Instance: + """Start a stopped instance + + Args: + cmd: Override image CMD for this run. + + Omit to keep previous value. + + entrypoint: Override image entrypoint for this run. Omit to keep previous 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 + """ + if not id: + raise ValueError(f"Expected a non-empty value for `id` but received {id!r}") + return self._post( + path_template("/instances/{id}/start", id=id), + body=maybe_transform( + { + "cmd": cmd, + "entrypoint": entrypoint, + }, + instance_start_params.InstanceStartParams, + ), + options=make_request_options( + extra_headers=extra_headers, extra_query=extra_query, extra_body=extra_body, timeout=timeout + ), + cast_to=Instance, + ) + + def stat( + self, + id: str, + *, + path: str, + follow_links: 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, + ) -> PathInfo: + """Returns information about a path in the guest filesystem. + + Useful for checking if + a path exists, its type, and permissions before performing file operations. + + Args: + path: Path to stat in the guest filesystem + + follow_links: Follow symbolic links (like stat vs lstat) + + extra_headers: Send extra headers + + extra_query: Add additional query parameters 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("/instances/{id}/stat", 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, + "follow_links": follow_links, + }, + instance_stat_params.InstanceStatParams, + ), + ), + cast_to=PathInfo, + ) + + def stats( + 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, + ) -> InstanceStats: + """ + Returns real-time resource utilization statistics for a running VM instance. + Metrics are collected from /proc//stat and /proc//statm for CPU and + memory, and from TAP interface statistics for network I/O. + + 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("/instances/{id}/stats", id=id), + options=make_request_options( + extra_headers=extra_headers, extra_query=extra_query, extra_body=extra_body, timeout=timeout + ), + cast_to=InstanceStats, + ) + + def stop( + 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, + ) -> Instance: + """ + Stop instance (graceful shutdown) + + 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("/instances/{id}/stop", id=id), + options=make_request_options( + extra_headers=extra_headers, extra_query=extra_query, extra_body=extra_body, timeout=timeout + ), + cast_to=Instance, + ) + + def wait( + self, + id: str, + *, + state: Literal["Created", "Initializing", "Running", "Paused", "Shutdown", "Stopped", "Standby", "Unknown"], + api_timeout: 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, + ) -> WaitForStateResponse: + """ + Blocks until the instance reaches the specified target state, the timeout + expires, or the instance enters a terminal/error state. Useful for avoiding + client-side polling when waiting for state transitions (e.g. waiting for an + instance to become Running). + + Args: + state: Target state to wait for + + api_timeout: Maximum duration to wait (Go duration format, e.g. "30s", "2m"). Capped at 5 + minutes. Defaults to 60 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._get( + path_template("/instances/{id}/wait", id=id), + options=make_request_options( + extra_headers=extra_headers, + extra_query=extra_query, + extra_body=extra_body, + timeout=timeout, + query=maybe_transform( + { + "state": state, + "api_timeout": api_timeout, + }, + instance_wait_params.InstanceWaitParams, + ), + ), + cast_to=WaitForStateResponse, + ) + + +class AsyncInstancesResource(AsyncAPIResource): + @cached_property + def auto_standby(self) -> AsyncAutoStandbyResource: + return AsyncAutoStandbyResource(self._client) + + @cached_property + def volumes(self) -> AsyncVolumesResource: + return AsyncVolumesResource(self._client) + + @cached_property + def snapshots(self) -> AsyncSnapshotsResource: + return AsyncSnapshotsResource(self._client) + + @cached_property + def snapshot_schedule(self) -> AsyncSnapshotScheduleResource: + return AsyncSnapshotScheduleResource(self._client) + + @cached_property + def with_raw_response(self) -> AsyncInstancesResourceWithRawResponse: + """ + 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/hypeman-python#accessing-raw-response-data-eg-headers + """ + return AsyncInstancesResourceWithRawResponse(self) + + @cached_property + def with_streaming_response(self) -> AsyncInstancesResourceWithStreamingResponse: + """ + An alternative to `.with_raw_response` that doesn't eagerly read the response body. + + For more information, see https://www.github.com/kernel/hypeman-python#with_streaming_response + """ + return AsyncInstancesResourceWithStreamingResponse(self) + + async def create( + self, + *, + image: str, + name: str, + auto_standby: AutoStandbyPolicyParam | Omit = omit, + cmd: SequenceNotStr[str] | Omit = omit, + credentials: Dict[str, instance_create_params.Credentials] | Omit = omit, + devices: SequenceNotStr[str] | Omit = omit, + disk_io_bps: str | Omit = omit, + entrypoint: SequenceNotStr[str] | Omit = omit, + env: Dict[str, str] | Omit = omit, + gpu: instance_create_params.GPU | Omit = omit, + health_check: HealthCheckParam | Omit = omit, + hotplug_size: str | Omit = omit, + hypervisor: Literal["cloud-hypervisor", "firecracker", "qemu", "qemu-microvm", "vz"] | Omit = omit, + network: instance_create_params.Network | Omit = omit, + overlay_size: str | Omit = omit, + platform: str | Omit = omit, + restart_policy: RestartPolicyParam | Omit = omit, + size: str | Omit = omit, + skip_guest_agent: bool | Omit = omit, + skip_kernel_headers: bool | Omit = omit, + snapshot_policy: SnapshotPolicyParam | Omit = omit, + tags: Dict[str, str] | Omit = omit, + vcpus: int | Omit = omit, + volumes: Iterable[VolumeMountParam] | 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, + ) -> Instance: + """ + Create and start instance + + Args: + image: OCI image reference + + name: Human-readable name (lowercase letters, digits, and dashes only; cannot start or + end with a dash) + + auto_standby: Linux-only automatic standby policy based on active inbound TCP connections + observed from the host conntrack table. + + cmd: Override image CMD (like docker run ). Omit to use image + default. + + credentials: Host-managed credential brokering policies keyed by guest-visible env var name. + Those guest env vars receive mock placeholder values, while the real values + remain host-scoped in the request `env` map and are only materialized on the + mediated egress path according to each credential's `source` and `inject` rules. + + devices: Device IDs or names to attach for GPU/PCI passthrough + + disk_io_bps: Disk I/O rate limit (e.g., "100MB/s", "500MB/s"). Defaults to proportional share + based on CPU allocation if configured. + + entrypoint: Override image entrypoint (like docker run --entrypoint). Omit to use image + default. + + env: Environment variables + + gpu: GPU configuration for the instance + + health_check: Workload health check policy. Health is reported separately from instance + lifecycle state. + + hotplug_size: Additional memory for hotplug (human-readable format like "3GB", "1G"). Omit to + disable hotplug memory. + + hypervisor: Hypervisor backend to use for this instance. qemu uses the architecture-native + standard board; qemu-microvm uses QEMU's minimal Linux amd64 board and does not + support PCI devices, hotplug memory, or more than eight virtio-mmio devices. + Defaults to server configuration. + + network: Network configuration for the instance + + overlay_size: Writable overlay disk size (human-readable format like "10GB", "50G") + + platform: Target platform as os/arch[/variant] (e.g. "linux/amd64"), matching Docker + --platform. Omit for the host platform. Not a fixed enum: the os/arch[/variant] + grammar is validated server-side and invalid values return 400 invalid_platform. + Only os "linux" with arch amd64 or arm64 is accepted today. + + restart_policy: Whole-instance restart supervision policy. + + size: Base memory size (human-readable format like "1GB", "512MB", "2G") + + skip_guest_agent: Skip guest-agent installation during boot. When true, the exec and stat APIs + will not work for this instance. The instance will still run, but remote command + execution will be unavailable. + + skip_kernel_headers: Skip kernel headers installation during boot for faster startup. When true, DKMS + (Dynamic Kernel Module Support) will not work, preventing compilation of + out-of-tree kernel modules (e.g., NVIDIA vGPU drivers). Recommended for + workloads that don't need kernel module compilation. + + snapshot_policy: Snapshot policy for this instance. Controls compression settings applied when + creating snapshots or entering standby, plus any default standby-only + compression delay. + + tags: User-defined key-value tags. + + vcpus: Number of virtual CPUs + + volumes: Volumes to attach to the instance at creation time + + extra_headers: Send extra headers + + extra_query: Add additional query parameters 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( + "/instances", + body=await async_maybe_transform( + { + "image": image, + "name": name, + "auto_standby": auto_standby, + "cmd": cmd, + "credentials": credentials, + "devices": devices, + "disk_io_bps": disk_io_bps, + "entrypoint": entrypoint, + "env": env, + "gpu": gpu, + "health_check": health_check, + "hotplug_size": hotplug_size, + "hypervisor": hypervisor, + "network": network, + "overlay_size": overlay_size, + "platform": platform, + "restart_policy": restart_policy, + "size": size, + "skip_guest_agent": skip_guest_agent, + "skip_kernel_headers": skip_kernel_headers, + "snapshot_policy": snapshot_policy, + "tags": tags, + "vcpus": vcpus, + "volumes": volumes, + }, + instance_create_params.InstanceCreateParams, + ), + options=make_request_options( + extra_headers=extra_headers, extra_query=extra_query, extra_body=extra_body, timeout=timeout + ), + cast_to=Instance, + ) + + async def update( + self, + id: str, + *, + auto_standby: AutoStandbyPolicyParam | Omit = omit, + env: Dict[str, str] | Omit = omit, + health_check: HealthCheckParam | Omit = omit, + restart_policy: RestartPolicyParam | 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, + ) -> Instance: + """Update mutable properties of a running instance. + + Currently supports updating + only the environment variables referenced by existing credential policies, + enabling secret/key rotation without instance restart. + + Args: + auto_standby: Linux-only automatic standby policy based on active inbound TCP connections + observed from the host conntrack table. + + env: Environment variables to update (merged with existing). Only keys referenced by + the instance's existing credential `source.env` bindings are accepted. Use this + to rotate real credential values without restarting the VM. + + health_check: Workload health check policy. Health is reported separately from instance + lifecycle state. + + restart_policy: Whole-instance restart supervision policy. + + extra_headers: Send extra headers + + extra_query: Add additional query parameters 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("/instances/{id}", id=id), + body=await async_maybe_transform( + { + "auto_standby": auto_standby, + "env": env, + "health_check": health_check, + "restart_policy": restart_policy, + }, + instance_update_params.InstanceUpdateParams, + ), + options=make_request_options( + extra_headers=extra_headers, extra_query=extra_query, extra_body=extra_body, timeout=timeout + ), + cast_to=Instance, + ) + + async def list( + self, + *, + state: Literal["Created", "Initializing", "Running", "Paused", "Shutdown", "Stopped", "Standby", "Unknown"] + | 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, + ) -> InstanceListResponse: + """ + List instances + + Args: + state: Filter instances by state (e.g., Running, Stopped) + + tags: + Filter instances by tag key-value pairs. Uses deepObject style: + ?tags[team]=backend&tags[env]=staging Multiple entries are ANDed together. All + specified key-value pairs must match. + + extra_headers: Send extra headers + + extra_query: Add additional query parameters to the request + + extra_body: Add additional JSON properties to the request + + timeout: Override the client-level default timeout for this request, in seconds + """ + return await self._get( + "/instances", + options=make_request_options( + extra_headers=extra_headers, + extra_query=extra_query, + extra_body=extra_body, + timeout=timeout, + query=await async_maybe_transform( + { + "state": state, + "tags": tags, + }, + instance_list_params.InstanceListParams, + ), + ), + cast_to=InstanceListResponse, + ) + + 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: + """ + Stop and delete 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": "*/*", **(extra_headers or {})} + return await self._delete( + path_template("/instances/{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 fork( + self, + id: str, + *, + name: str, + from_running: bool | Omit = omit, + target_state: Literal["Stopped", "Standby", "Running"] | 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, + ) -> Instance: + """ + Fork an instance from stopped, standby, or running (with from_running=true) + + Args: + name: Name for the forked instance (lowercase letters, digits, and dashes only; cannot + start or end with a dash) + + from_running: Allow forking from a running source instance. When true and source is Running, + the source is put into standby, forked, then restored back to Running. + + target_state: Optional final state for the forked instance. Default is the source instance + state at fork time. For example, forking from Running defaults the fork result + to Running. + + extra_headers: Send extra headers + + extra_query: Add additional query parameters 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("/instances/{id}/fork", id=id), + body=await async_maybe_transform( + { + "name": name, + "from_running": from_running, + "target_state": target_state, + }, + instance_fork_params.InstanceForkParams, + ), + options=make_request_options( + extra_headers=extra_headers, extra_query=extra_query, extra_body=extra_body, timeout=timeout + ), + cast_to=Instance, + ) + + async def get( + 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, + ) -> Instance: + """ + Get instance details + + 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("/instances/{id}", id=id), + options=make_request_options( + extra_headers=extra_headers, extra_query=extra_query, extra_body=extra_body, timeout=timeout + ), + cast_to=Instance, + ) + + async def logs( + self, + id: str, + *, + follow: bool | Omit = omit, + source: Literal["app", "vmm", "hypeman"] | Omit = omit, + tail: 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, + ) -> AsyncStream[InstanceLogsResponse]: + """Streams instance logs as Server-Sent Events. + + Use the `source` parameter to + select which log to stream: + + - `app` (default): Guest application logs (serial console) + - `vmm`: Cloud Hypervisor VMM logs + - `hypeman`: Hypeman operations log + + Returns the last N lines (controlled by `tail` parameter), then optionally + continues streaming new lines if `follow=true`. + + Args: + follow: Continue streaming new lines after initial output + + source: + Log source to stream: + + - app: Guest application logs (serial console output) + - vmm: Cloud Hypervisor VMM logs (hypervisor stdout+stderr) + - hypeman: Hypeman operations log (actions taken on this instance) + + tail: Number of lines to return from end + + extra_headers: Send extra headers + + extra_query: Add additional query parameters 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("/instances/{id}/logs", 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( + { + "follow": follow, + "source": source, + "tail": tail, + }, + instance_logs_params.InstanceLogsParams, + ), + ), + cast_to=str, + stream=True, + stream_cls=AsyncStream[InstanceLogsResponse], + ) + + async def restore( + 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, + ) -> Instance: + """ + Restore instance from standby + + 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("/instances/{id}/restore", id=id), + options=make_request_options( + extra_headers=extra_headers, extra_query=extra_query, extra_body=extra_body, timeout=timeout + ), + cast_to=Instance, + ) + + async def standby( + self, + id: str, + *, + compression: SnapshotCompressionConfig | Omit = omit, + compression_delay: 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, + ) -> Instance: + """ + Put instance in standby (pause, snapshot, delete VMM) + + Args: + compression: Compression settings for standby snapshot memory. Overrides instance defaults. + + compression_delay: Delay before standby snapshot compression begins, expressed as a Go duration + like "30s" or "5m". Overrides the instance default for this standby operation + 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("/instances/{id}/standby", id=id), + body=await async_maybe_transform( + { + "compression": compression, + "compression_delay": compression_delay, + }, + instance_standby_params.InstanceStandbyParams, + ), + options=make_request_options( + extra_headers=extra_headers, extra_query=extra_query, extra_body=extra_body, timeout=timeout + ), + cast_to=Instance, + ) + + async def start( + self, + id: str, + *, + cmd: SequenceNotStr[str] | Omit = omit, + entrypoint: 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, + ) -> Instance: + """Start a stopped instance + + Args: + cmd: Override image CMD for this run. + + Omit to keep previous value. + + entrypoint: Override image entrypoint for this run. Omit to keep previous 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 + """ + if not id: + raise ValueError(f"Expected a non-empty value for `id` but received {id!r}") + return await self._post( + path_template("/instances/{id}/start", id=id), + body=await async_maybe_transform( + { + "cmd": cmd, + "entrypoint": entrypoint, + }, + instance_start_params.InstanceStartParams, + ), + options=make_request_options( + extra_headers=extra_headers, extra_query=extra_query, extra_body=extra_body, timeout=timeout + ), + cast_to=Instance, + ) + + async def stat( + self, + id: str, + *, + path: str, + follow_links: 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, + ) -> PathInfo: + """Returns information about a path in the guest filesystem. + + Useful for checking if + a path exists, its type, and permissions before performing file operations. + + Args: + path: Path to stat in the guest filesystem + + follow_links: Follow symbolic links (like stat vs lstat) + + extra_headers: Send extra headers + + extra_query: Add additional query parameters 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("/instances/{id}/stat", 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, + "follow_links": follow_links, + }, + instance_stat_params.InstanceStatParams, + ), + ), + cast_to=PathInfo, + ) + + async def stats( + 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, + ) -> InstanceStats: + """ + Returns real-time resource utilization statistics for a running VM instance. + Metrics are collected from /proc//stat and /proc//statm for CPU and + memory, and from TAP interface statistics for network I/O. + + 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("/instances/{id}/stats", id=id), + options=make_request_options( + extra_headers=extra_headers, extra_query=extra_query, extra_body=extra_body, timeout=timeout + ), + cast_to=InstanceStats, + ) + + async def stop( + 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, + ) -> Instance: + """ + Stop instance (graceful shutdown) + + 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("/instances/{id}/stop", id=id), + options=make_request_options( + extra_headers=extra_headers, extra_query=extra_query, extra_body=extra_body, timeout=timeout + ), + cast_to=Instance, + ) + + async def wait( + self, + id: str, + *, + state: Literal["Created", "Initializing", "Running", "Paused", "Shutdown", "Stopped", "Standby", "Unknown"], + api_timeout: 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, + ) -> WaitForStateResponse: + """ + Blocks until the instance reaches the specified target state, the timeout + expires, or the instance enters a terminal/error state. Useful for avoiding + client-side polling when waiting for state transitions (e.g. waiting for an + instance to become Running). + + Args: + state: Target state to wait for + + api_timeout: Maximum duration to wait (Go duration format, e.g. "30s", "2m"). Capped at 5 + minutes. Defaults to 60 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._get( + path_template("/instances/{id}/wait", 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( + { + "state": state, + "api_timeout": api_timeout, + }, + instance_wait_params.InstanceWaitParams, + ), + ), + cast_to=WaitForStateResponse, + ) + + +class InstancesResourceWithRawResponse: + def __init__(self, instances: InstancesResource) -> None: + self._instances = instances + + self.create = to_raw_response_wrapper( + instances.create, + ) + self.update = to_raw_response_wrapper( + instances.update, + ) + self.list = to_raw_response_wrapper( + instances.list, + ) + self.delete = to_raw_response_wrapper( + instances.delete, + ) + self.fork = to_raw_response_wrapper( + instances.fork, + ) + self.get = to_raw_response_wrapper( + instances.get, + ) + self.logs = to_raw_response_wrapper( + instances.logs, + ) + self.restore = to_raw_response_wrapper( + instances.restore, + ) + self.standby = to_raw_response_wrapper( + instances.standby, + ) + self.start = to_raw_response_wrapper( + instances.start, + ) + self.stat = to_raw_response_wrapper( + instances.stat, + ) + self.stats = to_raw_response_wrapper( + instances.stats, + ) + self.stop = to_raw_response_wrapper( + instances.stop, + ) + self.wait = to_raw_response_wrapper( + instances.wait, + ) + + @cached_property + def auto_standby(self) -> AutoStandbyResourceWithRawResponse: + return AutoStandbyResourceWithRawResponse(self._instances.auto_standby) + + @cached_property + def volumes(self) -> VolumesResourceWithRawResponse: + return VolumesResourceWithRawResponse(self._instances.volumes) + + @cached_property + def snapshots(self) -> SnapshotsResourceWithRawResponse: + return SnapshotsResourceWithRawResponse(self._instances.snapshots) + + @cached_property + def snapshot_schedule(self) -> SnapshotScheduleResourceWithRawResponse: + return SnapshotScheduleResourceWithRawResponse(self._instances.snapshot_schedule) + + +class AsyncInstancesResourceWithRawResponse: + def __init__(self, instances: AsyncInstancesResource) -> None: + self._instances = instances + + self.create = async_to_raw_response_wrapper( + instances.create, + ) + self.update = async_to_raw_response_wrapper( + instances.update, + ) + self.list = async_to_raw_response_wrapper( + instances.list, + ) + self.delete = async_to_raw_response_wrapper( + instances.delete, + ) + self.fork = async_to_raw_response_wrapper( + instances.fork, + ) + self.get = async_to_raw_response_wrapper( + instances.get, + ) + self.logs = async_to_raw_response_wrapper( + instances.logs, + ) + self.restore = async_to_raw_response_wrapper( + instances.restore, + ) + self.standby = async_to_raw_response_wrapper( + instances.standby, + ) + self.start = async_to_raw_response_wrapper( + instances.start, + ) + self.stat = async_to_raw_response_wrapper( + instances.stat, + ) + self.stats = async_to_raw_response_wrapper( + instances.stats, + ) + self.stop = async_to_raw_response_wrapper( + instances.stop, + ) + self.wait = async_to_raw_response_wrapper( + instances.wait, + ) + + @cached_property + def auto_standby(self) -> AsyncAutoStandbyResourceWithRawResponse: + return AsyncAutoStandbyResourceWithRawResponse(self._instances.auto_standby) + + @cached_property + def volumes(self) -> AsyncVolumesResourceWithRawResponse: + return AsyncVolumesResourceWithRawResponse(self._instances.volumes) + + @cached_property + def snapshots(self) -> AsyncSnapshotsResourceWithRawResponse: + return AsyncSnapshotsResourceWithRawResponse(self._instances.snapshots) + + @cached_property + def snapshot_schedule(self) -> AsyncSnapshotScheduleResourceWithRawResponse: + return AsyncSnapshotScheduleResourceWithRawResponse(self._instances.snapshot_schedule) + + +class InstancesResourceWithStreamingResponse: + def __init__(self, instances: InstancesResource) -> None: + self._instances = instances + + self.create = to_streamed_response_wrapper( + instances.create, + ) + self.update = to_streamed_response_wrapper( + instances.update, + ) + self.list = to_streamed_response_wrapper( + instances.list, + ) + self.delete = to_streamed_response_wrapper( + instances.delete, + ) + self.fork = to_streamed_response_wrapper( + instances.fork, + ) + self.get = to_streamed_response_wrapper( + instances.get, + ) + self.logs = to_streamed_response_wrapper( + instances.logs, + ) + self.restore = to_streamed_response_wrapper( + instances.restore, + ) + self.standby = to_streamed_response_wrapper( + instances.standby, + ) + self.start = to_streamed_response_wrapper( + instances.start, + ) + self.stat = to_streamed_response_wrapper( + instances.stat, + ) + self.stats = to_streamed_response_wrapper( + instances.stats, + ) + self.stop = to_streamed_response_wrapper( + instances.stop, + ) + self.wait = to_streamed_response_wrapper( + instances.wait, + ) + + @cached_property + def auto_standby(self) -> AutoStandbyResourceWithStreamingResponse: + return AutoStandbyResourceWithStreamingResponse(self._instances.auto_standby) + + @cached_property + def volumes(self) -> VolumesResourceWithStreamingResponse: + return VolumesResourceWithStreamingResponse(self._instances.volumes) + + @cached_property + def snapshots(self) -> SnapshotsResourceWithStreamingResponse: + return SnapshotsResourceWithStreamingResponse(self._instances.snapshots) + + @cached_property + def snapshot_schedule(self) -> SnapshotScheduleResourceWithStreamingResponse: + return SnapshotScheduleResourceWithStreamingResponse(self._instances.snapshot_schedule) + + +class AsyncInstancesResourceWithStreamingResponse: + def __init__(self, instances: AsyncInstancesResource) -> None: + self._instances = instances + + self.create = async_to_streamed_response_wrapper( + instances.create, + ) + self.update = async_to_streamed_response_wrapper( + instances.update, + ) + self.list = async_to_streamed_response_wrapper( + instances.list, + ) + self.delete = async_to_streamed_response_wrapper( + instances.delete, + ) + self.fork = async_to_streamed_response_wrapper( + instances.fork, + ) + self.get = async_to_streamed_response_wrapper( + instances.get, + ) + self.logs = async_to_streamed_response_wrapper( + instances.logs, + ) + self.restore = async_to_streamed_response_wrapper( + instances.restore, + ) + self.standby = async_to_streamed_response_wrapper( + instances.standby, + ) + self.start = async_to_streamed_response_wrapper( + instances.start, + ) + self.stat = async_to_streamed_response_wrapper( + instances.stat, + ) + self.stats = async_to_streamed_response_wrapper( + instances.stats, + ) + self.stop = async_to_streamed_response_wrapper( + instances.stop, + ) + self.wait = async_to_streamed_response_wrapper( + instances.wait, + ) + + @cached_property + def auto_standby(self) -> AsyncAutoStandbyResourceWithStreamingResponse: + return AsyncAutoStandbyResourceWithStreamingResponse(self._instances.auto_standby) + + @cached_property + def volumes(self) -> AsyncVolumesResourceWithStreamingResponse: + return AsyncVolumesResourceWithStreamingResponse(self._instances.volumes) + + @cached_property + def snapshots(self) -> AsyncSnapshotsResourceWithStreamingResponse: + return AsyncSnapshotsResourceWithStreamingResponse(self._instances.snapshots) + + @cached_property + def snapshot_schedule(self) -> AsyncSnapshotScheduleResourceWithStreamingResponse: + return AsyncSnapshotScheduleResourceWithStreamingResponse(self._instances.snapshot_schedule) diff --git a/src/hypeman/resources/instances/snapshot_schedule.py b/src/hypeman/resources/instances/snapshot_schedule.py new file mode 100644 index 0000000..9294458 --- /dev/null +++ b/src/hypeman/resources/instances/snapshot_schedule.py @@ -0,0 +1,386 @@ +# 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 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 ..._base_client import make_request_options +from ...types.instances import snapshot_schedule_update_params +from ...types.snapshot_schedule import SnapshotSchedule +from ...types.snapshot_schedule_retention_param import SnapshotScheduleRetentionParam + +__all__ = ["SnapshotScheduleResource", "AsyncSnapshotScheduleResource"] + + +class SnapshotScheduleResource(SyncAPIResource): + @cached_property + def with_raw_response(self) -> SnapshotScheduleResourceWithRawResponse: + """ + 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/hypeman-python#accessing-raw-response-data-eg-headers + """ + return SnapshotScheduleResourceWithRawResponse(self) + + @cached_property + def with_streaming_response(self) -> SnapshotScheduleResourceWithStreamingResponse: + """ + An alternative to `.with_raw_response` that doesn't eagerly read the response body. + + For more information, see https://www.github.com/kernel/hypeman-python#with_streaming_response + """ + return SnapshotScheduleResourceWithStreamingResponse(self) + + def update( + self, + id: str, + *, + interval: str, + retention: SnapshotScheduleRetentionParam, + metadata: Dict[str, str] | Omit = omit, + name_prefix: 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, + ) -> SnapshotSchedule: + """ + Scheduled runs automatically choose snapshot behavior from current instance + state: + + - `Running` or `Standby` source: create a `Standby` snapshot. + - `Stopped` source: create a `Stopped` snapshot. For running instances, this + includes a brief pause/resume cycle during each capture. The minimum supported + interval is `1m`, but larger intervals are recommended for heavier or + latency-sensitive workloads. Updating only retention, metadata, or + `name_prefix` preserves the next scheduled run; changing `interval` + establishes a new cadence. + + Args: + interval: Snapshot interval (Go duration format, minimum 1m). + + retention: At least one of max_count or max_age must be provided. + + metadata: User-defined key-value tags. + + name_prefix: Optional prefix for auto-generated scheduled snapshot names (max 47 chars). + + extra_headers: Send extra headers + + extra_query: Add additional query parameters 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._put( + path_template("/instances/{id}/snapshot-schedule", id=id), + body=maybe_transform( + { + "interval": interval, + "retention": retention, + "metadata": metadata, + "name_prefix": name_prefix, + }, + snapshot_schedule_update_params.SnapshotScheduleUpdateParams, + ), + options=make_request_options( + extra_headers=extra_headers, extra_query=extra_query, extra_body=extra_body, timeout=timeout + ), + cast_to=SnapshotSchedule, + ) + + 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 snapshot schedule for an 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": "*/*", **(extra_headers or {})} + return self._delete( + path_template("/instances/{id}/snapshot-schedule", id=id), + options=make_request_options( + extra_headers=extra_headers, extra_query=extra_query, extra_body=extra_body, timeout=timeout + ), + cast_to=NoneType, + ) + + def get( + 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, + ) -> SnapshotSchedule: + """ + Get snapshot schedule for an 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._get( + path_template("/instances/{id}/snapshot-schedule", id=id), + options=make_request_options( + extra_headers=extra_headers, extra_query=extra_query, extra_body=extra_body, timeout=timeout + ), + cast_to=SnapshotSchedule, + ) + + +class AsyncSnapshotScheduleResource(AsyncAPIResource): + @cached_property + def with_raw_response(self) -> AsyncSnapshotScheduleResourceWithRawResponse: + """ + 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/hypeman-python#accessing-raw-response-data-eg-headers + """ + return AsyncSnapshotScheduleResourceWithRawResponse(self) + + @cached_property + def with_streaming_response(self) -> AsyncSnapshotScheduleResourceWithStreamingResponse: + """ + An alternative to `.with_raw_response` that doesn't eagerly read the response body. + + For more information, see https://www.github.com/kernel/hypeman-python#with_streaming_response + """ + return AsyncSnapshotScheduleResourceWithStreamingResponse(self) + + async def update( + self, + id: str, + *, + interval: str, + retention: SnapshotScheduleRetentionParam, + metadata: Dict[str, str] | Omit = omit, + name_prefix: 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, + ) -> SnapshotSchedule: + """ + Scheduled runs automatically choose snapshot behavior from current instance + state: + + - `Running` or `Standby` source: create a `Standby` snapshot. + - `Stopped` source: create a `Stopped` snapshot. For running instances, this + includes a brief pause/resume cycle during each capture. The minimum supported + interval is `1m`, but larger intervals are recommended for heavier or + latency-sensitive workloads. Updating only retention, metadata, or + `name_prefix` preserves the next scheduled run; changing `interval` + establishes a new cadence. + + Args: + interval: Snapshot interval (Go duration format, minimum 1m). + + retention: At least one of max_count or max_age must be provided. + + metadata: User-defined key-value tags. + + name_prefix: Optional prefix for auto-generated scheduled snapshot names (max 47 chars). + + extra_headers: Send extra headers + + extra_query: Add additional query parameters 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._put( + path_template("/instances/{id}/snapshot-schedule", id=id), + body=await async_maybe_transform( + { + "interval": interval, + "retention": retention, + "metadata": metadata, + "name_prefix": name_prefix, + }, + snapshot_schedule_update_params.SnapshotScheduleUpdateParams, + ), + options=make_request_options( + extra_headers=extra_headers, extra_query=extra_query, extra_body=extra_body, timeout=timeout + ), + cast_to=SnapshotSchedule, + ) + + 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 snapshot schedule for an 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": "*/*", **(extra_headers or {})} + return await self._delete( + path_template("/instances/{id}/snapshot-schedule", 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 get( + 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, + ) -> SnapshotSchedule: + """ + Get snapshot schedule for an 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._get( + path_template("/instances/{id}/snapshot-schedule", id=id), + options=make_request_options( + extra_headers=extra_headers, extra_query=extra_query, extra_body=extra_body, timeout=timeout + ), + cast_to=SnapshotSchedule, + ) + + +class SnapshotScheduleResourceWithRawResponse: + def __init__(self, snapshot_schedule: SnapshotScheduleResource) -> None: + self._snapshot_schedule = snapshot_schedule + + self.update = to_raw_response_wrapper( + snapshot_schedule.update, + ) + self.delete = to_raw_response_wrapper( + snapshot_schedule.delete, + ) + self.get = to_raw_response_wrapper( + snapshot_schedule.get, + ) + + +class AsyncSnapshotScheduleResourceWithRawResponse: + def __init__(self, snapshot_schedule: AsyncSnapshotScheduleResource) -> None: + self._snapshot_schedule = snapshot_schedule + + self.update = async_to_raw_response_wrapper( + snapshot_schedule.update, + ) + self.delete = async_to_raw_response_wrapper( + snapshot_schedule.delete, + ) + self.get = async_to_raw_response_wrapper( + snapshot_schedule.get, + ) + + +class SnapshotScheduleResourceWithStreamingResponse: + def __init__(self, snapshot_schedule: SnapshotScheduleResource) -> None: + self._snapshot_schedule = snapshot_schedule + + self.update = to_streamed_response_wrapper( + snapshot_schedule.update, + ) + self.delete = to_streamed_response_wrapper( + snapshot_schedule.delete, + ) + self.get = to_streamed_response_wrapper( + snapshot_schedule.get, + ) + + +class AsyncSnapshotScheduleResourceWithStreamingResponse: + def __init__(self, snapshot_schedule: AsyncSnapshotScheduleResource) -> None: + self._snapshot_schedule = snapshot_schedule + + self.update = async_to_streamed_response_wrapper( + snapshot_schedule.update, + ) + self.delete = async_to_streamed_response_wrapper( + snapshot_schedule.delete, + ) + self.get = async_to_streamed_response_wrapper( + snapshot_schedule.get, + ) diff --git a/src/hypeman/resources/instances/snapshots.py b/src/hypeman/resources/instances/snapshots.py new file mode 100644 index 0000000..0a0a75d --- /dev/null +++ b/src/hypeman/resources/instances/snapshots.py @@ -0,0 +1,338 @@ +# File generated from our OpenAPI spec by Stainless. See CONTRIBUTING.md for details. + +from __future__ import annotations + +from typing import Dict +from typing_extensions import Literal + +import httpx + +from ...types import SnapshotKind +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.instance import Instance +from ...types.snapshot import Snapshot +from ...types.instances import snapshot_create_params, snapshot_restore_params +from ...types.snapshot_kind import SnapshotKind +from ...types.shared_params.snapshot_compression_config import SnapshotCompressionConfig + +__all__ = ["SnapshotsResource", "AsyncSnapshotsResource"] + + +class SnapshotsResource(SyncAPIResource): + @cached_property + def with_raw_response(self) -> SnapshotsResourceWithRawResponse: + """ + 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/hypeman-python#accessing-raw-response-data-eg-headers + """ + return SnapshotsResourceWithRawResponse(self) + + @cached_property + def with_streaming_response(self) -> SnapshotsResourceWithStreamingResponse: + """ + An alternative to `.with_raw_response` that doesn't eagerly read the response body. + + For more information, see https://www.github.com/kernel/hypeman-python#with_streaming_response + """ + return SnapshotsResourceWithStreamingResponse(self) + + def create( + self, + id: str, + *, + kind: SnapshotKind, + compression: SnapshotCompressionConfig | Omit = omit, + name: str | 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, + ) -> Snapshot: + """ + Create a snapshot for an instance + + Args: + kind: Snapshot capture kind + + compression: Compression settings to use for this snapshot. Overrides instance and server + defaults. + + name: Optional snapshot name (lowercase letters, digits, and dashes only; cannot start + or end with a dash) + + tags: User-defined key-value tags. + + extra_headers: Send extra headers + + extra_query: Add additional query parameters 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("/instances/{id}/snapshots", id=id), + body=maybe_transform( + { + "kind": kind, + "compression": compression, + "name": name, + "tags": tags, + }, + snapshot_create_params.SnapshotCreateParams, + ), + options=make_request_options( + extra_headers=extra_headers, extra_query=extra_query, extra_body=extra_body, timeout=timeout + ), + cast_to=Snapshot, + ) + + def restore( + self, + snapshot_id: str, + *, + id: str, + target_hypervisor: Literal["cloud-hypervisor", "firecracker", "qemu", "qemu-microvm", "vz"] | Omit = omit, + target_state: Literal["Stopped", "Standby", "Running"] | 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, + ) -> Instance: + """ + Restore an instance from a snapshot in-place + + Args: + target_hypervisor: Optional hypervisor override. Allowed only when restoring from a Stopped + snapshot. Standby snapshots must restore with their original hypervisor. + + target_state: + Optional final state after restore. Defaults by snapshot kind: + + - Standby snapshot defaults to Running + - Stopped snapshot defaults to Stopped + + extra_headers: Send extra headers + + extra_query: Add additional query parameters 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 snapshot_id: + raise ValueError(f"Expected a non-empty value for `snapshot_id` but received {snapshot_id!r}") + return self._post( + path_template("/instances/{id}/snapshots/{snapshot_id}/restore", id=id, snapshot_id=snapshot_id), + body=maybe_transform( + { + "target_hypervisor": target_hypervisor, + "target_state": target_state, + }, + snapshot_restore_params.SnapshotRestoreParams, + ), + options=make_request_options( + extra_headers=extra_headers, extra_query=extra_query, extra_body=extra_body, timeout=timeout + ), + cast_to=Instance, + ) + + +class AsyncSnapshotsResource(AsyncAPIResource): + @cached_property + def with_raw_response(self) -> AsyncSnapshotsResourceWithRawResponse: + """ + 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/hypeman-python#accessing-raw-response-data-eg-headers + """ + return AsyncSnapshotsResourceWithRawResponse(self) + + @cached_property + def with_streaming_response(self) -> AsyncSnapshotsResourceWithStreamingResponse: + """ + An alternative to `.with_raw_response` that doesn't eagerly read the response body. + + For more information, see https://www.github.com/kernel/hypeman-python#with_streaming_response + """ + return AsyncSnapshotsResourceWithStreamingResponse(self) + + async def create( + self, + id: str, + *, + kind: SnapshotKind, + compression: SnapshotCompressionConfig | Omit = omit, + name: str | 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, + ) -> Snapshot: + """ + Create a snapshot for an instance + + Args: + kind: Snapshot capture kind + + compression: Compression settings to use for this snapshot. Overrides instance and server + defaults. + + name: Optional snapshot name (lowercase letters, digits, and dashes only; cannot start + or end with a dash) + + tags: User-defined key-value tags. + + extra_headers: Send extra headers + + extra_query: Add additional query parameters 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("/instances/{id}/snapshots", id=id), + body=await async_maybe_transform( + { + "kind": kind, + "compression": compression, + "name": name, + "tags": tags, + }, + snapshot_create_params.SnapshotCreateParams, + ), + options=make_request_options( + extra_headers=extra_headers, extra_query=extra_query, extra_body=extra_body, timeout=timeout + ), + cast_to=Snapshot, + ) + + async def restore( + self, + snapshot_id: str, + *, + id: str, + target_hypervisor: Literal["cloud-hypervisor", "firecracker", "qemu", "qemu-microvm", "vz"] | Omit = omit, + target_state: Literal["Stopped", "Standby", "Running"] | 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, + ) -> Instance: + """ + Restore an instance from a snapshot in-place + + Args: + target_hypervisor: Optional hypervisor override. Allowed only when restoring from a Stopped + snapshot. Standby snapshots must restore with their original hypervisor. + + target_state: + Optional final state after restore. Defaults by snapshot kind: + + - Standby snapshot defaults to Running + - Stopped snapshot defaults to Stopped + + extra_headers: Send extra headers + + extra_query: Add additional query parameters 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 snapshot_id: + raise ValueError(f"Expected a non-empty value for `snapshot_id` but received {snapshot_id!r}") + return await self._post( + path_template("/instances/{id}/snapshots/{snapshot_id}/restore", id=id, snapshot_id=snapshot_id), + body=await async_maybe_transform( + { + "target_hypervisor": target_hypervisor, + "target_state": target_state, + }, + snapshot_restore_params.SnapshotRestoreParams, + ), + options=make_request_options( + extra_headers=extra_headers, extra_query=extra_query, extra_body=extra_body, timeout=timeout + ), + cast_to=Instance, + ) + + +class SnapshotsResourceWithRawResponse: + def __init__(self, snapshots: SnapshotsResource) -> None: + self._snapshots = snapshots + + self.create = to_raw_response_wrapper( + snapshots.create, + ) + self.restore = to_raw_response_wrapper( + snapshots.restore, + ) + + +class AsyncSnapshotsResourceWithRawResponse: + def __init__(self, snapshots: AsyncSnapshotsResource) -> None: + self._snapshots = snapshots + + self.create = async_to_raw_response_wrapper( + snapshots.create, + ) + self.restore = async_to_raw_response_wrapper( + snapshots.restore, + ) + + +class SnapshotsResourceWithStreamingResponse: + def __init__(self, snapshots: SnapshotsResource) -> None: + self._snapshots = snapshots + + self.create = to_streamed_response_wrapper( + snapshots.create, + ) + self.restore = to_streamed_response_wrapper( + snapshots.restore, + ) + + +class AsyncSnapshotsResourceWithStreamingResponse: + def __init__(self, snapshots: AsyncSnapshotsResource) -> None: + self._snapshots = snapshots + + self.create = async_to_streamed_response_wrapper( + snapshots.create, + ) + self.restore = async_to_streamed_response_wrapper( + snapshots.restore, + ) diff --git a/src/hypeman/resources/instances/volumes.py b/src/hypeman/resources/instances/volumes.py new file mode 100644 index 0000000..5029df6 --- /dev/null +++ b/src/hypeman/resources/instances/volumes.py @@ -0,0 +1,281 @@ +# 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.instance import Instance +from ...types.instances import volume_attach_params + +__all__ = ["VolumesResource", "AsyncVolumesResource"] + + +class VolumesResource(SyncAPIResource): + @cached_property + def with_raw_response(self) -> VolumesResourceWithRawResponse: + """ + 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/hypeman-python#accessing-raw-response-data-eg-headers + """ + return VolumesResourceWithRawResponse(self) + + @cached_property + def with_streaming_response(self) -> VolumesResourceWithStreamingResponse: + """ + An alternative to `.with_raw_response` that doesn't eagerly read the response body. + + For more information, see https://www.github.com/kernel/hypeman-python#with_streaming_response + """ + return VolumesResourceWithStreamingResponse(self) + + def attach( + self, + volume_id: str, + *, + id: str, + mount_path: str, + readonly: 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, + ) -> Instance: + """ + Attach volume to instance + + Args: + mount_path: Path where volume should be mounted + + readonly: Mount as read-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}") + if not volume_id: + raise ValueError(f"Expected a non-empty value for `volume_id` but received {volume_id!r}") + return self._post( + path_template("/instances/{id}/volumes/{volume_id}", id=id, volume_id=volume_id), + body=maybe_transform( + { + "mount_path": mount_path, + "readonly": readonly, + }, + volume_attach_params.VolumeAttachParams, + ), + options=make_request_options( + extra_headers=extra_headers, extra_query=extra_query, extra_body=extra_body, timeout=timeout + ), + cast_to=Instance, + ) + + def detach( + self, + volume_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, + ) -> Instance: + """ + Detach volume from 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}") + if not volume_id: + raise ValueError(f"Expected a non-empty value for `volume_id` but received {volume_id!r}") + return self._delete( + path_template("/instances/{id}/volumes/{volume_id}", id=id, volume_id=volume_id), + options=make_request_options( + extra_headers=extra_headers, extra_query=extra_query, extra_body=extra_body, timeout=timeout + ), + cast_to=Instance, + ) + + +class AsyncVolumesResource(AsyncAPIResource): + @cached_property + def with_raw_response(self) -> AsyncVolumesResourceWithRawResponse: + """ + 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/hypeman-python#accessing-raw-response-data-eg-headers + """ + return AsyncVolumesResourceWithRawResponse(self) + + @cached_property + def with_streaming_response(self) -> AsyncVolumesResourceWithStreamingResponse: + """ + An alternative to `.with_raw_response` that doesn't eagerly read the response body. + + For more information, see https://www.github.com/kernel/hypeman-python#with_streaming_response + """ + return AsyncVolumesResourceWithStreamingResponse(self) + + async def attach( + self, + volume_id: str, + *, + id: str, + mount_path: str, + readonly: 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, + ) -> Instance: + """ + Attach volume to instance + + Args: + mount_path: Path where volume should be mounted + + readonly: Mount as read-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}") + if not volume_id: + raise ValueError(f"Expected a non-empty value for `volume_id` but received {volume_id!r}") + return await self._post( + path_template("/instances/{id}/volumes/{volume_id}", id=id, volume_id=volume_id), + body=await async_maybe_transform( + { + "mount_path": mount_path, + "readonly": readonly, + }, + volume_attach_params.VolumeAttachParams, + ), + options=make_request_options( + extra_headers=extra_headers, extra_query=extra_query, extra_body=extra_body, timeout=timeout + ), + cast_to=Instance, + ) + + async def detach( + self, + volume_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, + ) -> Instance: + """ + Detach volume from 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}") + if not volume_id: + raise ValueError(f"Expected a non-empty value for `volume_id` but received {volume_id!r}") + return await self._delete( + path_template("/instances/{id}/volumes/{volume_id}", id=id, volume_id=volume_id), + options=make_request_options( + extra_headers=extra_headers, extra_query=extra_query, extra_body=extra_body, timeout=timeout + ), + cast_to=Instance, + ) + + +class VolumesResourceWithRawResponse: + def __init__(self, volumes: VolumesResource) -> None: + self._volumes = volumes + + self.attach = to_raw_response_wrapper( + volumes.attach, + ) + self.detach = to_raw_response_wrapper( + volumes.detach, + ) + + +class AsyncVolumesResourceWithRawResponse: + def __init__(self, volumes: AsyncVolumesResource) -> None: + self._volumes = volumes + + self.attach = async_to_raw_response_wrapper( + volumes.attach, + ) + self.detach = async_to_raw_response_wrapper( + volumes.detach, + ) + + +class VolumesResourceWithStreamingResponse: + def __init__(self, volumes: VolumesResource) -> None: + self._volumes = volumes + + self.attach = to_streamed_response_wrapper( + volumes.attach, + ) + self.detach = to_streamed_response_wrapper( + volumes.detach, + ) + + +class AsyncVolumesResourceWithStreamingResponse: + def __init__(self, volumes: AsyncVolumesResource) -> None: + self._volumes = volumes + + self.attach = async_to_streamed_response_wrapper( + volumes.attach, + ) + self.detach = async_to_streamed_response_wrapper( + volumes.detach, + ) diff --git a/src/hypeman/resources/pushes.py b/src/hypeman/resources/pushes.py new file mode 100644 index 0000000..f8bea2e --- /dev/null +++ b/src/hypeman/resources/pushes.py @@ -0,0 +1,341 @@ +# File generated from our OpenAPI spec by Stainless. See CONTRIBUTING.md for details. + +from __future__ import annotations + +import httpx + +from ..types import push_create_params +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 ..types.push import Push +from .._base_client import make_request_options +from ..types.push_list_response import PushListResponse +from ..types.push_credentials_param import PushCredentialsParam + +__all__ = ["PushesResource", "AsyncPushesResource"] + + +class PushesResource(SyncAPIResource): + @cached_property + def with_raw_response(self) -> PushesResourceWithRawResponse: + """ + 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/hypeman-python#accessing-raw-response-data-eg-headers + """ + return PushesResourceWithRawResponse(self) + + @cached_property + def with_streaming_response(self) -> PushesResourceWithStreamingResponse: + """ + An alternative to `.with_raw_response` that doesn't eagerly read the response body. + + For more information, see https://www.github.com/kernel/hypeman-python#with_streaming_response + """ + return PushesResourceWithStreamingResponse(self) + + def create( + self, + *, + image: str, + target: str, + credentials: PushCredentialsParam | Omit = omit, + insecure: 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, + ) -> Push: + """ + Creates a push job that exports a hypeman image from the local OCI cache to a + remote registry (e.g. AWS ECR, Docker Hub). Only images in the ready state can + be pushed. + + Args: + image: Hypeman image name to push (tag or digest form) + + target: Full remote reference to push to + + credentials: Docker-style registry credentials borrowed for one image pull or push request. + They remain in memory and are never persisted or logged. When omitted or empty, + the server's own registry credentials are used. An interrupted credentialed + operation must be retried with fresh credentials. + + insecure: Allow pushing to plain-HTTP registries + + extra_headers: Send extra headers + + extra_query: Add additional query parameters 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( + "/pushes", + body=maybe_transform( + { + "image": image, + "target": target, + "credentials": credentials, + "insecure": insecure, + }, + push_create_params.PushCreateParams, + ), + options=make_request_options( + extra_headers=extra_headers, extra_query=extra_query, extra_body=extra_body, timeout=timeout + ), + cast_to=Push, + ) + + def list( + self, + *, + # Use the following arguments if you need to pass additional parameters to the API that aren't available via kwargs. + # The extra values given here take precedence over values defined on the client or passed to this method. + extra_headers: Headers | None = None, + extra_query: Query | None = None, + extra_body: Body | None = None, + timeout: float | httpx.Timeout | None | NotGiven = not_given, + ) -> PushListResponse: + """Lists outbound image push jobs, newest first.""" + return self._get( + "/pushes", + options=make_request_options( + extra_headers=extra_headers, extra_query=extra_query, extra_body=extra_body, timeout=timeout + ), + cast_to=PushListResponse, + ) + + def get( + 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, + ) -> Push: + """ + Get push details + + 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("/pushes/{id}", id=id), + options=make_request_options( + extra_headers=extra_headers, extra_query=extra_query, extra_body=extra_body, timeout=timeout + ), + cast_to=Push, + ) + + +class AsyncPushesResource(AsyncAPIResource): + @cached_property + def with_raw_response(self) -> AsyncPushesResourceWithRawResponse: + """ + 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/hypeman-python#accessing-raw-response-data-eg-headers + """ + return AsyncPushesResourceWithRawResponse(self) + + @cached_property + def with_streaming_response(self) -> AsyncPushesResourceWithStreamingResponse: + """ + An alternative to `.with_raw_response` that doesn't eagerly read the response body. + + For more information, see https://www.github.com/kernel/hypeman-python#with_streaming_response + """ + return AsyncPushesResourceWithStreamingResponse(self) + + async def create( + self, + *, + image: str, + target: str, + credentials: PushCredentialsParam | Omit = omit, + insecure: 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, + ) -> Push: + """ + Creates a push job that exports a hypeman image from the local OCI cache to a + remote registry (e.g. AWS ECR, Docker Hub). Only images in the ready state can + be pushed. + + Args: + image: Hypeman image name to push (tag or digest form) + + target: Full remote reference to push to + + credentials: Docker-style registry credentials borrowed for one image pull or push request. + They remain in memory and are never persisted or logged. When omitted or empty, + the server's own registry credentials are used. An interrupted credentialed + operation must be retried with fresh credentials. + + insecure: Allow pushing to plain-HTTP registries + + extra_headers: Send extra headers + + extra_query: Add additional query parameters 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( + "/pushes", + body=await async_maybe_transform( + { + "image": image, + "target": target, + "credentials": credentials, + "insecure": insecure, + }, + push_create_params.PushCreateParams, + ), + options=make_request_options( + extra_headers=extra_headers, extra_query=extra_query, extra_body=extra_body, timeout=timeout + ), + cast_to=Push, + ) + + async def list( + self, + *, + # Use the following arguments if you need to pass additional parameters to the API that aren't available via kwargs. + # The extra values given here take precedence over values defined on the client or passed to this method. + extra_headers: Headers | None = None, + extra_query: Query | None = None, + extra_body: Body | None = None, + timeout: float | httpx.Timeout | None | NotGiven = not_given, + ) -> PushListResponse: + """Lists outbound image push jobs, newest first.""" + return await self._get( + "/pushes", + options=make_request_options( + extra_headers=extra_headers, extra_query=extra_query, extra_body=extra_body, timeout=timeout + ), + cast_to=PushListResponse, + ) + + async def get( + 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, + ) -> Push: + """ + Get push details + + 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("/pushes/{id}", id=id), + options=make_request_options( + extra_headers=extra_headers, extra_query=extra_query, extra_body=extra_body, timeout=timeout + ), + cast_to=Push, + ) + + +class PushesResourceWithRawResponse: + def __init__(self, pushes: PushesResource) -> None: + self._pushes = pushes + + self.create = to_raw_response_wrapper( + pushes.create, + ) + self.list = to_raw_response_wrapper( + pushes.list, + ) + self.get = to_raw_response_wrapper( + pushes.get, + ) + + +class AsyncPushesResourceWithRawResponse: + def __init__(self, pushes: AsyncPushesResource) -> None: + self._pushes = pushes + + self.create = async_to_raw_response_wrapper( + pushes.create, + ) + self.list = async_to_raw_response_wrapper( + pushes.list, + ) + self.get = async_to_raw_response_wrapper( + pushes.get, + ) + + +class PushesResourceWithStreamingResponse: + def __init__(self, pushes: PushesResource) -> None: + self._pushes = pushes + + self.create = to_streamed_response_wrapper( + pushes.create, + ) + self.list = to_streamed_response_wrapper( + pushes.list, + ) + self.get = to_streamed_response_wrapper( + pushes.get, + ) + + +class AsyncPushesResourceWithStreamingResponse: + def __init__(self, pushes: AsyncPushesResource) -> None: + self._pushes = pushes + + self.create = async_to_streamed_response_wrapper( + pushes.create, + ) + self.list = async_to_streamed_response_wrapper( + pushes.list, + ) + self.get = async_to_streamed_response_wrapper( + pushes.get, + ) diff --git a/src/hypeman/resources/resources.py b/src/hypeman/resources/resources.py new file mode 100644 index 0000000..90633df --- /dev/null +++ b/src/hypeman/resources/resources.py @@ -0,0 +1,268 @@ +# File generated from our OpenAPI spec by Stainless. See CONTRIBUTING.md for details. + +from __future__ import annotations + +import httpx + +from ..types import resource_reclaim_memory_params +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.resources import Resources +from ..types.memory_reclaim_response import MemoryReclaimResponse + +__all__ = ["ResourcesResource", "AsyncResourcesResource"] + + +class ResourcesResource(SyncAPIResource): + @cached_property + def with_raw_response(self) -> ResourcesResourceWithRawResponse: + """ + 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/hypeman-python#accessing-raw-response-data-eg-headers + """ + return ResourcesResourceWithRawResponse(self) + + @cached_property + def with_streaming_response(self) -> ResourcesResourceWithStreamingResponse: + """ + An alternative to `.with_raw_response` that doesn't eagerly read the response body. + + For more information, see https://www.github.com/kernel/hypeman-python#with_streaming_response + """ + return ResourcesResourceWithStreamingResponse(self) + + def get( + self, + *, + # Use the following arguments if you need to pass additional parameters to the API that aren't available via kwargs. + # The extra values given here take precedence over values defined on the client or passed to this method. + extra_headers: Headers | None = None, + extra_query: Query | None = None, + extra_body: Body | None = None, + timeout: float | httpx.Timeout | None | NotGiven = not_given, + ) -> Resources: + """ + Returns current host resource capacity, allocation status, and per-instance + breakdown. Resources include CPU, memory, disk, and network. Oversubscription + ratios are applied to calculate effective limits. + """ + return self._get( + "/resources", + options=make_request_options( + extra_headers=extra_headers, extra_query=extra_query, extra_body=extra_body, timeout=timeout + ), + cast_to=Resources, + ) + + def reclaim_memory( + self, + *, + reclaim_bytes: int, + dry_run: bool | Omit = omit, + hold_for: str | Omit = omit, + reason: 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, + ) -> MemoryReclaimResponse: + """Requests runtime balloon inflation across reclaim-eligible guests. + + The same + planner used by host-pressure reclaim is applied, including protected floors and + per-VM step limits. + + Args: + reclaim_bytes: Total bytes of guest memory to reclaim across eligible VMs. + + dry_run: Calculate a reclaim plan without applying balloon changes or creating a hold. + + hold_for: How long to keep the reclaim hold active (Go duration string). Defaults to 5m + when omitted. + + reason: Optional operator-provided reason attached to logs and traces. + + extra_headers: Send extra headers + + extra_query: Add additional query parameters 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( + "/resources/memory/reclaim", + body=maybe_transform( + { + "reclaim_bytes": reclaim_bytes, + "dry_run": dry_run, + "hold_for": hold_for, + "reason": reason, + }, + resource_reclaim_memory_params.ResourceReclaimMemoryParams, + ), + options=make_request_options( + extra_headers=extra_headers, extra_query=extra_query, extra_body=extra_body, timeout=timeout + ), + cast_to=MemoryReclaimResponse, + ) + + +class AsyncResourcesResource(AsyncAPIResource): + @cached_property + def with_raw_response(self) -> AsyncResourcesResourceWithRawResponse: + """ + 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/hypeman-python#accessing-raw-response-data-eg-headers + """ + return AsyncResourcesResourceWithRawResponse(self) + + @cached_property + def with_streaming_response(self) -> AsyncResourcesResourceWithStreamingResponse: + """ + An alternative to `.with_raw_response` that doesn't eagerly read the response body. + + For more information, see https://www.github.com/kernel/hypeman-python#with_streaming_response + """ + return AsyncResourcesResourceWithStreamingResponse(self) + + async def get( + self, + *, + # Use the following arguments if you need to pass additional parameters to the API that aren't available via kwargs. + # The extra values given here take precedence over values defined on the client or passed to this method. + extra_headers: Headers | None = None, + extra_query: Query | None = None, + extra_body: Body | None = None, + timeout: float | httpx.Timeout | None | NotGiven = not_given, + ) -> Resources: + """ + Returns current host resource capacity, allocation status, and per-instance + breakdown. Resources include CPU, memory, disk, and network. Oversubscription + ratios are applied to calculate effective limits. + """ + return await self._get( + "/resources", + options=make_request_options( + extra_headers=extra_headers, extra_query=extra_query, extra_body=extra_body, timeout=timeout + ), + cast_to=Resources, + ) + + async def reclaim_memory( + self, + *, + reclaim_bytes: int, + dry_run: bool | Omit = omit, + hold_for: str | Omit = omit, + reason: 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, + ) -> MemoryReclaimResponse: + """Requests runtime balloon inflation across reclaim-eligible guests. + + The same + planner used by host-pressure reclaim is applied, including protected floors and + per-VM step limits. + + Args: + reclaim_bytes: Total bytes of guest memory to reclaim across eligible VMs. + + dry_run: Calculate a reclaim plan without applying balloon changes or creating a hold. + + hold_for: How long to keep the reclaim hold active (Go duration string). Defaults to 5m + when omitted. + + reason: Optional operator-provided reason attached to logs and traces. + + extra_headers: Send extra headers + + extra_query: Add additional query parameters 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( + "/resources/memory/reclaim", + body=await async_maybe_transform( + { + "reclaim_bytes": reclaim_bytes, + "dry_run": dry_run, + "hold_for": hold_for, + "reason": reason, + }, + resource_reclaim_memory_params.ResourceReclaimMemoryParams, + ), + options=make_request_options( + extra_headers=extra_headers, extra_query=extra_query, extra_body=extra_body, timeout=timeout + ), + cast_to=MemoryReclaimResponse, + ) + + +class ResourcesResourceWithRawResponse: + def __init__(self, resources: ResourcesResource) -> None: + self._resources = resources + + self.get = to_raw_response_wrapper( + resources.get, + ) + self.reclaim_memory = to_raw_response_wrapper( + resources.reclaim_memory, + ) + + +class AsyncResourcesResourceWithRawResponse: + def __init__(self, resources: AsyncResourcesResource) -> None: + self._resources = resources + + self.get = async_to_raw_response_wrapper( + resources.get, + ) + self.reclaim_memory = async_to_raw_response_wrapper( + resources.reclaim_memory, + ) + + +class ResourcesResourceWithStreamingResponse: + def __init__(self, resources: ResourcesResource) -> None: + self._resources = resources + + self.get = to_streamed_response_wrapper( + resources.get, + ) + self.reclaim_memory = to_streamed_response_wrapper( + resources.reclaim_memory, + ) + + +class AsyncResourcesResourceWithStreamingResponse: + def __init__(self, resources: AsyncResourcesResource) -> None: + self._resources = resources + + self.get = async_to_streamed_response_wrapper( + resources.get, + ) + self.reclaim_memory = async_to_streamed_response_wrapper( + resources.reclaim_memory, + ) diff --git a/src/hypeman/resources/snapshots.py b/src/hypeman/resources/snapshots.py new file mode 100644 index 0000000..e76fcbb --- /dev/null +++ b/src/hypeman/resources/snapshots.py @@ -0,0 +1,495 @@ +# File generated from our OpenAPI spec by Stainless. See CONTRIBUTING.md for details. + +from __future__ import annotations + +from typing import Dict +from typing_extensions import Literal + +import httpx + +from ..types import SnapshotKind, snapshot_fork_params, snapshot_list_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 .._base_client import make_request_options +from ..types.instance import Instance +from ..types.snapshot import Snapshot +from ..types.snapshot_kind import SnapshotKind +from ..types.snapshot_list_response import SnapshotListResponse + +__all__ = ["SnapshotsResource", "AsyncSnapshotsResource"] + + +class SnapshotsResource(SyncAPIResource): + @cached_property + def with_raw_response(self) -> SnapshotsResourceWithRawResponse: + """ + 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/hypeman-python#accessing-raw-response-data-eg-headers + """ + return SnapshotsResourceWithRawResponse(self) + + @cached_property + def with_streaming_response(self) -> SnapshotsResourceWithStreamingResponse: + """ + An alternative to `.with_raw_response` that doesn't eagerly read the response body. + + For more information, see https://www.github.com/kernel/hypeman-python#with_streaming_response + """ + return SnapshotsResourceWithStreamingResponse(self) + + def list( + self, + *, + kind: SnapshotKind | Omit = omit, + name: str | Omit = omit, + source_instance_id: str | 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, + ) -> SnapshotListResponse: + """ + List snapshots + + Args: + kind: Filter snapshots by kind + + name: Filter snapshots by snapshot name + + source_instance_id: Filter snapshots by source instance ID + + tags: Filter snapshots by tag key-value pairs. + + extra_headers: Send extra headers + + extra_query: Add additional query parameters 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( + "/snapshots", + options=make_request_options( + extra_headers=extra_headers, + extra_query=extra_query, + extra_body=extra_body, + timeout=timeout, + query=maybe_transform( + { + "kind": kind, + "name": name, + "source_instance_id": source_instance_id, + "tags": tags, + }, + snapshot_list_params.SnapshotListParams, + ), + ), + cast_to=SnapshotListResponse, + ) + + def delete( + self, + snapshot_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 snapshot + + 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 snapshot_id: + raise ValueError(f"Expected a non-empty value for `snapshot_id` but received {snapshot_id!r}") + extra_headers = {"Accept": "*/*", **(extra_headers or {})} + return self._delete( + path_template("/snapshots/{snapshot_id}", snapshot_id=snapshot_id), + options=make_request_options( + extra_headers=extra_headers, extra_query=extra_query, extra_body=extra_body, timeout=timeout + ), + cast_to=NoneType, + ) + + def fork( + self, + snapshot_id: str, + *, + name: str, + target_hypervisor: Literal["cloud-hypervisor", "firecracker", "qemu", "qemu-microvm", "vz"] | Omit = omit, + target_state: Literal["Stopped", "Standby", "Running"] | 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, + ) -> Instance: + """ + Fork a new instance from a snapshot + + Args: + name: Name for the new instance (lowercase letters, digits, and dashes only; cannot + start or end with a dash) + + target_hypervisor: Optional hypervisor override. Allowed only when forking from a Stopped snapshot. + Standby snapshots must fork with their original hypervisor. + + target_state: + Optional final state for the forked instance. Defaults by snapshot kind: + + - Standby snapshot defaults to Running + - Stopped snapshot defaults to Stopped + + extra_headers: Send extra headers + + extra_query: Add additional query parameters 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 snapshot_id: + raise ValueError(f"Expected a non-empty value for `snapshot_id` but received {snapshot_id!r}") + return self._post( + path_template("/snapshots/{snapshot_id}/fork", snapshot_id=snapshot_id), + body=maybe_transform( + { + "name": name, + "target_hypervisor": target_hypervisor, + "target_state": target_state, + }, + snapshot_fork_params.SnapshotForkParams, + ), + options=make_request_options( + extra_headers=extra_headers, extra_query=extra_query, extra_body=extra_body, timeout=timeout + ), + cast_to=Instance, + ) + + def get( + self, + snapshot_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, + ) -> Snapshot: + """ + Get snapshot details + + 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 snapshot_id: + raise ValueError(f"Expected a non-empty value for `snapshot_id` but received {snapshot_id!r}") + return self._get( + path_template("/snapshots/{snapshot_id}", snapshot_id=snapshot_id), + options=make_request_options( + extra_headers=extra_headers, extra_query=extra_query, extra_body=extra_body, timeout=timeout + ), + cast_to=Snapshot, + ) + + +class AsyncSnapshotsResource(AsyncAPIResource): + @cached_property + def with_raw_response(self) -> AsyncSnapshotsResourceWithRawResponse: + """ + 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/hypeman-python#accessing-raw-response-data-eg-headers + """ + return AsyncSnapshotsResourceWithRawResponse(self) + + @cached_property + def with_streaming_response(self) -> AsyncSnapshotsResourceWithStreamingResponse: + """ + An alternative to `.with_raw_response` that doesn't eagerly read the response body. + + For more information, see https://www.github.com/kernel/hypeman-python#with_streaming_response + """ + return AsyncSnapshotsResourceWithStreamingResponse(self) + + async def list( + self, + *, + kind: SnapshotKind | Omit = omit, + name: str | Omit = omit, + source_instance_id: str | 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, + ) -> SnapshotListResponse: + """ + List snapshots + + Args: + kind: Filter snapshots by kind + + name: Filter snapshots by snapshot name + + source_instance_id: Filter snapshots by source instance ID + + tags: Filter snapshots by tag key-value pairs. + + extra_headers: Send extra headers + + extra_query: Add additional query parameters to the request + + extra_body: Add additional JSON properties to the request + + timeout: Override the client-level default timeout for this request, in seconds + """ + return await self._get( + "/snapshots", + options=make_request_options( + extra_headers=extra_headers, + extra_query=extra_query, + extra_body=extra_body, + timeout=timeout, + query=await async_maybe_transform( + { + "kind": kind, + "name": name, + "source_instance_id": source_instance_id, + "tags": tags, + }, + snapshot_list_params.SnapshotListParams, + ), + ), + cast_to=SnapshotListResponse, + ) + + async def delete( + self, + snapshot_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 snapshot + + 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 snapshot_id: + raise ValueError(f"Expected a non-empty value for `snapshot_id` but received {snapshot_id!r}") + extra_headers = {"Accept": "*/*", **(extra_headers or {})} + return await self._delete( + path_template("/snapshots/{snapshot_id}", snapshot_id=snapshot_id), + options=make_request_options( + extra_headers=extra_headers, extra_query=extra_query, extra_body=extra_body, timeout=timeout + ), + cast_to=NoneType, + ) + + async def fork( + self, + snapshot_id: str, + *, + name: str, + target_hypervisor: Literal["cloud-hypervisor", "firecracker", "qemu", "qemu-microvm", "vz"] | Omit = omit, + target_state: Literal["Stopped", "Standby", "Running"] | 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, + ) -> Instance: + """ + Fork a new instance from a snapshot + + Args: + name: Name for the new instance (lowercase letters, digits, and dashes only; cannot + start or end with a dash) + + target_hypervisor: Optional hypervisor override. Allowed only when forking from a Stopped snapshot. + Standby snapshots must fork with their original hypervisor. + + target_state: + Optional final state for the forked instance. Defaults by snapshot kind: + + - Standby snapshot defaults to Running + - Stopped snapshot defaults to Stopped + + extra_headers: Send extra headers + + extra_query: Add additional query parameters 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 snapshot_id: + raise ValueError(f"Expected a non-empty value for `snapshot_id` but received {snapshot_id!r}") + return await self._post( + path_template("/snapshots/{snapshot_id}/fork", snapshot_id=snapshot_id), + body=await async_maybe_transform( + { + "name": name, + "target_hypervisor": target_hypervisor, + "target_state": target_state, + }, + snapshot_fork_params.SnapshotForkParams, + ), + options=make_request_options( + extra_headers=extra_headers, extra_query=extra_query, extra_body=extra_body, timeout=timeout + ), + cast_to=Instance, + ) + + async def get( + self, + snapshot_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, + ) -> Snapshot: + """ + Get snapshot details + + 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 snapshot_id: + raise ValueError(f"Expected a non-empty value for `snapshot_id` but received {snapshot_id!r}") + return await self._get( + path_template("/snapshots/{snapshot_id}", snapshot_id=snapshot_id), + options=make_request_options( + extra_headers=extra_headers, extra_query=extra_query, extra_body=extra_body, timeout=timeout + ), + cast_to=Snapshot, + ) + + +class SnapshotsResourceWithRawResponse: + def __init__(self, snapshots: SnapshotsResource) -> None: + self._snapshots = snapshots + + self.list = to_raw_response_wrapper( + snapshots.list, + ) + self.delete = to_raw_response_wrapper( + snapshots.delete, + ) + self.fork = to_raw_response_wrapper( + snapshots.fork, + ) + self.get = to_raw_response_wrapper( + snapshots.get, + ) + + +class AsyncSnapshotsResourceWithRawResponse: + def __init__(self, snapshots: AsyncSnapshotsResource) -> None: + self._snapshots = snapshots + + self.list = async_to_raw_response_wrapper( + snapshots.list, + ) + self.delete = async_to_raw_response_wrapper( + snapshots.delete, + ) + self.fork = async_to_raw_response_wrapper( + snapshots.fork, + ) + self.get = async_to_raw_response_wrapper( + snapshots.get, + ) + + +class SnapshotsResourceWithStreamingResponse: + def __init__(self, snapshots: SnapshotsResource) -> None: + self._snapshots = snapshots + + self.list = to_streamed_response_wrapper( + snapshots.list, + ) + self.delete = to_streamed_response_wrapper( + snapshots.delete, + ) + self.fork = to_streamed_response_wrapper( + snapshots.fork, + ) + self.get = to_streamed_response_wrapper( + snapshots.get, + ) + + +class AsyncSnapshotsResourceWithStreamingResponse: + def __init__(self, snapshots: AsyncSnapshotsResource) -> None: + self._snapshots = snapshots + + self.list = async_to_streamed_response_wrapper( + snapshots.list, + ) + self.delete = async_to_streamed_response_wrapper( + snapshots.delete, + ) + self.fork = async_to_streamed_response_wrapper( + snapshots.fork, + ) + self.get = async_to_streamed_response_wrapper( + snapshots.get, + ) diff --git a/src/hypeman/resources/volumes.py b/src/hypeman/resources/volumes.py new file mode 100644 index 0000000..42bf865 --- /dev/null +++ b/src/hypeman/resources/volumes.py @@ -0,0 +1,592 @@ +# File generated from our OpenAPI spec by Stainless. See CONTRIBUTING.md for details. + +from __future__ import annotations + +import os +from typing import Dict + +import httpx + +from ..types import volume_list_params, volume_create_params, volume_create_from_archive_params +from .._files import read_file_content, async_read_file_content +from .._types import ( + Body, + Omit, + Query, + Headers, + NoneType, + NotGiven, + BinaryTypes, + FileContent, + AsyncBinaryTypes, + 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.volume import Volume +from ..types.volume_list_response import VolumeListResponse + +__all__ = ["VolumesResource", "AsyncVolumesResource"] + + +class VolumesResource(SyncAPIResource): + @cached_property + def with_raw_response(self) -> VolumesResourceWithRawResponse: + """ + 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/hypeman-python#accessing-raw-response-data-eg-headers + """ + return VolumesResourceWithRawResponse(self) + + @cached_property + def with_streaming_response(self) -> VolumesResourceWithStreamingResponse: + """ + An alternative to `.with_raw_response` that doesn't eagerly read the response body. + + For more information, see https://www.github.com/kernel/hypeman-python#with_streaming_response + """ + return VolumesResourceWithStreamingResponse(self) + + def create( + self, + *, + name: str, + size_gb: int, + id: str | 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, + ) -> Volume: + """ + Creates a new empty volume of the specified size. + + Args: + name: Volume name + + size_gb: Size in gigabytes + + id: Optional custom identifier (auto-generated if not provided) + + tags: User-defined key-value tags. + + extra_headers: Send extra headers + + extra_query: Add additional query parameters 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( + "/volumes", + body=maybe_transform( + { + "name": name, + "size_gb": size_gb, + "id": id, + "tags": tags, + }, + volume_create_params.VolumeCreateParams, + ), + options=make_request_options( + extra_headers=extra_headers, extra_query=extra_query, extra_body=extra_body, timeout=timeout + ), + cast_to=Volume, + ) + + def list( + self, + *, + 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, + ) -> VolumeListResponse: + """ + List volumes + + Args: + tags: Filter volumes by tag key-value pairs. + + extra_headers: Send extra headers + + extra_query: Add additional query parameters 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( + "/volumes", + options=make_request_options( + extra_headers=extra_headers, + extra_query=extra_query, + extra_body=extra_body, + timeout=timeout, + query=maybe_transform({"tags": tags}, volume_list_params.VolumeListParams), + ), + cast_to=VolumeListResponse, + ) + + 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 volume + + 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("/volumes/{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 create_from_archive( + self, + body: FileContent | BinaryTypes, + *, + name: str, + size_gb: int, + id: str | 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, + ) -> Volume: + """Creates a new volume pre-populated with content from a tar.gz archive. + + The + archive is streamed directly into the volume's root directory. + + Args: + name: Volume name + + size_gb: Maximum size in GB (extraction fails if content exceeds this) + + id: Optional custom volume ID (auto-generated if not provided) + + tags: Tags for the created volume. + + extra_headers: Send extra headers + + extra_query: Add additional query parameters 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 = {"Content-Type": "application/gzip", **(extra_headers or {})} + return self._post( + "/volumes/from-archive", + content=read_file_content(body) if isinstance(body, os.PathLike) else body, + options=make_request_options( + extra_headers=extra_headers, + extra_query=extra_query, + extra_body=extra_body, + timeout=timeout, + query=maybe_transform( + { + "name": name, + "size_gb": size_gb, + "id": id, + "tags": tags, + }, + volume_create_from_archive_params.VolumeCreateFromArchiveParams, + ), + ), + cast_to=Volume, + ) + + def get( + 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, + ) -> Volume: + """ + Get volume details + + 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("/volumes/{id}", id=id), + options=make_request_options( + extra_headers=extra_headers, extra_query=extra_query, extra_body=extra_body, timeout=timeout + ), + cast_to=Volume, + ) + + +class AsyncVolumesResource(AsyncAPIResource): + @cached_property + def with_raw_response(self) -> AsyncVolumesResourceWithRawResponse: + """ + 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/hypeman-python#accessing-raw-response-data-eg-headers + """ + return AsyncVolumesResourceWithRawResponse(self) + + @cached_property + def with_streaming_response(self) -> AsyncVolumesResourceWithStreamingResponse: + """ + An alternative to `.with_raw_response` that doesn't eagerly read the response body. + + For more information, see https://www.github.com/kernel/hypeman-python#with_streaming_response + """ + return AsyncVolumesResourceWithStreamingResponse(self) + + async def create( + self, + *, + name: str, + size_gb: int, + id: str | 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, + ) -> Volume: + """ + Creates a new empty volume of the specified size. + + Args: + name: Volume name + + size_gb: Size in gigabytes + + id: Optional custom identifier (auto-generated if not provided) + + tags: User-defined key-value tags. + + extra_headers: Send extra headers + + extra_query: Add additional query parameters 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( + "/volumes", + body=await async_maybe_transform( + { + "name": name, + "size_gb": size_gb, + "id": id, + "tags": tags, + }, + volume_create_params.VolumeCreateParams, + ), + options=make_request_options( + extra_headers=extra_headers, extra_query=extra_query, extra_body=extra_body, timeout=timeout + ), + cast_to=Volume, + ) + + async def list( + self, + *, + 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, + ) -> VolumeListResponse: + """ + List volumes + + Args: + tags: Filter volumes by tag key-value pairs. + + extra_headers: Send extra headers + + extra_query: Add additional query parameters to the request + + extra_body: Add additional JSON properties to the request + + timeout: Override the client-level default timeout for this request, in seconds + """ + return await self._get( + "/volumes", + options=make_request_options( + extra_headers=extra_headers, + extra_query=extra_query, + extra_body=extra_body, + timeout=timeout, + query=await async_maybe_transform({"tags": tags}, volume_list_params.VolumeListParams), + ), + cast_to=VolumeListResponse, + ) + + 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 volume + + 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("/volumes/{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 create_from_archive( + self, + body: FileContent | AsyncBinaryTypes, + *, + name: str, + size_gb: int, + id: str | 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, + ) -> Volume: + """Creates a new volume pre-populated with content from a tar.gz archive. + + The + archive is streamed directly into the volume's root directory. + + Args: + name: Volume name + + size_gb: Maximum size in GB (extraction fails if content exceeds this) + + id: Optional custom volume ID (auto-generated if not provided) + + tags: Tags for the created volume. + + extra_headers: Send extra headers + + extra_query: Add additional query parameters 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 = {"Content-Type": "application/gzip", **(extra_headers or {})} + return await self._post( + "/volumes/from-archive", + content=await async_read_file_content(body) if isinstance(body, os.PathLike) else body, + options=make_request_options( + extra_headers=extra_headers, + extra_query=extra_query, + extra_body=extra_body, + timeout=timeout, + query=await async_maybe_transform( + { + "name": name, + "size_gb": size_gb, + "id": id, + "tags": tags, + }, + volume_create_from_archive_params.VolumeCreateFromArchiveParams, + ), + ), + cast_to=Volume, + ) + + async def get( + 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, + ) -> Volume: + """ + Get volume details + + 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("/volumes/{id}", id=id), + options=make_request_options( + extra_headers=extra_headers, extra_query=extra_query, extra_body=extra_body, timeout=timeout + ), + cast_to=Volume, + ) + + +class VolumesResourceWithRawResponse: + def __init__(self, volumes: VolumesResource) -> None: + self._volumes = volumes + + self.create = to_raw_response_wrapper( + volumes.create, + ) + self.list = to_raw_response_wrapper( + volumes.list, + ) + self.delete = to_raw_response_wrapper( + volumes.delete, + ) + self.create_from_archive = to_raw_response_wrapper( + volumes.create_from_archive, + ) + self.get = to_raw_response_wrapper( + volumes.get, + ) + + +class AsyncVolumesResourceWithRawResponse: + def __init__(self, volumes: AsyncVolumesResource) -> None: + self._volumes = volumes + + self.create = async_to_raw_response_wrapper( + volumes.create, + ) + self.list = async_to_raw_response_wrapper( + volumes.list, + ) + self.delete = async_to_raw_response_wrapper( + volumes.delete, + ) + self.create_from_archive = async_to_raw_response_wrapper( + volumes.create_from_archive, + ) + self.get = async_to_raw_response_wrapper( + volumes.get, + ) + + +class VolumesResourceWithStreamingResponse: + def __init__(self, volumes: VolumesResource) -> None: + self._volumes = volumes + + self.create = to_streamed_response_wrapper( + volumes.create, + ) + self.list = to_streamed_response_wrapper( + volumes.list, + ) + self.delete = to_streamed_response_wrapper( + volumes.delete, + ) + self.create_from_archive = to_streamed_response_wrapper( + volumes.create_from_archive, + ) + self.get = to_streamed_response_wrapper( + volumes.get, + ) + + +class AsyncVolumesResourceWithStreamingResponse: + def __init__(self, volumes: AsyncVolumesResource) -> None: + self._volumes = volumes + + self.create = async_to_streamed_response_wrapper( + volumes.create, + ) + self.list = async_to_streamed_response_wrapper( + volumes.list, + ) + self.delete = async_to_streamed_response_wrapper( + volumes.delete, + ) + self.create_from_archive = async_to_streamed_response_wrapper( + volumes.create_from_archive, + ) + self.get = async_to_streamed_response_wrapper( + volumes.get, + ) diff --git a/src/hypeman/types/__init__.py b/src/hypeman/types/__init__.py new file mode 100644 index 0000000..68d83c5 --- /dev/null +++ b/src/hypeman/types/__init__.py @@ -0,0 +1,110 @@ +# File generated from our OpenAPI spec by Stainless. See CONTRIBUTING.md for details. + +from __future__ import annotations + +from .push import Push as Push +from .build import Build as Build +from .image import Image as Image +from .device import Device as Device +from .shared import SnapshotCompressionConfig as SnapshotCompressionConfig +from .volume import Volume as Volume +from .builder import Builder as Builder +from .ingress import Ingress as Ingress +from .instance import Instance as Instance +from .snapshot import Snapshot as Snapshot +from .path_info import PathInfo as PathInfo +from .resources import Resources as Resources +from .build_event import BuildEvent as BuildEvent +from .device_type import DeviceType as DeviceType +from .gpu_profile import GPUProfile as GPUProfile +from .push_status import PushStatus as PushStatus +from .build_status import BuildStatus as BuildStatus +from .capabilities import Capabilities as Capabilities +from .health_check import HealthCheck as HealthCheck +from .ingress_rule import IngressRule as IngressRule +from .volume_mount import VolumeMount as VolumeMount +from .ingress_match import IngressMatch as IngressMatch +from .snapshot_kind import SnapshotKind as SnapshotKind +from .builder_status import BuilderStatus as BuilderStatus +from .disk_breakdown import DiskBreakdown as DiskBreakdown +from .ingress_target import IngressTarget as IngressTarget +from .instance_stats import InstanceStats as InstanceStats +from .restart_policy import RestartPolicy as RestartPolicy +from .restart_status import RestartStatus as RestartStatus +from .resource_status import ResourceStatus as ResourceStatus +from .snapshot_policy import SnapshotPolicy as SnapshotPolicy +from .available_device import AvailableDevice as AvailableDevice +from .build_provenance import BuildProvenance as BuildProvenance +from .health_check_tcp import HealthCheckTcp as HealthCheckTcp +from .build_list_params import BuildListParams as BuildListParams +from .capabilities_host import CapabilitiesHost as CapabilitiesHost +from .health_check_exec import HealthCheckExec as HealthCheckExec +from .health_check_http import HealthCheckHTTP as HealthCheckHTTP +from .image_list_params import ImageListParams as ImageListParams +from .snapshot_schedule import SnapshotSchedule as SnapshotSchedule +from .volume_attachment import VolumeAttachment as VolumeAttachment +from .device_list_params import DeviceListParams as DeviceListParams +from .health_check_param import HealthCheckParam as HealthCheckParam +from .ingress_rule_param import IngressRuleParam as IngressRuleParam +from .passthrough_device import PassthroughDevice as PassthroughDevice +from .push_create_params import PushCreateParams as PushCreateParams +from .push_list_response import PushListResponse as PushListResponse +from .volume_list_params import VolumeListParams as VolumeListParams +from .volume_mount_param import VolumeMountParam as VolumeMountParam +from .auto_standby_policy import AutoStandbyPolicy as AutoStandbyPolicy +from .auto_standby_status import AutoStandbyStatus as AutoStandbyStatus +from .build_create_params import BuildCreateParams as BuildCreateParams +from .build_events_params import BuildEventsParams as BuildEventsParams +from .build_list_response import BuildListResponse as BuildListResponse +from .builder_list_params import BuilderListParams as BuilderListParams +from .capabilities_images import CapabilitiesImages as CapabilitiesImages +from .capabilities_server import CapabilitiesServer as CapabilitiesServer +from .gpu_resource_status import GPUResourceStatus as GPUResourceStatus +from .image_create_params import ImageCreateParams as ImageCreateParams +from .image_list_response import ImageListResponse as ImageListResponse +from .ingress_list_params import IngressListParams as IngressListParams +from .ingress_match_param import IngressMatchParam as IngressMatchParam +from .resource_allocation import ResourceAllocation as ResourceAllocation +from .capabilities_network import CapabilitiesNetwork as CapabilitiesNetwork +from .capabilities_runtime import CapabilitiesRuntime as CapabilitiesRuntime +from .device_create_params import DeviceCreateParams as DeviceCreateParams +from .device_list_response import DeviceListResponse as DeviceListResponse +from .ingress_target_param import IngressTargetParam as IngressTargetParam +from .instance_fork_params import InstanceForkParams as InstanceForkParams +from .instance_list_params import InstanceListParams as InstanceListParams +from .instance_logs_params import InstanceLogsParams as InstanceLogsParams +from .instance_stat_params import InstanceStatParams as InstanceStatParams +from .instance_wait_params import InstanceWaitParams as InstanceWaitParams +from .restart_policy_param import RestartPolicyParam as RestartPolicyParam +from .snapshot_fork_params import SnapshotForkParams as SnapshotForkParams +from .snapshot_list_params import SnapshotListParams as SnapshotListParams +from .volume_create_params import VolumeCreateParams as VolumeCreateParams +from .volume_list_response import VolumeListResponse as VolumeListResponse +from .builder_create_params import BuilderCreateParams as BuilderCreateParams +from .builder_list_response import BuilderListResponse as BuilderListResponse +from .health_check_response import HealthCheckResponse as HealthCheckResponse +from .ingress_create_params import IngressCreateParams as IngressCreateParams +from .ingress_list_response import IngressListResponse as IngressListResponse +from .instance_start_params import InstanceStartParams as InstanceStartParams +from .memory_reclaim_action import MemoryReclaimAction as MemoryReclaimAction +from .snapshot_policy_param import SnapshotPolicyParam as SnapshotPolicyParam +from .health_check_tcp_param import HealthCheckTcpParam as HealthCheckTcpParam +from .instance_create_params import InstanceCreateParams as InstanceCreateParams +from .instance_health_status import InstanceHealthStatus as InstanceHealthStatus +from .instance_list_response import InstanceListResponse as InstanceListResponse +from .instance_logs_response import InstanceLogsResponse as InstanceLogsResponse +from .instance_update_params import InstanceUpdateParams as InstanceUpdateParams +from .push_credentials_param import PushCredentialsParam as PushCredentialsParam +from .snapshot_list_response import SnapshotListResponse as SnapshotListResponse +from .health_check_exec_param import HealthCheckExecParam as HealthCheckExecParam +from .health_check_http_param import HealthCheckHTTPParam as HealthCheckHTTPParam +from .instance_standby_params import InstanceStandbyParams as InstanceStandbyParams +from .memory_reclaim_response import MemoryReclaimResponse as MemoryReclaimResponse +from .wait_for_state_response import WaitForStateResponse as WaitForStateResponse +from .auto_standby_policy_param import AutoStandbyPolicyParam as AutoStandbyPolicyParam +from .snapshot_schedule_retention import SnapshotScheduleRetention as SnapshotScheduleRetention +from .capabilities_default_runtime import CapabilitiesDefaultRuntime as CapabilitiesDefaultRuntime +from .device_list_available_response import DeviceListAvailableResponse as DeviceListAvailableResponse +from .resource_reclaim_memory_params import ResourceReclaimMemoryParams as ResourceReclaimMemoryParams +from .snapshot_schedule_retention_param import SnapshotScheduleRetentionParam as SnapshotScheduleRetentionParam +from .volume_create_from_archive_params import VolumeCreateFromArchiveParams as VolumeCreateFromArchiveParams diff --git a/src/hypeman/types/auto_standby_policy.py b/src/hypeman/types/auto_standby_policy.py new file mode 100644 index 0000000..11375d2 --- /dev/null +++ b/src/hypeman/types/auto_standby_policy.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__ = ["AutoStandbyPolicy"] + + +class AutoStandbyPolicy(BaseModel): + """ + Linux-only automatic standby policy based on active inbound TCP connections + observed from the host conntrack table. + """ + + enabled: Optional[bool] = None + """Whether automatic standby is enabled for this instance.""" + + idle_timeout: Optional[str] = None + """ + How long the instance must have zero qualifying inbound TCP connections before + Hypeman places it into standby. + """ + + ignore_destination_ports: Optional[List[int]] = None + """Optional destination TCP ports that should not keep the instance awake.""" + + ignore_source_cidrs: Optional[List[str]] = None + """Optional client CIDRs that should not keep the instance awake.""" diff --git a/src/hypeman/types/auto_standby_policy_param.py b/src/hypeman/types/auto_standby_policy_param.py new file mode 100644 index 0000000..bb19100 --- /dev/null +++ b/src/hypeman/types/auto_standby_policy_param.py @@ -0,0 +1,32 @@ +# 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 TypedDict + +from .._types import SequenceNotStr + +__all__ = ["AutoStandbyPolicyParam"] + + +class AutoStandbyPolicyParam(TypedDict, total=False): + """ + Linux-only automatic standby policy based on active inbound TCP connections + observed from the host conntrack table. + """ + + enabled: bool + """Whether automatic standby is enabled for this instance.""" + + idle_timeout: str + """ + How long the instance must have zero qualifying inbound TCP connections before + Hypeman places it into standby. + """ + + ignore_destination_ports: Iterable[int] + """Optional destination TCP ports that should not keep the instance awake.""" + + ignore_source_cidrs: SequenceNotStr[str] + """Optional client CIDRs that should not keep the instance awake.""" diff --git a/src/hypeman/types/auto_standby_status.py b/src/hypeman/types/auto_standby_status.py new file mode 100644 index 0000000..083a316 --- /dev/null +++ b/src/hypeman/types/auto_standby_status.py @@ -0,0 +1,75 @@ +# 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__ = ["AutoStandbyStatus"] + + +class AutoStandbyStatus(BaseModel): + active_inbound_connections: int + """Number of currently tracked qualifying inbound TCP connections.""" + + configured: bool + """Whether the instance has any auto-standby policy configured.""" + + eligible: bool + """Whether the instance is currently eligible to enter standby.""" + + enabled: bool + """Whether the configured auto-standby policy is enabled.""" + + reason: Literal[ + "unsupported_platform", + "policy_missing", + "policy_disabled", + "instance_not_running", + "network_disabled", + "missing_ip", + "has_vgpu", + "active_inbound_connections", + "idle_timeout_not_elapsed", + "observer_error", + "ready_for_standby", + ] + + status: Literal[ + "unsupported", + "disabled", + "ineligible", + "active", + "idle_countdown", + "ready_for_standby", + "standby_requested", + "error", + ] + + supported: bool + """Whether the current host platform supports auto-standby diagnostics.""" + + tracking_mode: str + """Diagnostic identifier for the runtime tracking mode in use.""" + + countdown_remaining: Optional[str] = None + """Remaining time before the controller attempts standby, when applicable.""" + + hold_until: Optional[datetime] = None + """Until when auto-standby is held off, if a hold is active.""" + + idle_since: Optional[datetime] = None + """When the controller most recently observed the instance become idle.""" + + idle_timeout: Optional[str] = None + """Configured idle timeout from the auto-standby policy.""" + + last_inbound_activity_at: Optional[datetime] = None + """ + Timestamp of the most recent qualifying inbound TCP activity the controller + observed. + """ + + next_standby_at: Optional[datetime] = None + """When the controller expects to attempt standby next, if a countdown is active.""" diff --git a/src/hypeman/types/available_device.py b/src/hypeman/types/available_device.py new file mode 100644 index 0000000..e3581fe --- /dev/null +++ b/src/hypeman/types/available_device.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__ = ["AvailableDevice"] + + +class AvailableDevice(BaseModel): + device_id: str + """PCI device ID (hex)""" + + iommu_group: int + """IOMMU group number""" + + pci_address: str + """PCI address""" + + vendor_id: str + """PCI vendor ID (hex)""" + + current_driver: Optional[str] = None + """Currently bound driver (null if none)""" + + device_name: Optional[str] = None + """Human-readable device name""" + + vendor_name: Optional[str] = None + """Human-readable vendor name""" diff --git a/src/hypeman/types/build.py b/src/hypeman/types/build.py new file mode 100644 index 0000000..2813374 --- /dev/null +++ b/src/hypeman/types/build.py @@ -0,0 +1,53 @@ +# File generated from our OpenAPI spec by Stainless. See CONTRIBUTING.md for details. + +from typing import Dict, Optional +from datetime import datetime + +from .._models import BaseModel +from .build_status import BuildStatus +from .build_provenance import BuildProvenance + +__all__ = ["Build"] + + +class Build(BaseModel): + id: str + """Build job identifier""" + + created_at: datetime + """Build creation timestamp""" + + status: BuildStatus + """Build job status""" + + builder_id: Optional[str] = None + """Persistent Builder resource whose cache backed this build""" + + builder_instance_id: Optional[str] = None + """Disposable VM instance that executed this build; distinct from builder_id""" + + completed_at: Optional[datetime] = None + """Build completion timestamp""" + + duration_ms: Optional[int] = None + """Build duration in milliseconds""" + + error: Optional[str] = None + """Error message (only when status is failed)""" + + image_digest: Optional[str] = None + """Digest of built image (only when status is ready)""" + + image_ref: Optional[str] = None + """Full image reference (only when status is ready)""" + + provenance: Optional[BuildProvenance] = None + + queue_position: Optional[int] = None + """Position in build queue (only when status is queued)""" + + started_at: Optional[datetime] = None + """Build start timestamp""" + + tags: Optional[Dict[str, str]] = None + """User-defined key-value tags.""" diff --git a/src/hypeman/types/build_create_params.py b/src/hypeman/types/build_create_params.py new file mode 100644 index 0000000..b1a66d1 --- /dev/null +++ b/src/hypeman/types/build_create_params.py @@ -0,0 +1,69 @@ +# 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__ = ["BuildCreateParams"] + + +class BuildCreateParams(TypedDict, total=False): + source: Required[FileTypes] + """Source tarball (tar.gz) containing application code and optionally a Dockerfile""" + + base_image_digest: str + """Optional pinned base image digest""" + + builder_id: str + """ + Optional Builder ID whose persistent cache disk backs this build. This is the + only builder selector. One build at a time runs on a builder; builds for the + same builder are serialized. + """ + + cache_scope: str + """Tenant-specific cache key prefix""" + + cpus: int + """Number of vCPUs for builder VM (default 2)""" + + dockerfile: str + """Dockerfile content. Required if not included in the source tarball.""" + + global_cache_key: str + """ + Global cache identifier (e.g., "node", "python", "ubuntu", "browser"). When + specified, the build will import from cache/global/{key}. Admin builds will also + export to this location. + """ + + image_name: str + """Custom image name for the build output. + + When set, the image is pushed to {registry}/{image_name} instead of + {registry}/builds/{id}. + """ + + is_admin_build: str + """ + Set to "true" to grant push access to global cache (operator-only). Admin builds + can populate the shared global cache that all tenant builds read from. + """ + + memory_mb: int + """Memory limit for builder VM in MB (default 2048)""" + + secrets: str + """ + JSON array of secret references to inject during build. Each object has "id" + (required) for use with --mount=type=secret,id=... Example: [{"id": + "npm_token"}, {"id": "github_token"}] + """ + + tags: str + """JSON object of tags. Example: {"team":"backend","env":"staging"}""" + + timeout_seconds: int + """Build timeout (default 600)""" diff --git a/src/hypeman/types/build_event.py b/src/hypeman/types/build_event.py new file mode 100644 index 0000000..62d832a --- /dev/null +++ b/src/hypeman/types/build_event.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 typing_extensions import Literal + +from .._models import BaseModel +from .build_status import BuildStatus + +__all__ = ["BuildEvent"] + + +class BuildEvent(BaseModel): + timestamp: datetime + """Event timestamp""" + + type: Literal["log", "status", "heartbeat"] + """Event type""" + + content: Optional[str] = None + """Log line content (only for type=log)""" + + status: Optional[BuildStatus] = None + """New build status (only for type=status)""" diff --git a/src/hypeman/types/build_events_params.py b/src/hypeman/types/build_events_params.py new file mode 100644 index 0000000..c006a2d --- /dev/null +++ b/src/hypeman/types/build_events_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__ = ["BuildEventsParams"] + + +class BuildEventsParams(TypedDict, total=False): + follow: bool + """Continue streaming new events after initial output""" diff --git a/src/hypeman/types/build_list_params.py b/src/hypeman/types/build_list_params.py new file mode 100644 index 0000000..b71d817 --- /dev/null +++ b/src/hypeman/types/build_list_params.py @@ -0,0 +1,13 @@ +# File generated from our OpenAPI spec by Stainless. See CONTRIBUTING.md for details. + +from __future__ import annotations + +from typing import Dict +from typing_extensions import TypedDict + +__all__ = ["BuildListParams"] + + +class BuildListParams(TypedDict, total=False): + tags: Dict[str, str] + """Filter builds by tag key-value pairs.""" diff --git a/src/hypeman/types/build_list_response.py b/src/hypeman/types/build_list_response.py new file mode 100644 index 0000000..8b03ffc --- /dev/null +++ b/src/hypeman/types/build_list_response.py @@ -0,0 +1,10 @@ +# File generated from our OpenAPI spec by Stainless. See CONTRIBUTING.md for details. + +from typing import List +from typing_extensions import TypeAlias + +from .build import Build + +__all__ = ["BuildListResponse"] + +BuildListResponse: TypeAlias = List[Build] diff --git a/src/hypeman/types/build_provenance.py b/src/hypeman/types/build_provenance.py new file mode 100644 index 0000000..b3b326f --- /dev/null +++ b/src/hypeman/types/build_provenance.py @@ -0,0 +1,25 @@ +# File generated from our OpenAPI spec by Stainless. See CONTRIBUTING.md for details. + +from typing import Dict, Optional +from datetime import datetime + +from .._models import BaseModel + +__all__ = ["BuildProvenance"] + + +class BuildProvenance(BaseModel): + base_image_digest: Optional[str] = None + """Pinned base image digest used""" + + buildkit_version: Optional[str] = None + """BuildKit version used""" + + lockfile_hashes: Optional[Dict[str, str]] = None + """Map of lockfile names to SHA256 hashes""" + + source_hash: Optional[str] = None + """SHA256 hash of source tarball""" + + timestamp: Optional[datetime] = None + """Build completion timestamp""" diff --git a/src/hypeman/types/build_status.py b/src/hypeman/types/build_status.py new file mode 100644 index 0000000..7e1e163 --- /dev/null +++ b/src/hypeman/types/build_status.py @@ -0,0 +1,7 @@ +# File generated from our OpenAPI spec by Stainless. See CONTRIBUTING.md for details. + +from typing_extensions import Literal, TypeAlias + +__all__ = ["BuildStatus"] + +BuildStatus: TypeAlias = Literal["queued", "building", "pushing", "ready", "failed", "cancelled"] diff --git a/src/hypeman/types/builder.py b/src/hypeman/types/builder.py new file mode 100644 index 0000000..d4f4a80 --- /dev/null +++ b/src/hypeman/types/builder.py @@ -0,0 +1,44 @@ +# 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 .builder_status import BuilderStatus + +__all__ = ["Builder"] + + +class Builder(BaseModel): + id: str + """Builder identifier""" + + created_at: datetime + """Creation timestamp (RFC3339)""" + + disk_size_gb: int + """Persistent builder cache disk size in gigabytes. + + Cannot be changed after creation. + """ + + max_concurrency: int + """Maximum concurrent builds on this builder. Currently fixed at 1.""" + + queued_builds: List[str] + """Point-in-time IDs of queued builds waiting for this builder, oldest first""" + + status: BuilderStatus + """Builder lifecycle status""" + + active_build_id: Optional[str] = None + """Point-in-time ID of the build currently running on this builder""" + + last_used_at: Optional[datetime] = None + """When a build last ran on this builder""" + + name: Optional[str] = None + """Optional non-unique display name""" + + tags: Optional[Dict[str, str]] = None + """User-defined key-value tags.""" diff --git a/src/hypeman/types/builder_create_params.py b/src/hypeman/types/builder_create_params.py new file mode 100644 index 0000000..f13b58b --- /dev/null +++ b/src/hypeman/types/builder_create_params.py @@ -0,0 +1,22 @@ +# 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__ = ["BuilderCreateParams"] + + +class BuilderCreateParams(TypedDict, total=False): + id: str + """Optional caller-supplied identifier, auto-generated if not provided""" + + disk_size_gb: int + """Cache disk size in gigabytes. Omit to use the server default.""" + + name: str + """Optional non-unique display name""" + + tags: Dict[str, str] + """User-defined key-value tags.""" diff --git a/src/hypeman/types/builder_list_params.py b/src/hypeman/types/builder_list_params.py new file mode 100644 index 0000000..3174b58 --- /dev/null +++ b/src/hypeman/types/builder_list_params.py @@ -0,0 +1,13 @@ +# File generated from our OpenAPI spec by Stainless. See CONTRIBUTING.md for details. + +from __future__ import annotations + +from typing import Dict +from typing_extensions import TypedDict + +__all__ = ["BuilderListParams"] + + +class BuilderListParams(TypedDict, total=False): + tags: Dict[str, str] + """Filter builders by tag key-value pairs.""" diff --git a/src/hypeman/types/builder_list_response.py b/src/hypeman/types/builder_list_response.py new file mode 100644 index 0000000..dd62394 --- /dev/null +++ b/src/hypeman/types/builder_list_response.py @@ -0,0 +1,10 @@ +# File generated from our OpenAPI spec by Stainless. See CONTRIBUTING.md for details. + +from typing import List +from typing_extensions import TypeAlias + +from .builder import Builder + +__all__ = ["BuilderListResponse"] + +BuilderListResponse: TypeAlias = List[Builder] diff --git a/src/hypeman/types/builder_status.py b/src/hypeman/types/builder_status.py new file mode 100644 index 0000000..ac09ffc --- /dev/null +++ b/src/hypeman/types/builder_status.py @@ -0,0 +1,7 @@ +# File generated from our OpenAPI spec by Stainless. See CONTRIBUTING.md for details. + +from typing_extensions import Literal, TypeAlias + +__all__ = ["BuilderStatus"] + +BuilderStatus: TypeAlias = Literal["ready", "pruning", "deleting", "error"] diff --git a/src/hypeman/types/capabilities.py b/src/hypeman/types/capabilities.py new file mode 100644 index 0000000..2579323 --- /dev/null +++ b/src/hypeman/types/capabilities.py @@ -0,0 +1,45 @@ +# File generated from our OpenAPI spec by Stainless. See CONTRIBUTING.md for details. + +from typing import List + +from .._models import BaseModel +from .capabilities_host import CapabilitiesHost +from .capabilities_images import CapabilitiesImages +from .capabilities_server import CapabilitiesServer +from .capabilities_network import CapabilitiesNetwork +from .capabilities_runtime import CapabilitiesRuntime +from .capabilities_default_runtime import CapabilitiesDefaultRuntime + +__all__ = ["Capabilities"] + + +class Capabilities(BaseModel): + default_runtime: CapabilitiesDefaultRuntime + + features: List[str] + """ + Stable server-level feature IDs: API surfaces this server exposes regardless of + which runtime backs an instance. Always present: "instances", "images", + "builds", "volumes", "ingress", "exec", "logs". Host-conditional: "devices" + (device passthrough management, Linux hosts only) and "rosetta-emulation" (Apple + Silicon macOS hosts with Rosetta currently installed, per the same availability + probe launches enforce). Per-runtime features are reported under each runtimes[] + entry. + """ + + host: CapabilitiesHost + + images: CapabilitiesImages + + network: CapabilitiesNetwork + + runtimes: List[CapabilitiesRuntime] + """ + Every runtime this server build supports on this host platform, each with its + own availability flag and feature IDs. Hosts commonly support several runtimes + at once (for example cloud-hypervisor, firecracker, qemu, and qemu-microvm on + linux/amd64). A listed runtime is only launchable when its "available" flag is + true. Entries are sorted by name. + """ + + server: CapabilitiesServer diff --git a/src/hypeman/types/capabilities_default_runtime.py b/src/hypeman/types/capabilities_default_runtime.py new file mode 100644 index 0000000..73dd0b4 --- /dev/null +++ b/src/hypeman/types/capabilities_default_runtime.py @@ -0,0 +1,19 @@ +# File generated from our OpenAPI spec by Stainless. See CONTRIBUTING.md for details. + +from .._models import BaseModel + +__all__ = ["CapabilitiesDefaultRuntime"] + + +class CapabilitiesDefaultRuntime(BaseModel): + available: bool + """ + Whether the default runtime can launch on this host: it appears in runtimes and + its launch prerequisites are met (matches that entry's "available"). When false, + launches that rely on the default will fail until the server is reconfigured + with an available runtime or the missing prerequisite (for example the QEMU + system binary) is installed. + """ + + name: str + """Runtime used for launches that do not name one""" diff --git a/src/hypeman/types/capabilities_host.py b/src/hypeman/types/capabilities_host.py new file mode 100644 index 0000000..10d3c6b --- /dev/null +++ b/src/hypeman/types/capabilities_host.py @@ -0,0 +1,13 @@ +# File generated from our OpenAPI spec by Stainless. See CONTRIBUTING.md for details. + +from .._models import BaseModel + +__all__ = ["CapabilitiesHost"] + + +class CapabilitiesHost(BaseModel): + arch: str + """Host CPU architecture""" + + os: str + """Host operating system""" diff --git a/src/hypeman/types/capabilities_images.py b/src/hypeman/types/capabilities_images.py new file mode 100644 index 0000000..d3274bd --- /dev/null +++ b/src/hypeman/types/capabilities_images.py @@ -0,0 +1,21 @@ +# File generated from our OpenAPI spec by Stainless. See CONTRIBUTING.md for details. + +from typing import List + +from .._models import BaseModel + +__all__ = ["CapabilitiesImages"] + + +class CapabilitiesImages(BaseModel): + default_platform: str + """Image platform selected when a create request omits one""" + + platforms: List[str] + """Image platforms (os/arch) this host can run. + + On Apple Silicon macOS this includes linux/amd64 only when Rosetta is currently + installed — probed via the same Virtualization.framework availability check + launches enforce — so a listed platform is launchable right now. Install Rosetta + (softwareupdate --install-rosetta) to enable it. + """ diff --git a/src/hypeman/types/capabilities_network.py b/src/hypeman/types/capabilities_network.py new file mode 100644 index 0000000..e0d0553 --- /dev/null +++ b/src/hypeman/types/capabilities_network.py @@ -0,0 +1,30 @@ +# 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__ = ["CapabilitiesNetwork"] + + +class CapabilitiesNetwork(BaseModel): + guest_to_guest: bool + """Whether direct VM-to-VM traffic is permitted on the default network""" + + model: Literal["bridge", "nat"] + """Guest networking model. + + "bridge" is a Linux bridge with per-VM TAP devices; "nat" is hypervisor-provided + NAT (macOS). + """ + + gateway: Optional[str] = None + """Guest-visible host gateway IP. + + Guests reach host services (including host ingress) through this address. + Omitted when no default network has been resolved on this host yet. + """ + + subnet: Optional[str] = None + """Guest subnet CIDR""" diff --git a/src/hypeman/types/capabilities_runtime.py b/src/hypeman/types/capabilities_runtime.py new file mode 100644 index 0000000..7b0838e --- /dev/null +++ b/src/hypeman/types/capabilities_runtime.py @@ -0,0 +1,37 @@ +# File generated from our OpenAPI spec by Stainless. See CONTRIBUTING.md for details. + +from typing import List + +from .._models import BaseModel + +__all__ = ["CapabilitiesRuntime"] + + +class CapabilitiesRuntime(BaseModel): + available: bool + """Whether this runtime's launch prerequisites are currently met on this host. + + Listed runtimes are supported by this server build on this platform; + available=false means a host prerequisite is missing (for example qemu requires + a runnable system-installed QEMU binary and the host vhost-vsock device) and + launches naming this runtime will fail until it is installed. + """ + + features: List[str] + """ + Stable feature IDs supported by this runtime on this host: "snapshots" + (snapshot/restore), "standby" (pause + memory snapshot, with later restore), + "fork" (clone an instance from a stopped source; forking a standby or running + source restores/creates snapshots and additionally requires "standby"), "pause" + (pause/resume), "hotplug-memory" (live memory resize), "balloon-control" + (runtime balloon target changes), "vsock" (guest vsock communication), + "gpu-passthrough" (GPU/PCI device passthrough), "disk-io-limit" (disk I/O rate + limiting), "disk-resize" (live disk resize). Values are host- and + configuration-truthful: vz omits snapshots and standby on macOS 13, which lacks + Virtualization.framework VM save/restore, while still advertising fork + (stopped-source clones need no save/restore there), and cloud-hypervisor reports + "disk-resize" only when the configured default version supports it. + """ + + name: str + """Runtime identifier""" diff --git a/src/hypeman/types/capabilities_server.py b/src/hypeman/types/capabilities_server.py new file mode 100644 index 0000000..4482da0 --- /dev/null +++ b/src/hypeman/types/capabilities_server.py @@ -0,0 +1,16 @@ +# File generated from our OpenAPI spec by Stainless. See CONTRIBUTING.md for details. + +from .._models import BaseModel + +__all__ = ["CapabilitiesServer"] + + +class CapabilitiesServer(BaseModel): + api_version: str + """API contract version (matches the OpenAPI document info version)""" + + version: str + """ + Server build version (short git revision, with "-dirty" suffix for uncommitted + builds, or "unknown") + """ diff --git a/src/hypeman/types/device.py b/src/hypeman/types/device.py new file mode 100644 index 0000000..05f6bdc --- /dev/null +++ b/src/hypeman/types/device.py @@ -0,0 +1,52 @@ +# File generated from our OpenAPI spec by Stainless. See CONTRIBUTING.md for details. + +from typing import Dict, Optional +from datetime import datetime + +from .._models import BaseModel +from .device_type import DeviceType + +__all__ = ["Device"] + + +class Device(BaseModel): + id: str + """Auto-generated unique identifier (CUID2 format)""" + + bound_to_vfio: bool + """ + Whether the device is currently bound to the vfio-pci driver, which is required + for VM passthrough. + + - true: Device is bound to vfio-pci and ready for (or currently in use by) a VM. + The device's native driver has been unloaded. + - false: Device is using its native driver (e.g., nvidia) or no driver. Hypeman + will automatically bind to vfio-pci when attaching to an instance. + """ + + created_at: datetime + """Registration timestamp (RFC3339)""" + + device_id: str + """PCI device ID (hex)""" + + iommu_group: int + """IOMMU group number""" + + pci_address: str + """PCI address""" + + type: DeviceType + """Type of PCI device""" + + vendor_id: str + """PCI vendor ID (hex)""" + + attached_to: Optional[str] = None + """Instance ID if attached""" + + name: Optional[str] = None + """Device name (user-provided or auto-generated from PCI address)""" + + tags: Optional[Dict[str, str]] = None + """User-defined key-value tags.""" diff --git a/src/hypeman/types/device_create_params.py b/src/hypeman/types/device_create_params.py new file mode 100644 index 0000000..2bfa4a4 --- /dev/null +++ b/src/hypeman/types/device_create_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 Dict +from typing_extensions import Required, TypedDict + +__all__ = ["DeviceCreateParams"] + + +class DeviceCreateParams(TypedDict, total=False): + pci_address: Required[str] + """PCI address of the device (required, e.g., "0000:a2:00.0")""" + + name: str + """Optional globally unique device name. + + If not provided, a name is auto-generated from the PCI address (e.g., + "pci-0000-a2-00-0") + """ + + tags: Dict[str, str] + """User-defined key-value tags.""" diff --git a/src/hypeman/types/device_list_available_response.py b/src/hypeman/types/device_list_available_response.py new file mode 100644 index 0000000..dcebd39 --- /dev/null +++ b/src/hypeman/types/device_list_available_response.py @@ -0,0 +1,10 @@ +# File generated from our OpenAPI spec by Stainless. See CONTRIBUTING.md for details. + +from typing import List +from typing_extensions import TypeAlias + +from .available_device import AvailableDevice + +__all__ = ["DeviceListAvailableResponse"] + +DeviceListAvailableResponse: TypeAlias = List[AvailableDevice] diff --git a/src/hypeman/types/device_list_params.py b/src/hypeman/types/device_list_params.py new file mode 100644 index 0000000..45f5645 --- /dev/null +++ b/src/hypeman/types/device_list_params.py @@ -0,0 +1,13 @@ +# File generated from our OpenAPI spec by Stainless. See CONTRIBUTING.md for details. + +from __future__ import annotations + +from typing import Dict +from typing_extensions import TypedDict + +__all__ = ["DeviceListParams"] + + +class DeviceListParams(TypedDict, total=False): + tags: Dict[str, str] + """Filter devices by tag key-value pairs.""" diff --git a/src/hypeman/types/device_list_response.py b/src/hypeman/types/device_list_response.py new file mode 100644 index 0000000..85bf0de --- /dev/null +++ b/src/hypeman/types/device_list_response.py @@ -0,0 +1,10 @@ +# File generated from our OpenAPI spec by Stainless. See CONTRIBUTING.md for details. + +from typing import List +from typing_extensions import TypeAlias + +from .device import Device + +__all__ = ["DeviceListResponse"] + +DeviceListResponse: TypeAlias = List[Device] diff --git a/src/hypeman/types/device_type.py b/src/hypeman/types/device_type.py new file mode 100644 index 0000000..56862f6 --- /dev/null +++ b/src/hypeman/types/device_type.py @@ -0,0 +1,7 @@ +# File generated from our OpenAPI spec by Stainless. See CONTRIBUTING.md for details. + +from typing_extensions import Literal, TypeAlias + +__all__ = ["DeviceType"] + +DeviceType: TypeAlias = Literal["gpu", "pci"] diff --git a/src/hypeman/types/disk_breakdown.py b/src/hypeman/types/disk_breakdown.py new file mode 100644 index 0000000..bf3f5ec --- /dev/null +++ b/src/hypeman/types/disk_breakdown.py @@ -0,0 +1,21 @@ +# File generated from our OpenAPI spec by Stainless. See CONTRIBUTING.md for details. + +from typing import Optional + +from .._models import BaseModel + +__all__ = ["DiskBreakdown"] + + +class DiskBreakdown(BaseModel): + images_bytes: Optional[int] = None + """Disk used by exported rootfs images""" + + oci_cache_bytes: Optional[int] = None + """Disk used by OCI layer cache (shared blobs)""" + + overlays_bytes: Optional[int] = None + """Disk used by instance overlays (rootfs + volume overlays)""" + + volumes_bytes: Optional[int] = None + """Disk used by volumes""" diff --git a/src/hypeman/types/gpu_profile.py b/src/hypeman/types/gpu_profile.py new file mode 100644 index 0000000..109beb3 --- /dev/null +++ b/src/hypeman/types/gpu_profile.py @@ -0,0 +1,18 @@ +# File generated from our OpenAPI spec by Stainless. See CONTRIBUTING.md for details. + +from .._models import BaseModel + +__all__ = ["GPUProfile"] + + +class GPUProfile(BaseModel): + """Available vGPU profile""" + + available: int + """Number of instances that can be created with this profile""" + + framebuffer_mb: int + """Frame buffer size in MB""" + + name: str + """Profile name (user-facing)""" diff --git a/src/hypeman/types/gpu_resource_status.py b/src/hypeman/types/gpu_resource_status.py new file mode 100644 index 0000000..e3e9b26 --- /dev/null +++ b/src/hypeman/types/gpu_resource_status.py @@ -0,0 +1,29 @@ +# 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 .gpu_profile import GPUProfile +from .passthrough_device import PassthroughDevice + +__all__ = ["GPUResourceStatus"] + + +class GPUResourceStatus(BaseModel): + """GPU resource status. Null if no GPUs available.""" + + mode: Literal["vgpu", "passthrough"] + """GPU mode (vgpu for SR-IOV/mdev, passthrough for whole GPU)""" + + total_slots: int + """Total slots (VFs for vGPU, physical GPUs for passthrough)""" + + used_slots: int + """Slots currently in use""" + + devices: Optional[List[PassthroughDevice]] = None + """Physical GPUs (only in passthrough mode)""" + + profiles: Optional[List[GPUProfile]] = None + """Available vGPU profiles (only in vGPU mode)""" diff --git a/src/hypeman/types/health_check.py b/src/hypeman/types/health_check.py new file mode 100644 index 0000000..f7005e8 --- /dev/null +++ b/src/hypeman/types/health_check.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 .health_check_tcp import HealthCheckTcp +from .health_check_exec import HealthCheckExec +from .health_check_http import HealthCheckHTTP + +__all__ = ["HealthCheck"] + + +class HealthCheck(BaseModel): + """Workload health check policy. + + Health is reported separately from instance lifecycle state. + """ + + exec: Optional[HealthCheckExec] = None + + failure_threshold: Optional[int] = None + """Consecutive failed checks required to mark the workload unhealthy.""" + + http: Optional[HealthCheckHTTP] = None + + interval: Optional[str] = None + """Delay between checks as a Go duration.""" + + start_period: Optional[str] = None + """Startup grace period before failures can mark the workload unhealthy.""" + + success_threshold: Optional[int] = None + """Consecutive successful checks required to mark the workload healthy.""" + + tcp: Optional[HealthCheckTcp] = None + + timeout: Optional[str] = None + """Per-check timeout as a Go duration.""" + + type: Optional[Literal["none", "http", "tcp", "exec"]] = None + """Probe type. Omit health_check or set type=none to disable health checks.""" diff --git a/src/hypeman/types/health_check_exec.py b/src/hypeman/types/health_check_exec.py new file mode 100644 index 0000000..7f16a4c --- /dev/null +++ b/src/hypeman/types/health_check_exec.py @@ -0,0 +1,15 @@ +# File generated from our OpenAPI spec by Stainless. See CONTRIBUTING.md for details. + +from typing import List, Optional + +from .._models import BaseModel + +__all__ = ["HealthCheckExec"] + + +class HealthCheckExec(BaseModel): + command: List[str] + """Command and arguments to run inside the guest after guest-agent readiness.""" + + working_dir: Optional[str] = None + """Optional working directory for the command.""" diff --git a/src/hypeman/types/health_check_exec_param.py b/src/hypeman/types/health_check_exec_param.py new file mode 100644 index 0000000..21b3044 --- /dev/null +++ b/src/hypeman/types/health_check_exec_param.py @@ -0,0 +1,17 @@ +# File generated from our OpenAPI spec by Stainless. See CONTRIBUTING.md for details. + +from __future__ import annotations + +from typing_extensions import Required, TypedDict + +from .._types import SequenceNotStr + +__all__ = ["HealthCheckExecParam"] + + +class HealthCheckExecParam(TypedDict, total=False): + command: Required[SequenceNotStr[str]] + """Command and arguments to run inside the guest after guest-agent readiness.""" + + working_dir: str + """Optional working directory for the command.""" diff --git a/src/hypeman/types/health_check_http.py b/src/hypeman/types/health_check_http.py new file mode 100644 index 0000000..089e5ca --- /dev/null +++ b/src/hypeman/types/health_check_http.py @@ -0,0 +1,22 @@ +# 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__ = ["HealthCheckHTTP"] + + +class HealthCheckHTTP(BaseModel): + port: int + """Port to probe on the instance network address.""" + + expected_status: Optional[int] = None + """Exact status code required for a successful probe.""" + + path: Optional[str] = None + """HTTP path to request.""" + + scheme: Optional[Literal["http", "https"]] = None + """HTTP scheme to use for the probe.""" diff --git a/src/hypeman/types/health_check_http_param.py b/src/hypeman/types/health_check_http_param.py new file mode 100644 index 0000000..cc7ae35 --- /dev/null +++ b/src/hypeman/types/health_check_http_param.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__ = ["HealthCheckHTTPParam"] + + +class HealthCheckHTTPParam(TypedDict, total=False): + port: Required[int] + """Port to probe on the instance network address.""" + + expected_status: int + """Exact status code required for a successful probe.""" + + path: str + """HTTP path to request.""" + + scheme: Literal["http", "https"] + """HTTP scheme to use for the probe.""" diff --git a/src/hypeman/types/health_check_param.py b/src/hypeman/types/health_check_param.py new file mode 100644 index 0000000..cf4affe --- /dev/null +++ b/src/hypeman/types/health_check_param.py @@ -0,0 +1,42 @@ +# File generated from our OpenAPI spec by Stainless. See CONTRIBUTING.md for details. + +from __future__ import annotations + +from typing_extensions import Literal, TypedDict + +from .health_check_tcp_param import HealthCheckTcpParam +from .health_check_exec_param import HealthCheckExecParam +from .health_check_http_param import HealthCheckHTTPParam + +__all__ = ["HealthCheckParam"] + + +class HealthCheckParam(TypedDict, total=False): + """Workload health check policy. + + Health is reported separately from instance lifecycle state. + """ + + exec: HealthCheckExecParam + + failure_threshold: int + """Consecutive failed checks required to mark the workload unhealthy.""" + + http: HealthCheckHTTPParam + + interval: str + """Delay between checks as a Go duration.""" + + start_period: str + """Startup grace period before failures can mark the workload unhealthy.""" + + success_threshold: int + """Consecutive successful checks required to mark the workload healthy.""" + + tcp: HealthCheckTcpParam + + timeout: str + """Per-check timeout as a Go duration.""" + + type: Literal["none", "http", "tcp", "exec"] + """Probe type. Omit health_check or set type=none to disable health checks.""" diff --git a/src/hypeman/types/health_check_response.py b/src/hypeman/types/health_check_response.py new file mode 100644 index 0000000..388ccda --- /dev/null +++ b/src/hypeman/types/health_check_response.py @@ -0,0 +1,11 @@ +# File generated from our OpenAPI spec by Stainless. See CONTRIBUTING.md for details. + +from typing_extensions import Literal + +from .._models import BaseModel + +__all__ = ["HealthCheckResponse"] + + +class HealthCheckResponse(BaseModel): + status: Literal["ok"] diff --git a/src/hypeman/types/health_check_tcp.py b/src/hypeman/types/health_check_tcp.py new file mode 100644 index 0000000..3854932 --- /dev/null +++ b/src/hypeman/types/health_check_tcp.py @@ -0,0 +1,10 @@ +# File generated from our OpenAPI spec by Stainless. See CONTRIBUTING.md for details. + +from .._models import BaseModel + +__all__ = ["HealthCheckTcp"] + + +class HealthCheckTcp(BaseModel): + port: int + """Port to open on the instance network address.""" diff --git a/src/hypeman/types/health_check_tcp_param.py b/src/hypeman/types/health_check_tcp_param.py new file mode 100644 index 0000000..2cd3b96 --- /dev/null +++ b/src/hypeman/types/health_check_tcp_param.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__ = ["HealthCheckTcpParam"] + + +class HealthCheckTcpParam(TypedDict, total=False): + port: Required[int] + """Port to open on the instance network address.""" diff --git a/src/hypeman/types/image.py b/src/hypeman/types/image.py new file mode 100644 index 0000000..57d4667 --- /dev/null +++ b/src/hypeman/types/image.py @@ -0,0 +1,50 @@ +# File generated from our OpenAPI spec by Stainless. See CONTRIBUTING.md for details. + +from typing import Dict, List, Optional +from datetime import datetime +from typing_extensions import Literal + +from .._models import BaseModel + +__all__ = ["Image"] + + +class Image(BaseModel): + created_at: datetime + """Creation timestamp (RFC3339)""" + + digest: str + """Resolved manifest digest""" + + name: str + """Normalized OCI image reference (tag or digest)""" + + status: Literal["pending", "pulling", "converting", "ready", "failed"] + """Build status""" + + cmd: Optional[List[str]] = None + """CMD from container metadata""" + + entrypoint: Optional[List[str]] = None + """Entrypoint from container metadata""" + + env: Optional[Dict[str, str]] = None + """Environment variables from container metadata""" + + error: Optional[str] = None + """Error message if status is failed""" + + platform: Optional[str] = None + """Resolved image platform as os/arch[/variant] (e.g. "linux/amd64")""" + + queue_position: Optional[int] = None + """Position in build queue (null if not queued)""" + + size_bytes: Optional[int] = None + """Disk size in bytes (null until ready)""" + + tags: Optional[Dict[str, str]] = None + """User-defined key-value tags.""" + + working_dir: Optional[str] = None + """Working directory from container metadata""" diff --git a/src/hypeman/types/image_create_params.py b/src/hypeman/types/image_create_params.py new file mode 100644 index 0000000..b5124cc --- /dev/null +++ b/src/hypeman/types/image_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 import Dict +from typing_extensions import Required, TypedDict + +from .push_credentials_param import PushCredentialsParam + +__all__ = ["ImageCreateParams"] + + +class ImageCreateParams(TypedDict, total=False): + name: Required[str] + """OCI image reference (e.g., docker.io/library/nginx:latest)""" + + credentials: PushCredentialsParam + """Docker-style registry credentials borrowed for one image pull or push request. + + They remain in memory and are never persisted or logged. When omitted or empty, + the server's own registry credentials are used. An interrupted credentialed + operation must be retried with fresh credentials. + """ + + platform: str + """Target platform as os/arch[/variant] (e.g. + + "linux/amd64"), matching Docker --platform. Omit for the host platform. Not a + fixed enum: the os/arch[/variant] grammar is validated server-side and invalid + values return 400 invalid_platform. Only os "linux" with arch amd64 or arm64 is + accepted today. + """ + + tags: Dict[str, str] + """User-defined key-value tags.""" diff --git a/src/hypeman/types/image_list_params.py b/src/hypeman/types/image_list_params.py new file mode 100644 index 0000000..6f4cde7 --- /dev/null +++ b/src/hypeman/types/image_list_params.py @@ -0,0 +1,13 @@ +# File generated from our OpenAPI spec by Stainless. See CONTRIBUTING.md for details. + +from __future__ import annotations + +from typing import Dict +from typing_extensions import TypedDict + +__all__ = ["ImageListParams"] + + +class ImageListParams(TypedDict, total=False): + tags: Dict[str, str] + """Filter images by tag key-value pairs.""" diff --git a/src/hypeman/types/image_list_response.py b/src/hypeman/types/image_list_response.py new file mode 100644 index 0000000..df7d863 --- /dev/null +++ b/src/hypeman/types/image_list_response.py @@ -0,0 +1,10 @@ +# File generated from our OpenAPI spec by Stainless. See CONTRIBUTING.md for details. + +from typing import List +from typing_extensions import TypeAlias + +from .image import Image + +__all__ = ["ImageListResponse"] + +ImageListResponse: TypeAlias = List[Image] diff --git a/src/hypeman/types/ingress.py b/src/hypeman/types/ingress.py new file mode 100644 index 0000000..7d893cc --- /dev/null +++ b/src/hypeman/types/ingress.py @@ -0,0 +1,26 @@ +# 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 .ingress_rule import IngressRule + +__all__ = ["Ingress"] + + +class Ingress(BaseModel): + id: str + """Auto-generated unique identifier""" + + created_at: datetime + """Creation timestamp (RFC3339)""" + + name: str + """Human-readable name""" + + rules: List[IngressRule] + """Routing rules for this ingress""" + + tags: Optional[Dict[str, str]] = None + """User-defined key-value tags.""" diff --git a/src/hypeman/types/ingress_create_params.py b/src/hypeman/types/ingress_create_params.py new file mode 100644 index 0000000..67ba7df --- /dev/null +++ b/src/hypeman/types/ingress_create_params.py @@ -0,0 +1,24 @@ +# File generated from our OpenAPI spec by Stainless. See CONTRIBUTING.md for details. + +from __future__ import annotations + +from typing import Dict, Iterable +from typing_extensions import Required, TypedDict + +from .ingress_rule_param import IngressRuleParam + +__all__ = ["IngressCreateParams"] + + +class IngressCreateParams(TypedDict, total=False): + name: Required[str] + """ + Human-readable name (lowercase letters, digits, and dashes only; cannot start or + end with a dash) + """ + + rules: Required[Iterable[IngressRuleParam]] + """Routing rules for this ingress""" + + tags: Dict[str, str] + """User-defined key-value tags.""" diff --git a/src/hypeman/types/ingress_list_params.py b/src/hypeman/types/ingress_list_params.py new file mode 100644 index 0000000..2c1d32e --- /dev/null +++ b/src/hypeman/types/ingress_list_params.py @@ -0,0 +1,13 @@ +# File generated from our OpenAPI spec by Stainless. See CONTRIBUTING.md for details. + +from __future__ import annotations + +from typing import Dict +from typing_extensions import TypedDict + +__all__ = ["IngressListParams"] + + +class IngressListParams(TypedDict, total=False): + tags: Dict[str, str] + """Filter ingresses by tag key-value pairs.""" diff --git a/src/hypeman/types/ingress_list_response.py b/src/hypeman/types/ingress_list_response.py new file mode 100644 index 0000000..b7936e6 --- /dev/null +++ b/src/hypeman/types/ingress_list_response.py @@ -0,0 +1,10 @@ +# File generated from our OpenAPI spec by Stainless. See CONTRIBUTING.md for details. + +from typing import List +from typing_extensions import TypeAlias + +from .ingress import Ingress + +__all__ = ["IngressListResponse"] + +IngressListResponse: TypeAlias = List[Ingress] diff --git a/src/hypeman/types/ingress_match.py b/src/hypeman/types/ingress_match.py new file mode 100644 index 0000000..6f85038 --- /dev/null +++ b/src/hypeman/types/ingress_match.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__ = ["IngressMatch"] + + +class IngressMatch(BaseModel): + hostname: str + """Hostname to match. Can be: + + - Literal: "api.example.com" (exact match on Host header) + - Pattern: "{instance}.example.com" (dynamic routing based on subdomain) + + Pattern hostnames use named captures in curly braces (e.g., {instance}, {app}) + that extract parts of the hostname for routing. The extracted values can be + referenced in the target.instance field. + """ + + port: Optional[int] = None + """Host port to listen on for this rule (default 80)""" diff --git a/src/hypeman/types/ingress_match_param.py b/src/hypeman/types/ingress_match_param.py new file mode 100644 index 0000000..ff3fec5 --- /dev/null +++ b/src/hypeman/types/ingress_match_param.py @@ -0,0 +1,23 @@ +# File generated from our OpenAPI spec by Stainless. See CONTRIBUTING.md for details. + +from __future__ import annotations + +from typing_extensions import Required, TypedDict + +__all__ = ["IngressMatchParam"] + + +class IngressMatchParam(TypedDict, total=False): + hostname: Required[str] + """Hostname to match. Can be: + + - Literal: "api.example.com" (exact match on Host header) + - Pattern: "{instance}.example.com" (dynamic routing based on subdomain) + + Pattern hostnames use named captures in curly braces (e.g., {instance}, {app}) + that extract parts of the hostname for routing. The extracted values can be + referenced in the target.instance field. + """ + + port: int + """Host port to listen on for this rule (default 80)""" diff --git a/src/hypeman/types/ingress_rule.py b/src/hypeman/types/ingress_rule.py new file mode 100644 index 0000000..2e691bf --- /dev/null +++ b/src/hypeman/types/ingress_rule.py @@ -0,0 +1,42 @@ +# File generated from our OpenAPI spec by Stainless. See CONTRIBUTING.md for details. + +from typing import Optional + +from .._models import BaseModel +from .ingress_match import IngressMatch +from .ingress_target import IngressTarget + +__all__ = ["IngressRule", "RequestHeaderAuth"] + + +class RequestHeaderAuth(BaseModel): + header: str + """Dedicated request header that must match before proxying. + + Reserved authentication, cookie, host, framing, proxy, and hop-by-hop headers + are not allowed. + """ + + value: str + """Exact header value required before proxying. + + This sensitive value is persisted and returned by the API like instance + environment variables; clients should hide it by default. + """ + + +class IngressRule(BaseModel): + match: IngressMatch + + target: IngressTarget + + redirect_http: Optional[bool] = None + """ + Auto-create HTTP to HTTPS redirect for this hostname (only applies when tls is + enabled) + """ + + request_header_auth: Optional[RequestHeaderAuth] = None + + tls: Optional[bool] = None + """Enable TLS termination (certificate auto-issued via ACME).""" diff --git a/src/hypeman/types/ingress_rule_param.py b/src/hypeman/types/ingress_rule_param.py new file mode 100644 index 0000000..c3dd786 --- /dev/null +++ b/src/hypeman/types/ingress_rule_param.py @@ -0,0 +1,43 @@ +# File generated from our OpenAPI spec by Stainless. See CONTRIBUTING.md for details. + +from __future__ import annotations + +from typing_extensions import Required, TypedDict + +from .ingress_match_param import IngressMatchParam +from .ingress_target_param import IngressTargetParam + +__all__ = ["IngressRuleParam", "RequestHeaderAuth"] + + +class RequestHeaderAuth(TypedDict, total=False): + header: Required[str] + """Dedicated request header that must match before proxying. + + Reserved authentication, cookie, host, framing, proxy, and hop-by-hop headers + are not allowed. + """ + + value: Required[str] + """Exact header value required before proxying. + + This sensitive value is persisted and returned by the API like instance + environment variables; clients should hide it by default. + """ + + +class IngressRuleParam(TypedDict, total=False): + match: Required[IngressMatchParam] + + target: Required[IngressTargetParam] + + redirect_http: bool + """ + Auto-create HTTP to HTTPS redirect for this hostname (only applies when tls is + enabled) + """ + + request_header_auth: RequestHeaderAuth + + tls: bool + """Enable TLS termination (certificate auto-issued via ACME).""" diff --git a/src/hypeman/types/ingress_target.py b/src/hypeman/types/ingress_target.py new file mode 100644 index 0000000..59353e1 --- /dev/null +++ b/src/hypeman/types/ingress_target.py @@ -0,0 +1,21 @@ +# File generated from our OpenAPI spec by Stainless. See CONTRIBUTING.md for details. + +from .._models import BaseModel + +__all__ = ["IngressTarget"] + + +class IngressTarget(BaseModel): + instance: str + """Target instance name, ID, or capture reference. + + - For literal hostnames: Use the instance name or ID directly (e.g., "my-api") + - For pattern hostnames: Reference a capture from the hostname (e.g., + "{instance}") + + When using pattern hostnames, the instance is resolved dynamically at request + time. + """ + + port: int + """Target port on the instance""" diff --git a/src/hypeman/types/ingress_target_param.py b/src/hypeman/types/ingress_target_param.py new file mode 100644 index 0000000..b8df920 --- /dev/null +++ b/src/hypeman/types/ingress_target_param.py @@ -0,0 +1,23 @@ +# File generated from our OpenAPI spec by Stainless. See CONTRIBUTING.md for details. + +from __future__ import annotations + +from typing_extensions import Required, TypedDict + +__all__ = ["IngressTargetParam"] + + +class IngressTargetParam(TypedDict, total=False): + instance: Required[str] + """Target instance name, ID, or capture reference. + + - For literal hostnames: Use the instance name or ID directly (e.g., "my-api") + - For pattern hostnames: Reference a capture from the hostname (e.g., + "{instance}") + + When using pattern hostnames, the instance is resolved dynamically at request + time. + """ + + port: Required[int] + """Target port on the instance""" diff --git a/src/hypeman/types/instance.py b/src/hypeman/types/instance.py new file mode 100644 index 0000000..f047835 --- /dev/null +++ b/src/hypeman/types/instance.py @@ -0,0 +1,172 @@ +# File generated from our OpenAPI spec by Stainless. See CONTRIBUTING.md for details. + +from typing import Dict, List, Optional +from datetime import datetime +from typing_extensions import Literal + +from .._models import BaseModel +from .health_check import HealthCheck +from .volume_mount import VolumeMount +from .restart_policy import RestartPolicy +from .restart_status import RestartStatus +from .snapshot_policy import SnapshotPolicy +from .auto_standby_policy import AutoStandbyPolicy +from .instance_health_status import InstanceHealthStatus + +__all__ = ["Instance", "GPU", "Network"] + + +class GPU(BaseModel): + """GPU information attached to the instance""" + + mdev_uuid: Optional[str] = None + """mdev device UUID""" + + profile: Optional[str] = None + """vGPU profile name""" + + +class Network(BaseModel): + """Network configuration of the instance""" + + bandwidth_download: Optional[str] = None + """Download bandwidth limit (human-readable, e.g., "1Gbps", "125MB/s")""" + + bandwidth_upload: Optional[str] = None + """Upload bandwidth limit (human-readable, e.g., "1Gbps", "125MB/s")""" + + enabled: Optional[bool] = None + """Whether instance is attached to the default network""" + + ip: Optional[str] = None + """Assigned IP address (null if no network)""" + + mac: Optional[str] = None + """Assigned MAC address (null if no network)""" + + name: Optional[str] = None + """Network name (always "default" when enabled)""" + + +class Instance(BaseModel): + id: str + """Auto-generated unique identifier (CUID2 format)""" + + created_at: datetime + """Creation timestamp (RFC3339)""" + + image: str + """OCI image reference""" + + name: str + """Human-readable name""" + + state: Literal["Created", "Initializing", "Running", "Paused", "Shutdown", "Stopped", "Standby", "Unknown"] + """Instance state: + + - Created: VMM created but not started (Cloud Hypervisor native) + - Initializing: VM is running while guest init is still in progress + - Running: Guest program has started and instance is ready + - Paused: VM is paused (Cloud Hypervisor native) + - Shutdown: VM shut down but VMM exists (Cloud Hypervisor native) + - Stopped: No VMM running, no snapshot exists + - Standby: No VMM running, snapshot exists (can be restored) + - Unknown: Failed to determine state (see state_error for details) + """ + + auto_standby: Optional[AutoStandbyPolicy] = None + """ + Linux-only automatic standby policy based on active inbound TCP connections + observed from the host conntrack table. + """ + + current_phase: Optional[str] = None + """The lifecycle phase the instance is currently in.""" + + current_phase_since: Optional[datetime] = None + """When the instance entered current_phase.""" + + disk_io_bps: Optional[str] = None + """Disk I/O rate limit (human-readable, e.g., "100MB/s")""" + + env: Optional[Dict[str, str]] = None + """Environment variables""" + + exit_code: Optional[int] = None + """App exit code (null if VM hasn't exited)""" + + exit_message: Optional[str] = None + """ + Human-readable description of exit (e.g., "command not found", "killed by signal + 9 (SIGKILL) - OOM") + """ + + gpu: Optional[GPU] = None + """GPU information attached to the instance""" + + has_snapshot: Optional[bool] = None + """Whether a snapshot exists for this instance""" + + health_check: Optional[HealthCheck] = None + """Workload health check policy. + + Health is reported separately from instance lifecycle state. + """ + + health_status: Optional[InstanceHealthStatus] = None + + hotplug_size: Optional[str] = None + """Hotplug memory size (human-readable)""" + + hypervisor: Optional[Literal["cloud-hypervisor", "firecracker", "qemu", "qemu-microvm", "vz"]] = None + """Hypervisor backend running this instance""" + + network: Optional[Network] = None + """Network configuration of the instance""" + + overlay_size: Optional[str] = None + """Writable overlay disk size (human-readable)""" + + phase_durations_ms: Optional[Dict[str, int]] = None + """ + Cumulative milliseconds the instance has spent in each lifecycle phase, + including time accrued in the current phase up to the response time. Keys mirror + instance states lowercased (running, standby, paused, stopped, created, + initializing, shutdown). Consumers (e.g. billing) sum the phases they consider + billable. + """ + + platform: Optional[str] = None + """Resolved image platform as os/arch[/variant] (e.g. + + "linux/amd64"). amd64 images on an arm64 host run under Rosetta emulation. + """ + + restart_policy: Optional[RestartPolicy] = None + """Whole-instance restart supervision policy.""" + + restart_status: Optional[RestartStatus] = None + """Runtime status for restart policy decisions.""" + + size: Optional[str] = None + """Base memory size (human-readable)""" + + snapshot_policy: Optional[SnapshotPolicy] = None + + started_at: Optional[datetime] = None + """Start timestamp (RFC3339)""" + + state_error: Optional[str] = None + """Error message if state couldn't be determined (only set when state is Unknown)""" + + stopped_at: Optional[datetime] = None + """Stop timestamp (RFC3339)""" + + tags: Optional[Dict[str, str]] = None + """User-defined key-value tags.""" + + vcpus: Optional[int] = None + """Number of virtual CPUs""" + + volumes: Optional[List[VolumeMount]] = None + """Volumes attached to the instance""" diff --git a/src/hypeman/types/instance_create_params.py b/src/hypeman/types/instance_create_params.py new file mode 100644 index 0000000..51b2195 --- /dev/null +++ b/src/hypeman/types/instance_create_params.py @@ -0,0 +1,256 @@ +# File generated from our OpenAPI spec by Stainless. See CONTRIBUTING.md for details. + +from __future__ import annotations + +from typing import Dict, Iterable +from typing_extensions import Literal, Required, TypedDict + +from .._types import SequenceNotStr +from .health_check_param import HealthCheckParam +from .volume_mount_param import VolumeMountParam +from .restart_policy_param import RestartPolicyParam +from .snapshot_policy_param import SnapshotPolicyParam +from .auto_standby_policy_param import AutoStandbyPolicyParam + +__all__ = [ + "InstanceCreateParams", + "Credentials", + "CredentialsInject", + "CredentialsInjectAs", + "CredentialsSource", + "GPU", + "Network", + "NetworkEgress", + "NetworkEgressEnforcement", +] + + +class InstanceCreateParams(TypedDict, total=False): + image: Required[str] + """OCI image reference""" + + name: Required[str] + """ + Human-readable name (lowercase letters, digits, and dashes only; cannot start or + end with a dash) + """ + + auto_standby: AutoStandbyPolicyParam + """ + Linux-only automatic standby policy based on active inbound TCP connections + observed from the host conntrack table. + """ + + cmd: SequenceNotStr[str] + """Override image CMD (like docker run ). + + Omit to use image default. + """ + + credentials: Dict[str, Credentials] + """ + Host-managed credential brokering policies keyed by guest-visible env var name. + Those guest env vars receive mock placeholder values, while the real values + remain host-scoped in the request `env` map and are only materialized on the + mediated egress path according to each credential's `source` and `inject` rules. + """ + + devices: SequenceNotStr[str] + """Device IDs or names to attach for GPU/PCI passthrough""" + + disk_io_bps: str + """Disk I/O rate limit (e.g., "100MB/s", "500MB/s"). + + Defaults to proportional share based on CPU allocation if configured. + """ + + entrypoint: SequenceNotStr[str] + """Override image entrypoint (like docker run --entrypoint). + + Omit to use image default. + """ + + env: Dict[str, str] + """Environment variables""" + + gpu: GPU + """GPU configuration for the instance""" + + health_check: HealthCheckParam + """Workload health check policy. + + Health is reported separately from instance lifecycle state. + """ + + hotplug_size: str + """Additional memory for hotplug (human-readable format like "3GB", "1G"). + + Omit to disable hotplug memory. + """ + + hypervisor: Literal["cloud-hypervisor", "firecracker", "qemu", "qemu-microvm", "vz"] + """Hypervisor backend to use for this instance. + + qemu uses the architecture-native standard board; qemu-microvm uses QEMU's + minimal Linux amd64 board and does not support PCI devices, hotplug memory, or + more than eight virtio-mmio devices. Defaults to server configuration. + """ + + network: Network + """Network configuration for the instance""" + + overlay_size: str + """Writable overlay disk size (human-readable format like "10GB", "50G")""" + + platform: str + """Target platform as os/arch[/variant] (e.g. + + "linux/amd64"), matching Docker --platform. Omit for the host platform. Not a + fixed enum: the os/arch[/variant] grammar is validated server-side and invalid + values return 400 invalid_platform. Only os "linux" with arch amd64 or arm64 is + accepted today. + """ + + restart_policy: RestartPolicyParam + """Whole-instance restart supervision policy.""" + + size: str + """Base memory size (human-readable format like "1GB", "512MB", "2G")""" + + skip_guest_agent: bool + """ + Skip guest-agent installation during boot. When true, the exec and stat APIs + will not work for this instance. The instance will still run, but remote command + execution will be unavailable. + """ + + skip_kernel_headers: bool + """ + Skip kernel headers installation during boot for faster startup. When true, DKMS + (Dynamic Kernel Module Support) will not work, preventing compilation of + out-of-tree kernel modules (e.g., NVIDIA vGPU drivers). Recommended for + workloads that don't need kernel module compilation. + """ + + snapshot_policy: SnapshotPolicyParam + """Snapshot policy for this instance. + + Controls compression settings applied when creating snapshots or entering + standby, plus any default standby-only compression delay. + """ + + tags: Dict[str, str] + """User-defined key-value tags.""" + + vcpus: int + """Number of virtual CPUs""" + + volumes: Iterable[VolumeMountParam] + """Volumes to attach to the instance at creation time""" + + +class CredentialsInjectAs(TypedDict, total=False): + """Current v1 transform shape. + + Header templating is supported now; other transform + types (for example request signing) can be added in future revisions. + """ + + format: Required[str] + """Template that must include `${value}`.""" + + header: Required[str] + """Header name to set/mutate for matching outbound requests.""" + + +_CredentialsInjectReservedKeywords = TypedDict( + "_CredentialsInjectReservedKeywords", + { + "as": CredentialsInjectAs, + }, + total=False, +) + + +class CredentialsInject(_CredentialsInjectReservedKeywords, total=False): + hosts: SequenceNotStr[str] + """ + Optional destination host patterns (`api.example.com`, `*.example.com`). Omit to + allow injection on all destinations. + """ + + +class CredentialsSource(TypedDict, total=False): + env: Required[str] + """ + Name of the real credential in the request `env` map. The guest-visible env var + key can receive a mock placeholder, while the mediated egress path resolves that + placeholder back to this real value only on the host. + """ + + +class Credentials(TypedDict, total=False): + inject: Required[Iterable[CredentialsInject]] + + source: Required[CredentialsSource] + + +class GPU(TypedDict, total=False): + """GPU configuration for the instance""" + + profile: str + """vGPU profile name (e.g., "L40S-1Q"). Only used in vGPU mode.""" + + +class NetworkEgressEnforcement(TypedDict, total=False): + """Egress enforcement policy applied when mediation is enabled.""" + + mode: Literal["all", "http_https_only"] + """ + `all` (default) rejects direct non-mediated TCP egress from the VM, while + `http_https_only` rejects direct egress only on TCP ports 80 and 443. + """ + + +class NetworkEgress(TypedDict, total=False): + """ + Host-mediated outbound network policy. + Omit this object, or set `enabled: false`, to preserve normal direct outbound networking + when `network.enabled` is true. + """ + + enabled: bool + """ + Whether to enable the mediated egress path. When false or omitted, the instance + keeps normal direct outbound networking and host-managed credential rewriting is + disabled. + """ + + enforcement: NetworkEgressEnforcement + """Egress enforcement policy applied when mediation is enabled.""" + + +class Network(TypedDict, total=False): + """Network configuration for the instance""" + + bandwidth_download: str + """Download bandwidth limit (external→VM, e.g., "1Gbps", "125MB/s"). + + Defaults to proportional share based on CPU allocation. + """ + + bandwidth_upload: str + """Upload bandwidth limit (VM→external, e.g., "1Gbps", "125MB/s"). + + Defaults to proportional share based on CPU allocation. + """ + + egress: NetworkEgress + """ + Host-mediated outbound network policy. Omit this object, or set + `enabled: false`, to preserve normal direct outbound networking when + `network.enabled` is true. + """ + + enabled: bool + """Whether to attach instance to the default network""" diff --git a/src/hypeman/types/instance_fork_params.py b/src/hypeman/types/instance_fork_params.py new file mode 100644 index 0000000..ce51a6b --- /dev/null +++ b/src/hypeman/types/instance_fork_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 Literal, Required, TypedDict + +__all__ = ["InstanceForkParams"] + + +class InstanceForkParams(TypedDict, total=False): + name: Required[str] + """ + Name for the forked instance (lowercase letters, digits, and dashes only; cannot + start or end with a dash) + """ + + from_running: bool + """ + Allow forking from a running source instance. When true and source is Running, + the source is put into standby, forked, then restored back to Running. + """ + + target_state: Literal["Stopped", "Standby", "Running"] + """ + Optional final state for the forked instance. Default is the source instance + state at fork time. For example, forking from Running defaults the fork result + to Running. + """ diff --git a/src/hypeman/types/instance_health_status.py b/src/hypeman/types/instance_health_status.py new file mode 100644 index 0000000..cff819d --- /dev/null +++ b/src/hypeman/types/instance_health_status.py @@ -0,0 +1,32 @@ +# 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__ = ["InstanceHealthStatus"] + + +class InstanceHealthStatus(BaseModel): + consecutive_failures: int + """Consecutive failed checks in the current health window.""" + + consecutive_successes: int + """Consecutive successful checks in the current health window.""" + + status: Literal["disabled", "starting", "healthy", "unhealthy", "unknown"] + """Current workload health status.""" + + last_checked_at: Optional[datetime] = None + """Most recent check completion time.""" + + last_error: Optional[str] = None + """Truncated error from the most recent failed check.""" + + last_failure_at: Optional[datetime] = None + """Most recent failed check completion time.""" + + last_success_at: Optional[datetime] = None + """Most recent successful check completion time.""" diff --git a/src/hypeman/types/instance_list_params.py b/src/hypeman/types/instance_list_params.py new file mode 100644 index 0000000..a486f1f --- /dev/null +++ b/src/hypeman/types/instance_list_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 import Dict +from typing_extensions import Literal, TypedDict + +__all__ = ["InstanceListParams"] + + +class InstanceListParams(TypedDict, total=False): + state: Literal["Created", "Initializing", "Running", "Paused", "Shutdown", "Stopped", "Standby", "Unknown"] + """Filter instances by state (e.g., Running, Stopped)""" + + tags: Dict[str, str] + """Filter instances by tag key-value pairs. + + Uses deepObject style: ?tags[team]=backend&tags[env]=staging Multiple entries + are ANDed together. All specified key-value pairs must match. + """ diff --git a/src/hypeman/types/instance_list_response.py b/src/hypeman/types/instance_list_response.py new file mode 100644 index 0000000..7333a4d --- /dev/null +++ b/src/hypeman/types/instance_list_response.py @@ -0,0 +1,10 @@ +# File generated from our OpenAPI spec by Stainless. See CONTRIBUTING.md for details. + +from typing import List +from typing_extensions import TypeAlias + +from .instance import Instance + +__all__ = ["InstanceListResponse"] + +InstanceListResponse: TypeAlias = List[Instance] diff --git a/src/hypeman/types/instance_logs_params.py b/src/hypeman/types/instance_logs_params.py new file mode 100644 index 0000000..a56923f --- /dev/null +++ b/src/hypeman/types/instance_logs_params.py @@ -0,0 +1,23 @@ +# File generated from our OpenAPI spec by Stainless. See CONTRIBUTING.md for details. + +from __future__ import annotations + +from typing_extensions import Literal, TypedDict + +__all__ = ["InstanceLogsParams"] + + +class InstanceLogsParams(TypedDict, total=False): + follow: bool + """Continue streaming new lines after initial output""" + + source: Literal["app", "vmm", "hypeman"] + """Log source to stream: + + - app: Guest application logs (serial console output) + - vmm: Cloud Hypervisor VMM logs (hypervisor stdout+stderr) + - hypeman: Hypeman operations log (actions taken on this instance) + """ + + tail: int + """Number of lines to return from end""" diff --git a/src/hypeman/types/instance_logs_response.py b/src/hypeman/types/instance_logs_response.py new file mode 100644 index 0000000..bdae96d --- /dev/null +++ b/src/hypeman/types/instance_logs_response.py @@ -0,0 +1,7 @@ +# File generated from our OpenAPI spec by Stainless. See CONTRIBUTING.md for details. + +from typing_extensions import TypeAlias + +__all__ = ["InstanceLogsResponse"] + +InstanceLogsResponse: TypeAlias = str diff --git a/src/hypeman/types/instance_standby_params.py b/src/hypeman/types/instance_standby_params.py new file mode 100644 index 0000000..5f3bb2a --- /dev/null +++ b/src/hypeman/types/instance_standby_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 + +from .shared_params.snapshot_compression_config import SnapshotCompressionConfig + +__all__ = ["InstanceStandbyParams"] + + +class InstanceStandbyParams(TypedDict, total=False): + compression: SnapshotCompressionConfig + """Compression settings for standby snapshot memory. Overrides instance defaults.""" + + compression_delay: str + """ + Delay before standby snapshot compression begins, expressed as a Go duration + like "30s" or "5m". Overrides the instance default for this standby operation + only. + """ diff --git a/src/hypeman/types/instance_start_params.py b/src/hypeman/types/instance_start_params.py new file mode 100644 index 0000000..d685fcb --- /dev/null +++ b/src/hypeman/types/instance_start_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 + +from .._types import SequenceNotStr + +__all__ = ["InstanceStartParams"] + + +class InstanceStartParams(TypedDict, total=False): + cmd: SequenceNotStr[str] + """Override image CMD for this run. Omit to keep previous value.""" + + entrypoint: SequenceNotStr[str] + """Override image entrypoint for this run. Omit to keep previous value.""" diff --git a/src/hypeman/types/instance_stat_params.py b/src/hypeman/types/instance_stat_params.py new file mode 100644 index 0000000..4c89af6 --- /dev/null +++ b/src/hypeman/types/instance_stat_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__ = ["InstanceStatParams"] + + +class InstanceStatParams(TypedDict, total=False): + path: Required[str] + """Path to stat in the guest filesystem""" + + follow_links: bool + """Follow symbolic links (like stat vs lstat)""" diff --git a/src/hypeman/types/instance_stats.py b/src/hypeman/types/instance_stats.py new file mode 100644 index 0000000..905e3fb --- /dev/null +++ b/src/hypeman/types/instance_stats.py @@ -0,0 +1,44 @@ +# File generated from our OpenAPI spec by Stainless. See CONTRIBUTING.md for details. + +from typing import Optional + +from .._models import BaseModel + +__all__ = ["InstanceStats"] + + +class InstanceStats(BaseModel): + """Real-time resource utilization statistics for a VM instance""" + + allocated_memory_bytes: int + """Total memory allocated to the VM (Size + HotplugSize) in bytes""" + + allocated_vcpus: int + """Number of vCPUs allocated to the VM""" + + cpu_seconds: float + """Total CPU time consumed by the VM hypervisor process in seconds""" + + instance_id: str + """Instance identifier""" + + instance_name: str + """Instance name""" + + memory_rss_bytes: int + """Resident Set Size - actual physical memory used by the VM in bytes""" + + memory_vms_bytes: int + """Virtual Memory Size - total virtual memory allocated in bytes""" + + network_rx_bytes: int + """Total network bytes received by the VM (from TAP interface)""" + + network_tx_bytes: int + """Total network bytes transmitted by the VM (from TAP interface)""" + + memory_utilization_ratio: Optional[float] = None + """Memory utilization ratio (RSS / allocated memory). + + Only present when allocated_memory_bytes > 0. + """ diff --git a/src/hypeman/types/instance_update_params.py b/src/hypeman/types/instance_update_params.py new file mode 100644 index 0000000..1c5ab4f --- /dev/null +++ b/src/hypeman/types/instance_update_params.py @@ -0,0 +1,36 @@ +# 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 + +from .health_check_param import HealthCheckParam +from .restart_policy_param import RestartPolicyParam +from .auto_standby_policy_param import AutoStandbyPolicyParam + +__all__ = ["InstanceUpdateParams"] + + +class InstanceUpdateParams(TypedDict, total=False): + auto_standby: AutoStandbyPolicyParam + """ + Linux-only automatic standby policy based on active inbound TCP connections + observed from the host conntrack table. + """ + + env: Dict[str, str] + """ + Environment variables to update (merged with existing). Only keys referenced by + the instance's existing credential `source.env` bindings are accepted. Use this + to rotate real credential values without restarting the VM. + """ + + health_check: HealthCheckParam + """Workload health check policy. + + Health is reported separately from instance lifecycle state. + """ + + restart_policy: RestartPolicyParam + """Whole-instance restart supervision policy.""" diff --git a/src/hypeman/types/instance_wait_params.py b/src/hypeman/types/instance_wait_params.py new file mode 100644 index 0000000..f6ddaf2 --- /dev/null +++ b/src/hypeman/types/instance_wait_params.py @@ -0,0 +1,22 @@ +# File generated from our OpenAPI spec by Stainless. See CONTRIBUTING.md for details. + +from __future__ import annotations + +from typing_extensions import Literal, Required, Annotated, TypedDict + +from .._utils import PropertyInfo + +__all__ = ["InstanceWaitParams"] + + +class InstanceWaitParams(TypedDict, total=False): + state: Required[ + Literal["Created", "Initializing", "Running", "Paused", "Shutdown", "Stopped", "Standby", "Unknown"] + ] + """Target state to wait for""" + + api_timeout: Annotated[str, PropertyInfo(alias="timeout")] + """Maximum duration to wait (Go duration format, e.g. + + "30s", "2m"). Capped at 5 minutes. Defaults to 60 seconds. + """ diff --git a/src/hypeman/types/instances/__init__.py b/src/hypeman/types/instances/__init__.py new file mode 100644 index 0000000..3af5ce5 --- /dev/null +++ b/src/hypeman/types/instances/__init__.py @@ -0,0 +1,8 @@ +# File generated from our OpenAPI spec by Stainless. See CONTRIBUTING.md for details. + +from __future__ import annotations + +from .volume_attach_params import VolumeAttachParams as VolumeAttachParams +from .snapshot_create_params import SnapshotCreateParams as SnapshotCreateParams +from .snapshot_restore_params import SnapshotRestoreParams as SnapshotRestoreParams +from .snapshot_schedule_update_params import SnapshotScheduleUpdateParams as SnapshotScheduleUpdateParams diff --git a/src/hypeman/types/instances/snapshot_create_params.py b/src/hypeman/types/instances/snapshot_create_params.py new file mode 100644 index 0000000..ef715ca --- /dev/null +++ b/src/hypeman/types/instances/snapshot_create_params.py @@ -0,0 +1,31 @@ +# 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 + +from ..snapshot_kind import SnapshotKind +from ..shared_params.snapshot_compression_config import SnapshotCompressionConfig + +__all__ = ["SnapshotCreateParams"] + + +class SnapshotCreateParams(TypedDict, total=False): + kind: Required[SnapshotKind] + """Snapshot capture kind""" + + compression: SnapshotCompressionConfig + """Compression settings to use for this snapshot. + + Overrides instance and server defaults. + """ + + name: str + """ + Optional snapshot name (lowercase letters, digits, and dashes only; cannot start + or end with a dash) + """ + + tags: Dict[str, str] + """User-defined key-value tags.""" diff --git a/src/hypeman/types/instances/snapshot_restore_params.py b/src/hypeman/types/instances/snapshot_restore_params.py new file mode 100644 index 0000000..9cdad3b --- /dev/null +++ b/src/hypeman/types/instances/snapshot_restore_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 Literal, Required, TypedDict + +__all__ = ["SnapshotRestoreParams"] + + +class SnapshotRestoreParams(TypedDict, total=False): + id: Required[str] + + target_hypervisor: Literal["cloud-hypervisor", "firecracker", "qemu", "qemu-microvm", "vz"] + """Optional hypervisor override. + + Allowed only when restoring from a Stopped snapshot. Standby snapshots must + restore with their original hypervisor. + """ + + target_state: Literal["Stopped", "Standby", "Running"] + """Optional final state after restore. Defaults by snapshot kind: + + - Standby snapshot defaults to Running + - Stopped snapshot defaults to Stopped + """ diff --git a/src/hypeman/types/instances/snapshot_schedule_update_params.py b/src/hypeman/types/instances/snapshot_schedule_update_params.py new file mode 100644 index 0000000..c683a2e --- /dev/null +++ b/src/hypeman/types/instances/snapshot_schedule_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 import Dict, Optional +from typing_extensions import Required, TypedDict + +from ..snapshot_schedule_retention_param import SnapshotScheduleRetentionParam + +__all__ = ["SnapshotScheduleUpdateParams"] + + +class SnapshotScheduleUpdateParams(TypedDict, total=False): + interval: Required[str] + """Snapshot interval (Go duration format, minimum 1m).""" + + retention: Required[SnapshotScheduleRetentionParam] + """At least one of max_count or max_age must be provided.""" + + metadata: Dict[str, str] + """User-defined key-value tags.""" + + name_prefix: Optional[str] + """Optional prefix for auto-generated scheduled snapshot names (max 47 chars).""" diff --git a/src/hypeman/types/instances/volume_attach_params.py b/src/hypeman/types/instances/volume_attach_params.py new file mode 100644 index 0000000..82e5166 --- /dev/null +++ b/src/hypeman/types/instances/volume_attach_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__ = ["VolumeAttachParams"] + + +class VolumeAttachParams(TypedDict, total=False): + id: Required[str] + + mount_path: Required[str] + """Path where volume should be mounted""" + + readonly: bool + """Mount as read-only""" diff --git a/src/hypeman/types/memory_reclaim_action.py b/src/hypeman/types/memory_reclaim_action.py new file mode 100644 index 0000000..b99af64 --- /dev/null +++ b/src/hypeman/types/memory_reclaim_action.py @@ -0,0 +1,34 @@ +# 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__ = ["MemoryReclaimAction"] + + +class MemoryReclaimAction(BaseModel): + applied_reclaim_bytes: int + + assigned_memory_bytes: int + + hypervisor: Literal["cloud-hypervisor", "firecracker", "qemu", "qemu-microvm", "vz"] + + instance_id: str + + instance_name: str + + planned_target_guest_memory_bytes: int + + previous_target_guest_memory_bytes: int + + protected_floor_bytes: int + + status: str + """Result of this VM's reclaim step.""" + + target_guest_memory_bytes: int + + error: Optional[str] = None + """Error message when status is error or unsupported.""" diff --git a/src/hypeman/types/memory_reclaim_response.py b/src/hypeman/types/memory_reclaim_response.py new file mode 100644 index 0000000..d07b2e6 --- /dev/null +++ b/src/hypeman/types/memory_reclaim_response.py @@ -0,0 +1,27 @@ +# 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 .memory_reclaim_action import MemoryReclaimAction + +__all__ = ["MemoryReclaimResponse"] + + +class MemoryReclaimResponse(BaseModel): + actions: List[MemoryReclaimAction] + + applied_reclaim_bytes: int + + host_available_bytes: int + + host_pressure_state: Literal["healthy", "pressure"] + + planned_reclaim_bytes: int + + requested_reclaim_bytes: int + + hold_until: Optional[datetime] = None + """When the current manual reclaim hold expires.""" diff --git a/src/hypeman/types/passthrough_device.py b/src/hypeman/types/passthrough_device.py new file mode 100644 index 0000000..697c6fb --- /dev/null +++ b/src/hypeman/types/passthrough_device.py @@ -0,0 +1,15 @@ +# File generated from our OpenAPI spec by Stainless. See CONTRIBUTING.md for details. + +from .._models import BaseModel + +__all__ = ["PassthroughDevice"] + + +class PassthroughDevice(BaseModel): + """Physical GPU available for passthrough""" + + available: bool + """Whether this GPU is available (not attached to an instance)""" + + name: str + """GPU name""" diff --git a/src/hypeman/types/path_info.py b/src/hypeman/types/path_info.py new file mode 100644 index 0000000..8ac2a69 --- /dev/null +++ b/src/hypeman/types/path_info.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__ = ["PathInfo"] + + +class PathInfo(BaseModel): + exists: bool + """Whether the path exists""" + + error: Optional[str] = None + """Error message if stat failed (e.g., permission denied). + + Only set when exists is false due to an error rather than the path not existing. + """ + + is_dir: Optional[bool] = None + """True if this is a directory""" + + is_file: Optional[bool] = None + """True if this is a regular file""" + + is_symlink: Optional[bool] = None + """True if this is a symbolic link (only set when follow_links=false)""" + + link_target: Optional[str] = None + """Symlink target path (only set when is_symlink=true)""" + + mode: Optional[int] = None + """File mode (Unix permissions)""" + + size: Optional[int] = None + """File size in bytes""" diff --git a/src/hypeman/types/push.py b/src/hypeman/types/push.py new file mode 100644 index 0000000..709b01b --- /dev/null +++ b/src/hypeman/types/push.py @@ -0,0 +1,41 @@ +# 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 +from .push_status import PushStatus + +__all__ = ["Push"] + + +class Push(BaseModel): + id: str + """Push job identifier""" + + created_at: datetime + + digest: str + """Cached manifest digest being pushed""" + + image: str + """Hypeman image name (normalized ref)""" + + status: PushStatus + + target: str + """Remote reference the image is pushed to""" + + bytes: Optional[int] = None + """Total compressed layer bytes pushed""" + + completed_at: Optional[datetime] = None + + error: Optional[str] = None + """Error message""" + + layers: Optional[int] = None + """Number of layers pushed""" + + queue_position: Optional[int] = None + """Position in the push queue""" diff --git a/src/hypeman/types/push_create_params.py b/src/hypeman/types/push_create_params.py new file mode 100644 index 0000000..6a57df2 --- /dev/null +++ b/src/hypeman/types/push_create_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 .push_credentials_param import PushCredentialsParam + +__all__ = ["PushCreateParams"] + + +class PushCreateParams(TypedDict, total=False): + image: Required[str] + """Hypeman image name to push (tag or digest form)""" + + target: Required[str] + """Full remote reference to push to""" + + credentials: PushCredentialsParam + """Docker-style registry credentials borrowed for one image pull or push request. + + They remain in memory and are never persisted or logged. When omitted or empty, + the server's own registry credentials are used. An interrupted credentialed + operation must be retried with fresh credentials. + """ + + insecure: bool + """Allow pushing to plain-HTTP registries""" diff --git a/src/hypeman/types/push_credentials_param.py b/src/hypeman/types/push_credentials_param.py new file mode 100644 index 0000000..b42cf17 --- /dev/null +++ b/src/hypeman/types/push_credentials_param.py @@ -0,0 +1,25 @@ +# File generated from our OpenAPI spec by Stainless. See CONTRIBUTING.md for details. + +from __future__ import annotations + +from typing_extensions import TypedDict + +__all__ = ["PushCredentialsParam"] + + +class PushCredentialsParam(TypedDict, total=False): + """Docker-style registry credentials borrowed for one image pull or push request. + + They remain + in memory and are never persisted or logged. When omitted or empty, the server's own registry + credentials are used. An interrupted credentialed operation must be retried with fresh credentials. + """ + + password: str + """Registry password or access token""" + + registry_token: str + """Bearer token for an Authorization header""" + + username: str + """Registry username""" diff --git a/src/hypeman/types/push_list_response.py b/src/hypeman/types/push_list_response.py new file mode 100644 index 0000000..6bd1aba --- /dev/null +++ b/src/hypeman/types/push_list_response.py @@ -0,0 +1,10 @@ +# File generated from our OpenAPI spec by Stainless. See CONTRIBUTING.md for details. + +from typing import List +from typing_extensions import TypeAlias + +from .push import Push + +__all__ = ["PushListResponse"] + +PushListResponse: TypeAlias = List[Push] diff --git a/src/hypeman/types/push_status.py b/src/hypeman/types/push_status.py new file mode 100644 index 0000000..c5838f7 --- /dev/null +++ b/src/hypeman/types/push_status.py @@ -0,0 +1,7 @@ +# File generated from our OpenAPI spec by Stainless. See CONTRIBUTING.md for details. + +from typing_extensions import Literal, TypeAlias + +__all__ = ["PushStatus"] + +PushStatus: TypeAlias = Literal["queued", "pushing", "pushed", "failed"] diff --git a/src/hypeman/types/resource_allocation.py b/src/hypeman/types/resource_allocation.py new file mode 100644 index 0000000..92ff8cd --- /dev/null +++ b/src/hypeman/types/resource_allocation.py @@ -0,0 +1,33 @@ +# File generated from our OpenAPI spec by Stainless. See CONTRIBUTING.md for details. + +from typing import Optional + +from .._models import BaseModel + +__all__ = ["ResourceAllocation"] + + +class ResourceAllocation(BaseModel): + cpu: Optional[int] = None + """vCPUs allocated""" + + disk_bytes: Optional[int] = None + """Disk allocated in bytes (overlay + volumes)""" + + disk_io_bps: Optional[int] = None + """Disk I/O bandwidth limit in bytes/sec""" + + instance_id: Optional[str] = None + """Instance identifier""" + + instance_name: Optional[str] = None + """Instance name""" + + memory_bytes: Optional[int] = None + """Memory allocated in bytes""" + + network_download_bps: Optional[int] = None + """Download bandwidth limit in bytes/sec (external→VM)""" + + network_upload_bps: Optional[int] = None + """Upload bandwidth limit in bytes/sec (VM→external)""" diff --git a/src/hypeman/types/resource_reclaim_memory_params.py b/src/hypeman/types/resource_reclaim_memory_params.py new file mode 100644 index 0000000..e2a9d28 --- /dev/null +++ b/src/hypeman/types/resource_reclaim_memory_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 Required, TypedDict + +__all__ = ["ResourceReclaimMemoryParams"] + + +class ResourceReclaimMemoryParams(TypedDict, total=False): + reclaim_bytes: Required[int] + """Total bytes of guest memory to reclaim across eligible VMs.""" + + dry_run: bool + """Calculate a reclaim plan without applying balloon changes or creating a hold.""" + + hold_for: str + """How long to keep the reclaim hold active (Go duration string). + + Defaults to 5m when omitted. + """ + + reason: str + """Optional operator-provided reason attached to logs and traces.""" diff --git a/src/hypeman/types/resource_status.py b/src/hypeman/types/resource_status.py new file mode 100644 index 0000000..15f0c1a --- /dev/null +++ b/src/hypeman/types/resource_status.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__ = ["ResourceStatus"] + + +class ResourceStatus(BaseModel): + allocated: int + """Currently allocated resources""" + + available: int + """Available for allocation (effective_limit - allocated)""" + + capacity: int + """Raw host capacity""" + + effective_limit: int + """Capacity after oversubscription (capacity \\** ratio)""" + + oversub_ratio: float + """Oversubscription ratio applied""" + + type: str + """Resource type""" + + source: Optional[str] = None + """How capacity was determined (detected, configured)""" diff --git a/src/hypeman/types/resources.py b/src/hypeman/types/resources.py new file mode 100644 index 0000000..dacb746 --- /dev/null +++ b/src/hypeman/types/resources.py @@ -0,0 +1,30 @@ +# File generated from our OpenAPI spec by Stainless. See CONTRIBUTING.md for details. + +from typing import List, Optional + +from .._models import BaseModel +from .disk_breakdown import DiskBreakdown +from .resource_status import ResourceStatus +from .gpu_resource_status import GPUResourceStatus +from .resource_allocation import ResourceAllocation + +__all__ = ["Resources"] + + +class Resources(BaseModel): + allocations: List[ResourceAllocation] + + cpu: ResourceStatus + + disk: ResourceStatus + + memory: ResourceStatus + + network: ResourceStatus + + disk_breakdown: Optional[DiskBreakdown] = None + + disk_io: Optional[ResourceStatus] = None + + gpu: Optional[GPUResourceStatus] = None + """GPU resource status. Null if no GPUs available.""" diff --git a/src/hypeman/types/restart_policy.py b/src/hypeman/types/restart_policy.py new file mode 100644 index 0000000..519515a --- /dev/null +++ b/src/hypeman/types/restart_policy.py @@ -0,0 +1,34 @@ +# 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__ = ["RestartPolicy"] + + +class RestartPolicy(BaseModel): + """Whole-instance restart supervision policy.""" + + backoff: Optional[str] = None + """ + Delay before each restart attempt, expressed as a Go duration like "5s" or "1m". + """ + + max_attempts: Optional[int] = None + """Consecutive automatic restart attempts before blocking retries. + + 0 means unlimited. + """ + + policy: Optional[Literal["never", "always", "on_failure"]] = None + """Restart behavior when the guest program exits: + + - never: do not automatically restart + - always: restart after any guest exit + - on_failure: restart only for nonzero, signaled, OOM, or unknown exits + """ + + stable_after: Optional[str] = None + """Running this long resets the consecutive restart attempt count.""" diff --git a/src/hypeman/types/restart_policy_param.py b/src/hypeman/types/restart_policy_param.py new file mode 100644 index 0000000..1e5fd04 --- /dev/null +++ b/src/hypeman/types/restart_policy_param.py @@ -0,0 +1,33 @@ +# File generated from our OpenAPI spec by Stainless. See CONTRIBUTING.md for details. + +from __future__ import annotations + +from typing_extensions import Literal, TypedDict + +__all__ = ["RestartPolicyParam"] + + +class RestartPolicyParam(TypedDict, total=False): + """Whole-instance restart supervision policy.""" + + backoff: str + """ + Delay before each restart attempt, expressed as a Go duration like "5s" or "1m". + """ + + max_attempts: int + """Consecutive automatic restart attempts before blocking retries. + + 0 means unlimited. + """ + + policy: Literal["never", "always", "on_failure"] + """Restart behavior when the guest program exits: + + - never: do not automatically restart + - always: restart after any guest exit + - on_failure: restart only for nonzero, signaled, OOM, or unknown exits + """ + + stable_after: str + """Running this long resets the consecutive restart attempt count.""" diff --git a/src/hypeman/types/restart_status.py b/src/hypeman/types/restart_status.py new file mode 100644 index 0000000..7faf757 --- /dev/null +++ b/src/hypeman/types/restart_status.py @@ -0,0 +1,28 @@ +# 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__ = ["RestartStatus"] + + +class RestartStatus(BaseModel): + """Runtime status for restart policy decisions.""" + + attempts: Optional[int] = None + """Consecutive automatic restart attempts in the current failure window.""" + + blocked_reason: Optional[Literal["manual_stop", "max_attempts_exceeded"]] = None + """Reason automatic restarts are currently blocked.""" + + last_attempt_at: Optional[datetime] = None + """Last time Hypeman attempted an automatic restart.""" + + last_reason: Optional[Literal["health_check_failed"]] = None + """Most recent non-exit failure signal that entered restart policy.""" + + next_attempt_at: Optional[datetime] = None + """Next scheduled automatic restart attempt after backoff.""" diff --git a/src/hypeman/types/shared/__init__.py b/src/hypeman/types/shared/__init__.py new file mode 100644 index 0000000..cb6f428 --- /dev/null +++ b/src/hypeman/types/shared/__init__.py @@ -0,0 +1,3 @@ +# File generated from our OpenAPI spec by Stainless. See CONTRIBUTING.md for details. + +from .snapshot_compression_config import SnapshotCompressionConfig as SnapshotCompressionConfig diff --git a/src/hypeman/types/shared/snapshot_compression_config.py b/src/hypeman/types/shared/snapshot_compression_config.py new file mode 100644 index 0000000..433c883 --- /dev/null +++ b/src/hypeman/types/shared/snapshot_compression_config.py @@ -0,0 +1,26 @@ +# 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__ = ["SnapshotCompressionConfig"] + + +class SnapshotCompressionConfig(BaseModel): + enabled: bool + """Enable snapshot memory compression""" + + algorithm: Optional[Literal["zstd", "lz4"]] = None + """Compression algorithm (defaults to zstd when enabled). + + Ignored when enabled is false. + """ + + level: Optional[int] = None + """Compression level. + + Allowed ranges are zstd=1-19 and lz4=0-9. When omitted, zstd defaults to 1 and + lz4 defaults to 0. Ignored when enabled is false. + """ diff --git a/src/hypeman/types/shared_params/__init__.py b/src/hypeman/types/shared_params/__init__.py new file mode 100644 index 0000000..cb6f428 --- /dev/null +++ b/src/hypeman/types/shared_params/__init__.py @@ -0,0 +1,3 @@ +# File generated from our OpenAPI spec by Stainless. See CONTRIBUTING.md for details. + +from .snapshot_compression_config import SnapshotCompressionConfig as SnapshotCompressionConfig diff --git a/src/hypeman/types/shared_params/snapshot_compression_config.py b/src/hypeman/types/shared_params/snapshot_compression_config.py new file mode 100644 index 0000000..801b6c1 --- /dev/null +++ b/src/hypeman/types/shared_params/snapshot_compression_config.py @@ -0,0 +1,25 @@ +# File generated from our OpenAPI spec by Stainless. See CONTRIBUTING.md for details. + +from __future__ import annotations + +from typing_extensions import Literal, Required, TypedDict + +__all__ = ["SnapshotCompressionConfig"] + + +class SnapshotCompressionConfig(TypedDict, total=False): + enabled: Required[bool] + """Enable snapshot memory compression""" + + algorithm: Literal["zstd", "lz4"] + """Compression algorithm (defaults to zstd when enabled). + + Ignored when enabled is false. + """ + + level: int + """Compression level. + + Allowed ranges are zstd=1-19 and lz4=0-9. When omitted, zstd defaults to 1 and + lz4 defaults to 0. Ignored when enabled is false. + """ diff --git a/src/hypeman/types/snapshot.py b/src/hypeman/types/snapshot.py new file mode 100644 index 0000000..166900d --- /dev/null +++ b/src/hypeman/types/snapshot.py @@ -0,0 +1,54 @@ +# 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 +from .snapshot_kind import SnapshotKind +from .shared.snapshot_compression_config import SnapshotCompressionConfig + +__all__ = ["Snapshot"] + + +class Snapshot(BaseModel): + id: str + """Auto-generated unique snapshot identifier""" + + created_at: datetime + """Snapshot creation timestamp""" + + kind: SnapshotKind + """Snapshot capture kind""" + + size_bytes: int + """Total payload size in bytes""" + + source_hypervisor: Literal["cloud-hypervisor", "firecracker", "qemu", "qemu-microvm", "vz"] + """Source instance hypervisor at snapshot creation time""" + + source_instance_id: str + """Source instance ID at snapshot creation time""" + + source_instance_name: str + """Source instance name at snapshot creation time""" + + compressed_size_bytes: Optional[int] = None + """Compressed memory payload size in bytes""" + + compression: Optional[SnapshotCompressionConfig] = None + + compression_error: Optional[str] = None + """Compression error message when compression_state is error""" + + compression_state: Optional[Literal["none", "compressing", "compressed", "error"]] = None + """Compression status of the snapshot payload memory file""" + + name: Optional[str] = None + """Optional human-readable snapshot name (unique per source instance)""" + + tags: Optional[Dict[str, str]] = None + """User-defined key-value tags.""" + + uncompressed_size_bytes: Optional[int] = None + """Uncompressed memory payload size in bytes""" diff --git a/src/hypeman/types/snapshot_fork_params.py b/src/hypeman/types/snapshot_fork_params.py new file mode 100644 index 0000000..a24d3cc --- /dev/null +++ b/src/hypeman/types/snapshot_fork_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 + +__all__ = ["SnapshotForkParams"] + + +class SnapshotForkParams(TypedDict, total=False): + name: Required[str] + """ + Name for the new instance (lowercase letters, digits, and dashes only; cannot + start or end with a dash) + """ + + target_hypervisor: Literal["cloud-hypervisor", "firecracker", "qemu", "qemu-microvm", "vz"] + """Optional hypervisor override. + + Allowed only when forking from a Stopped snapshot. Standby snapshots must fork + with their original hypervisor. + """ + + target_state: Literal["Stopped", "Standby", "Running"] + """Optional final state for the forked instance. Defaults by snapshot kind: + + - Standby snapshot defaults to Running + - Stopped snapshot defaults to Stopped + """ diff --git a/src/hypeman/types/snapshot_kind.py b/src/hypeman/types/snapshot_kind.py new file mode 100644 index 0000000..eb7cb46 --- /dev/null +++ b/src/hypeman/types/snapshot_kind.py @@ -0,0 +1,7 @@ +# File generated from our OpenAPI spec by Stainless. See CONTRIBUTING.md for details. + +from typing_extensions import Literal, TypeAlias + +__all__ = ["SnapshotKind"] + +SnapshotKind: TypeAlias = Literal["Standby", "Stopped"] diff --git a/src/hypeman/types/snapshot_list_params.py b/src/hypeman/types/snapshot_list_params.py new file mode 100644 index 0000000..d38f919 --- /dev/null +++ b/src/hypeman/types/snapshot_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 import Dict +from typing_extensions import TypedDict + +from .snapshot_kind import SnapshotKind + +__all__ = ["SnapshotListParams"] + + +class SnapshotListParams(TypedDict, total=False): + kind: SnapshotKind + """Filter snapshots by kind""" + + name: str + """Filter snapshots by snapshot name""" + + source_instance_id: str + """Filter snapshots by source instance ID""" + + tags: Dict[str, str] + """Filter snapshots by tag key-value pairs.""" diff --git a/src/hypeman/types/snapshot_list_response.py b/src/hypeman/types/snapshot_list_response.py new file mode 100644 index 0000000..8fabd05 --- /dev/null +++ b/src/hypeman/types/snapshot_list_response.py @@ -0,0 +1,10 @@ +# File generated from our OpenAPI spec by Stainless. See CONTRIBUTING.md for details. + +from typing import List +from typing_extensions import TypeAlias + +from .snapshot import Snapshot + +__all__ = ["SnapshotListResponse"] + +SnapshotListResponse: TypeAlias = List[Snapshot] diff --git a/src/hypeman/types/snapshot_policy.py b/src/hypeman/types/snapshot_policy.py new file mode 100644 index 0000000..a637204 --- /dev/null +++ b/src/hypeman/types/snapshot_policy.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 +from .shared.snapshot_compression_config import SnapshotCompressionConfig + +__all__ = ["SnapshotPolicy"] + + +class SnapshotPolicy(BaseModel): + compression: Optional[SnapshotCompressionConfig] = None + + standby_compression_delay: Optional[str] = None + """ + Delay before standby snapshot compression begins, expressed as a Go duration + like "30s" or "5m". Applies only to standby compression and defaults to + immediate start when omitted. + """ diff --git a/src/hypeman/types/snapshot_policy_param.py b/src/hypeman/types/snapshot_policy_param.py new file mode 100644 index 0000000..34d0f7f --- /dev/null +++ b/src/hypeman/types/snapshot_policy_param.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 TypedDict + +from .shared_params.snapshot_compression_config import SnapshotCompressionConfig + +__all__ = ["SnapshotPolicyParam"] + + +class SnapshotPolicyParam(TypedDict, total=False): + compression: SnapshotCompressionConfig + + standby_compression_delay: str + """ + Delay before standby snapshot compression begins, expressed as a Go duration + like "30s" or "5m". Applies only to standby compression and defaults to + immediate start when omitted. + """ diff --git a/src/hypeman/types/snapshot_schedule.py b/src/hypeman/types/snapshot_schedule.py new file mode 100644 index 0000000..9991256 --- /dev/null +++ b/src/hypeman/types/snapshot_schedule.py @@ -0,0 +1,44 @@ +# File generated from our OpenAPI spec by Stainless. See CONTRIBUTING.md for details. + +from typing import Dict, Optional +from datetime import datetime + +from .._models import BaseModel +from .snapshot_schedule_retention import SnapshotScheduleRetention + +__all__ = ["SnapshotSchedule"] + + +class SnapshotSchedule(BaseModel): + created_at: datetime + """Schedule creation timestamp.""" + + instance_id: str + """Source instance ID.""" + + interval: str + """Snapshot interval (Go duration format).""" + + next_run_at: datetime + """Next scheduled run time.""" + + retention: SnapshotScheduleRetention + """Automatic cleanup policy for scheduled snapshots.""" + + updated_at: datetime + """Schedule update timestamp.""" + + last_error: Optional[str] = None + """Last schedule run error, if any.""" + + last_run_at: Optional[datetime] = None + """Last schedule execution time.""" + + last_snapshot_id: Optional[str] = None + """Snapshot ID produced by the last successful run.""" + + metadata: Optional[Dict[str, str]] = None + """User-defined key-value tags.""" + + name_prefix: Optional[str] = None + """Optional prefix used for generated scheduled snapshot names.""" diff --git a/src/hypeman/types/snapshot_schedule_retention.py b/src/hypeman/types/snapshot_schedule_retention.py new file mode 100644 index 0000000..5638fe6 --- /dev/null +++ b/src/hypeman/types/snapshot_schedule_retention.py @@ -0,0 +1,20 @@ +# File generated from our OpenAPI spec by Stainless. See CONTRIBUTING.md for details. + +from typing import Optional + +from .._models import BaseModel + +__all__ = ["SnapshotScheduleRetention"] + + +class SnapshotScheduleRetention(BaseModel): + """Automatic cleanup policy for scheduled snapshots.""" + + max_age: Optional[str] = None + """Delete scheduled snapshots older than this duration (Go duration format).""" + + max_count: Optional[int] = None + """ + Keep at most this many scheduled snapshots for the instance (0 disables + count-based cleanup). + """ diff --git a/src/hypeman/types/snapshot_schedule_retention_param.py b/src/hypeman/types/snapshot_schedule_retention_param.py new file mode 100644 index 0000000..6d635fd --- /dev/null +++ b/src/hypeman/types/snapshot_schedule_retention_param.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 TypedDict + +__all__ = ["SnapshotScheduleRetentionParam"] + + +class SnapshotScheduleRetentionParam(TypedDict, total=False): + """Automatic cleanup policy for scheduled snapshots.""" + + max_age: str + """Delete scheduled snapshots older than this duration (Go duration format).""" + + max_count: int + """ + Keep at most this many scheduled snapshots for the instance (0 disables + count-based cleanup). + """ diff --git a/src/hypeman/types/volume.py b/src/hypeman/types/volume.py new file mode 100644 index 0000000..19695d1 --- /dev/null +++ b/src/hypeman/types/volume.py @@ -0,0 +1,29 @@ +# 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 .volume_attachment import VolumeAttachment + +__all__ = ["Volume"] + + +class Volume(BaseModel): + id: str + """Unique identifier""" + + created_at: datetime + """Creation timestamp (RFC3339)""" + + name: str + """Volume name""" + + size_gb: int + """Size in gigabytes""" + + attachments: Optional[List[VolumeAttachment]] = None + """List of current attachments (empty if not attached)""" + + tags: Optional[Dict[str, str]] = None + """User-defined key-value tags.""" diff --git a/src/hypeman/types/volume_attachment.py b/src/hypeman/types/volume_attachment.py new file mode 100644 index 0000000..c0c6284 --- /dev/null +++ b/src/hypeman/types/volume_attachment.py @@ -0,0 +1,16 @@ +# File generated from our OpenAPI spec by Stainless. See CONTRIBUTING.md for details. + +from .._models import BaseModel + +__all__ = ["VolumeAttachment"] + + +class VolumeAttachment(BaseModel): + instance_id: str + """ID of the instance this volume is attached to""" + + mount_path: str + """Mount path in the guest""" + + readonly: bool + """Whether the attachment is read-only""" diff --git a/src/hypeman/types/volume_create_from_archive_params.py b/src/hypeman/types/volume_create_from_archive_params.py new file mode 100644 index 0000000..486c2bc --- /dev/null +++ b/src/hypeman/types/volume_create_from_archive_params.py @@ -0,0 +1,22 @@ +# 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__ = ["VolumeCreateFromArchiveParams"] + + +class VolumeCreateFromArchiveParams(TypedDict, total=False): + name: Required[str] + """Volume name""" + + size_gb: Required[int] + """Maximum size in GB (extraction fails if content exceeds this)""" + + id: str + """Optional custom volume ID (auto-generated if not provided)""" + + tags: Dict[str, str] + """Tags for the created volume.""" diff --git a/src/hypeman/types/volume_create_params.py b/src/hypeman/types/volume_create_params.py new file mode 100644 index 0000000..613f9f9 --- /dev/null +++ b/src/hypeman/types/volume_create_params.py @@ -0,0 +1,22 @@ +# 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__ = ["VolumeCreateParams"] + + +class VolumeCreateParams(TypedDict, total=False): + name: Required[str] + """Volume name""" + + size_gb: Required[int] + """Size in gigabytes""" + + id: str + """Optional custom identifier (auto-generated if not provided)""" + + tags: Dict[str, str] + """User-defined key-value tags.""" diff --git a/src/hypeman/types/volume_list_params.py b/src/hypeman/types/volume_list_params.py new file mode 100644 index 0000000..70cec6b --- /dev/null +++ b/src/hypeman/types/volume_list_params.py @@ -0,0 +1,13 @@ +# File generated from our OpenAPI spec by Stainless. See CONTRIBUTING.md for details. + +from __future__ import annotations + +from typing import Dict +from typing_extensions import TypedDict + +__all__ = ["VolumeListParams"] + + +class VolumeListParams(TypedDict, total=False): + tags: Dict[str, str] + """Filter volumes by tag key-value pairs.""" diff --git a/src/hypeman/types/volume_list_response.py b/src/hypeman/types/volume_list_response.py new file mode 100644 index 0000000..c3e04ec --- /dev/null +++ b/src/hypeman/types/volume_list_response.py @@ -0,0 +1,10 @@ +# File generated from our OpenAPI spec by Stainless. See CONTRIBUTING.md for details. + +from typing import List +from typing_extensions import TypeAlias + +from .volume import Volume + +__all__ = ["VolumeListResponse"] + +VolumeListResponse: TypeAlias = List[Volume] diff --git a/src/hypeman/types/volume_mount.py b/src/hypeman/types/volume_mount.py new file mode 100644 index 0000000..60207f2 --- /dev/null +++ b/src/hypeman/types/volume_mount.py @@ -0,0 +1,27 @@ +# File generated from our OpenAPI spec by Stainless. See CONTRIBUTING.md for details. + +from typing import Optional + +from .._models import BaseModel + +__all__ = ["VolumeMount"] + + +class VolumeMount(BaseModel): + mount_path: str + """Path where volume is mounted in the guest""" + + volume_id: str + """Volume identifier""" + + overlay: Optional[bool] = None + """Create per-instance overlay for writes (requires readonly=true)""" + + overlay_size: Optional[str] = None + """Max overlay size as human-readable string (e.g., "1GB"). + + Required if overlay=true. + """ + + readonly: Optional[bool] = None + """Whether volume is mounted read-only""" diff --git a/src/hypeman/types/volume_mount_param.py b/src/hypeman/types/volume_mount_param.py new file mode 100644 index 0000000..a8dfa45 --- /dev/null +++ b/src/hypeman/types/volume_mount_param.py @@ -0,0 +1,27 @@ +# File generated from our OpenAPI spec by Stainless. See CONTRIBUTING.md for details. + +from __future__ import annotations + +from typing_extensions import Required, TypedDict + +__all__ = ["VolumeMountParam"] + + +class VolumeMountParam(TypedDict, total=False): + mount_path: Required[str] + """Path where volume is mounted in the guest""" + + volume_id: Required[str] + """Volume identifier""" + + overlay: bool + """Create per-instance overlay for writes (requires readonly=true)""" + + overlay_size: str + """Max overlay size as human-readable string (e.g., "1GB"). + + Required if overlay=true. + """ + + readonly: bool + """Whether volume is mounted read-only""" diff --git a/src/hypeman/types/wait_for_state_response.py b/src/hypeman/types/wait_for_state_response.py new file mode 100644 index 0000000..a4fcbf7 --- /dev/null +++ b/src/hypeman/types/wait_for_state_response.py @@ -0,0 +1,19 @@ +# 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__ = ["WaitForStateResponse"] + + +class WaitForStateResponse(BaseModel): + state: Literal["Created", "Initializing", "Running", "Paused", "Shutdown", "Stopped", "Standby", "Unknown"] + """Current instance state when the wait completed""" + + timed_out: bool + """Whether the timeout expired before the target state was reached""" + + state_error: Optional[str] = None + """Error message when derived state is Unknown""" diff --git a/tests/__init__.py b/tests/__init__.py new file mode 100644 index 0000000..fd8019a --- /dev/null +++ b/tests/__init__.py @@ -0,0 +1 @@ +# File generated from our OpenAPI spec by Stainless. See CONTRIBUTING.md for details. diff --git a/tests/api_resources/__init__.py b/tests/api_resources/__init__.py new file mode 100644 index 0000000..fd8019a --- /dev/null +++ b/tests/api_resources/__init__.py @@ -0,0 +1 @@ +# File generated from our OpenAPI spec by Stainless. See CONTRIBUTING.md for details. diff --git a/tests/api_resources/instances/__init__.py b/tests/api_resources/instances/__init__.py new file mode 100644 index 0000000..fd8019a --- /dev/null +++ b/tests/api_resources/instances/__init__.py @@ -0,0 +1 @@ +# File generated from our OpenAPI spec by Stainless. See CONTRIBUTING.md for details. diff --git a/tests/api_resources/instances/test_auto_standby.py b/tests/api_resources/instances/test_auto_standby.py new file mode 100644 index 0000000..f2917f0 --- /dev/null +++ b/tests/api_resources/instances/test_auto_standby.py @@ -0,0 +1,192 @@ +# 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 hypeman import Hypeman, AsyncHypeman +from tests.utils import assert_matches_type +from hypeman.types import AutoStandbyStatus + +base_url = os.environ.get("TEST_API_BASE_URL", "http://127.0.0.1:4010") + + +class TestAutoStandby: + 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_hold(self, client: Hypeman) -> None: + auto_standby = client.instances.auto_standby.hold( + "id", + ) + assert_matches_type(AutoStandbyStatus, auto_standby, path=["response"]) + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + def test_raw_response_hold(self, client: Hypeman) -> None: + response = client.instances.auto_standby.with_raw_response.hold( + "id", + ) + + assert response.is_closed is True + assert response.http_request.headers.get("X-Stainless-Lang") == "python" + auto_standby = response.parse() + assert_matches_type(AutoStandbyStatus, auto_standby, path=["response"]) + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + def test_streaming_response_hold(self, client: Hypeman) -> None: + with client.instances.auto_standby.with_streaming_response.hold( + "id", + ) as response: + assert not response.is_closed + assert response.http_request.headers.get("X-Stainless-Lang") == "python" + + auto_standby = response.parse() + assert_matches_type(AutoStandbyStatus, auto_standby, path=["response"]) + + assert cast(Any, response.is_closed) is True + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + def test_path_params_hold(self, client: Hypeman) -> None: + with pytest.raises(ValueError, match=r"Expected a non-empty value for `id` but received ''"): + client.instances.auto_standby.with_raw_response.hold( + "", + ) + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + def test_method_status(self, client: Hypeman) -> None: + auto_standby = client.instances.auto_standby.status( + "id", + ) + assert_matches_type(AutoStandbyStatus, auto_standby, path=["response"]) + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + def test_raw_response_status(self, client: Hypeman) -> None: + response = client.instances.auto_standby.with_raw_response.status( + "id", + ) + + assert response.is_closed is True + assert response.http_request.headers.get("X-Stainless-Lang") == "python" + auto_standby = response.parse() + assert_matches_type(AutoStandbyStatus, auto_standby, path=["response"]) + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + def test_streaming_response_status(self, client: Hypeman) -> None: + with client.instances.auto_standby.with_streaming_response.status( + "id", + ) as response: + assert not response.is_closed + assert response.http_request.headers.get("X-Stainless-Lang") == "python" + + auto_standby = response.parse() + assert_matches_type(AutoStandbyStatus, auto_standby, 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: Hypeman) -> None: + with pytest.raises(ValueError, match=r"Expected a non-empty value for `id` but received ''"): + client.instances.auto_standby.with_raw_response.status( + "", + ) + + +class TestAsyncAutoStandby: + 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_hold(self, async_client: AsyncHypeman) -> None: + auto_standby = await async_client.instances.auto_standby.hold( + "id", + ) + assert_matches_type(AutoStandbyStatus, auto_standby, path=["response"]) + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + async def test_raw_response_hold(self, async_client: AsyncHypeman) -> None: + response = await async_client.instances.auto_standby.with_raw_response.hold( + "id", + ) + + assert response.is_closed is True + assert response.http_request.headers.get("X-Stainless-Lang") == "python" + auto_standby = await response.parse() + assert_matches_type(AutoStandbyStatus, auto_standby, path=["response"]) + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + async def test_streaming_response_hold(self, async_client: AsyncHypeman) -> None: + async with async_client.instances.auto_standby.with_streaming_response.hold( + "id", + ) as response: + assert not response.is_closed + assert response.http_request.headers.get("X-Stainless-Lang") == "python" + + auto_standby = await response.parse() + assert_matches_type(AutoStandbyStatus, auto_standby, 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_hold(self, async_client: AsyncHypeman) -> None: + with pytest.raises(ValueError, match=r"Expected a non-empty value for `id` but received ''"): + await async_client.instances.auto_standby.with_raw_response.hold( + "", + ) + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + async def test_method_status(self, async_client: AsyncHypeman) -> None: + auto_standby = await async_client.instances.auto_standby.status( + "id", + ) + assert_matches_type(AutoStandbyStatus, auto_standby, path=["response"]) + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + async def test_raw_response_status(self, async_client: AsyncHypeman) -> None: + response = await async_client.instances.auto_standby.with_raw_response.status( + "id", + ) + + assert response.is_closed is True + assert response.http_request.headers.get("X-Stainless-Lang") == "python" + auto_standby = await response.parse() + assert_matches_type(AutoStandbyStatus, auto_standby, path=["response"]) + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + async def test_streaming_response_status(self, async_client: AsyncHypeman) -> None: + async with async_client.instances.auto_standby.with_streaming_response.status( + "id", + ) as response: + assert not response.is_closed + assert response.http_request.headers.get("X-Stainless-Lang") == "python" + + auto_standby = await response.parse() + assert_matches_type(AutoStandbyStatus, auto_standby, 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: AsyncHypeman) -> None: + with pytest.raises(ValueError, match=r"Expected a non-empty value for `id` but received ''"): + await async_client.instances.auto_standby.with_raw_response.status( + "", + ) diff --git a/tests/api_resources/instances/test_snapshot_schedule.py b/tests/api_resources/instances/test_snapshot_schedule.py new file mode 100644 index 0000000..d0ccdd3 --- /dev/null +++ b/tests/api_resources/instances/test_snapshot_schedule.py @@ -0,0 +1,328 @@ +# 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 hypeman import Hypeman, AsyncHypeman +from tests.utils import assert_matches_type +from hypeman.types import SnapshotSchedule + +base_url = os.environ.get("TEST_API_BASE_URL", "http://127.0.0.1:4010") + + +class TestSnapshotSchedule: + 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_update(self, client: Hypeman) -> None: + snapshot_schedule = client.instances.snapshot_schedule.update( + id="id", + interval="24h", + retention={}, + ) + assert_matches_type(SnapshotSchedule, snapshot_schedule, path=["response"]) + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + def test_method_update_with_all_params(self, client: Hypeman) -> None: + snapshot_schedule = client.instances.snapshot_schedule.update( + id="id", + interval="24h", + retention={ + "max_age": "168h", + "max_count": 7, + }, + metadata={ + "team": "backend", + "env": "staging", + }, + name_prefix="nightly", + ) + assert_matches_type(SnapshotSchedule, snapshot_schedule, path=["response"]) + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + def test_raw_response_update(self, client: Hypeman) -> None: + response = client.instances.snapshot_schedule.with_raw_response.update( + id="id", + interval="24h", + retention={}, + ) + + assert response.is_closed is True + assert response.http_request.headers.get("X-Stainless-Lang") == "python" + snapshot_schedule = response.parse() + assert_matches_type(SnapshotSchedule, snapshot_schedule, path=["response"]) + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + def test_streaming_response_update(self, client: Hypeman) -> None: + with client.instances.snapshot_schedule.with_streaming_response.update( + id="id", + interval="24h", + retention={}, + ) as response: + assert not response.is_closed + assert response.http_request.headers.get("X-Stainless-Lang") == "python" + + snapshot_schedule = response.parse() + assert_matches_type(SnapshotSchedule, snapshot_schedule, 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: Hypeman) -> None: + with pytest.raises(ValueError, match=r"Expected a non-empty value for `id` but received ''"): + client.instances.snapshot_schedule.with_raw_response.update( + id="", + interval="24h", + retention={}, + ) + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + def test_method_delete(self, client: Hypeman) -> None: + snapshot_schedule = client.instances.snapshot_schedule.delete( + "id", + ) + assert snapshot_schedule is None + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + def test_raw_response_delete(self, client: Hypeman) -> None: + response = client.instances.snapshot_schedule.with_raw_response.delete( + "id", + ) + + assert response.is_closed is True + assert response.http_request.headers.get("X-Stainless-Lang") == "python" + snapshot_schedule = response.parse() + assert snapshot_schedule is None + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + def test_streaming_response_delete(self, client: Hypeman) -> None: + with client.instances.snapshot_schedule.with_streaming_response.delete( + "id", + ) as response: + assert not response.is_closed + assert response.http_request.headers.get("X-Stainless-Lang") == "python" + + snapshot_schedule = response.parse() + assert snapshot_schedule 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: Hypeman) -> None: + with pytest.raises(ValueError, match=r"Expected a non-empty value for `id` but received ''"): + client.instances.snapshot_schedule.with_raw_response.delete( + "", + ) + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + def test_method_get(self, client: Hypeman) -> None: + snapshot_schedule = client.instances.snapshot_schedule.get( + "id", + ) + assert_matches_type(SnapshotSchedule, snapshot_schedule, path=["response"]) + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + def test_raw_response_get(self, client: Hypeman) -> None: + response = client.instances.snapshot_schedule.with_raw_response.get( + "id", + ) + + assert response.is_closed is True + assert response.http_request.headers.get("X-Stainless-Lang") == "python" + snapshot_schedule = response.parse() + assert_matches_type(SnapshotSchedule, snapshot_schedule, path=["response"]) + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + def test_streaming_response_get(self, client: Hypeman) -> None: + with client.instances.snapshot_schedule.with_streaming_response.get( + "id", + ) as response: + assert not response.is_closed + assert response.http_request.headers.get("X-Stainless-Lang") == "python" + + snapshot_schedule = response.parse() + assert_matches_type(SnapshotSchedule, snapshot_schedule, 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: Hypeman) -> None: + with pytest.raises(ValueError, match=r"Expected a non-empty value for `id` but received ''"): + client.instances.snapshot_schedule.with_raw_response.get( + "", + ) + + +class TestAsyncSnapshotSchedule: + 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_update(self, async_client: AsyncHypeman) -> None: + snapshot_schedule = await async_client.instances.snapshot_schedule.update( + id="id", + interval="24h", + retention={}, + ) + assert_matches_type(SnapshotSchedule, snapshot_schedule, path=["response"]) + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + async def test_method_update_with_all_params(self, async_client: AsyncHypeman) -> None: + snapshot_schedule = await async_client.instances.snapshot_schedule.update( + id="id", + interval="24h", + retention={ + "max_age": "168h", + "max_count": 7, + }, + metadata={ + "team": "backend", + "env": "staging", + }, + name_prefix="nightly", + ) + assert_matches_type(SnapshotSchedule, snapshot_schedule, path=["response"]) + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + async def test_raw_response_update(self, async_client: AsyncHypeman) -> None: + response = await async_client.instances.snapshot_schedule.with_raw_response.update( + id="id", + interval="24h", + retention={}, + ) + + assert response.is_closed is True + assert response.http_request.headers.get("X-Stainless-Lang") == "python" + snapshot_schedule = await response.parse() + assert_matches_type(SnapshotSchedule, snapshot_schedule, path=["response"]) + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + async def test_streaming_response_update(self, async_client: AsyncHypeman) -> None: + async with async_client.instances.snapshot_schedule.with_streaming_response.update( + id="id", + interval="24h", + retention={}, + ) as response: + assert not response.is_closed + assert response.http_request.headers.get("X-Stainless-Lang") == "python" + + snapshot_schedule = await response.parse() + assert_matches_type(SnapshotSchedule, snapshot_schedule, 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: AsyncHypeman) -> None: + with pytest.raises(ValueError, match=r"Expected a non-empty value for `id` but received ''"): + await async_client.instances.snapshot_schedule.with_raw_response.update( + id="", + interval="24h", + retention={}, + ) + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + async def test_method_delete(self, async_client: AsyncHypeman) -> None: + snapshot_schedule = await async_client.instances.snapshot_schedule.delete( + "id", + ) + assert snapshot_schedule is None + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + async def test_raw_response_delete(self, async_client: AsyncHypeman) -> None: + response = await async_client.instances.snapshot_schedule.with_raw_response.delete( + "id", + ) + + assert response.is_closed is True + assert response.http_request.headers.get("X-Stainless-Lang") == "python" + snapshot_schedule = await response.parse() + assert snapshot_schedule is None + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + async def test_streaming_response_delete(self, async_client: AsyncHypeman) -> None: + async with async_client.instances.snapshot_schedule.with_streaming_response.delete( + "id", + ) as response: + assert not response.is_closed + assert response.http_request.headers.get("X-Stainless-Lang") == "python" + + snapshot_schedule = await response.parse() + assert snapshot_schedule 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: AsyncHypeman) -> None: + with pytest.raises(ValueError, match=r"Expected a non-empty value for `id` but received ''"): + await async_client.instances.snapshot_schedule.with_raw_response.delete( + "", + ) + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + async def test_method_get(self, async_client: AsyncHypeman) -> None: + snapshot_schedule = await async_client.instances.snapshot_schedule.get( + "id", + ) + assert_matches_type(SnapshotSchedule, snapshot_schedule, path=["response"]) + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + async def test_raw_response_get(self, async_client: AsyncHypeman) -> None: + response = await async_client.instances.snapshot_schedule.with_raw_response.get( + "id", + ) + + assert response.is_closed is True + assert response.http_request.headers.get("X-Stainless-Lang") == "python" + snapshot_schedule = await response.parse() + assert_matches_type(SnapshotSchedule, snapshot_schedule, path=["response"]) + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + async def test_streaming_response_get(self, async_client: AsyncHypeman) -> None: + async with async_client.instances.snapshot_schedule.with_streaming_response.get( + "id", + ) as response: + assert not response.is_closed + assert response.http_request.headers.get("X-Stainless-Lang") == "python" + + snapshot_schedule = await response.parse() + assert_matches_type(SnapshotSchedule, snapshot_schedule, 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: AsyncHypeman) -> None: + with pytest.raises(ValueError, match=r"Expected a non-empty value for `id` but received ''"): + await async_client.instances.snapshot_schedule.with_raw_response.get( + "", + ) diff --git a/tests/api_resources/instances/test_snapshots.py b/tests/api_resources/instances/test_snapshots.py new file mode 100644 index 0000000..312ec2d --- /dev/null +++ b/tests/api_resources/instances/test_snapshots.py @@ -0,0 +1,280 @@ +# 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 hypeman import Hypeman, AsyncHypeman +from tests.utils import assert_matches_type +from hypeman.types import Instance, Snapshot + +base_url = os.environ.get("TEST_API_BASE_URL", "http://127.0.0.1:4010") + + +class TestSnapshots: + 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: Hypeman) -> None: + snapshot = client.instances.snapshots.create( + id="id", + kind="Standby", + ) + assert_matches_type(Snapshot, snapshot, path=["response"]) + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + def test_method_create_with_all_params(self, client: Hypeman) -> None: + snapshot = client.instances.snapshots.create( + id="id", + kind="Standby", + compression={ + "enabled": True, + "algorithm": "zstd", + "level": 1, + }, + name="pre-upgrade", + tags={ + "team": "backend", + "env": "staging", + }, + ) + assert_matches_type(Snapshot, snapshot, path=["response"]) + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + def test_raw_response_create(self, client: Hypeman) -> None: + response = client.instances.snapshots.with_raw_response.create( + id="id", + kind="Standby", + ) + + assert response.is_closed is True + assert response.http_request.headers.get("X-Stainless-Lang") == "python" + snapshot = response.parse() + assert_matches_type(Snapshot, snapshot, path=["response"]) + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + def test_streaming_response_create(self, client: Hypeman) -> None: + with client.instances.snapshots.with_streaming_response.create( + id="id", + kind="Standby", + ) as response: + assert not response.is_closed + assert response.http_request.headers.get("X-Stainless-Lang") == "python" + + snapshot = response.parse() + assert_matches_type(Snapshot, snapshot, path=["response"]) + + assert cast(Any, response.is_closed) is True + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + def test_path_params_create(self, client: Hypeman) -> None: + with pytest.raises(ValueError, match=r"Expected a non-empty value for `id` but received ''"): + client.instances.snapshots.with_raw_response.create( + id="", + kind="Standby", + ) + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + def test_method_restore(self, client: Hypeman) -> None: + snapshot = client.instances.snapshots.restore( + snapshot_id="snapshotId", + id="id", + ) + assert_matches_type(Instance, snapshot, path=["response"]) + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + def test_method_restore_with_all_params(self, client: Hypeman) -> None: + snapshot = client.instances.snapshots.restore( + snapshot_id="snapshotId", + id="id", + target_hypervisor="qemu", + target_state="Running", + ) + assert_matches_type(Instance, snapshot, path=["response"]) + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + def test_raw_response_restore(self, client: Hypeman) -> None: + response = client.instances.snapshots.with_raw_response.restore( + snapshot_id="snapshotId", + id="id", + ) + + assert response.is_closed is True + assert response.http_request.headers.get("X-Stainless-Lang") == "python" + snapshot = response.parse() + assert_matches_type(Instance, snapshot, path=["response"]) + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + def test_streaming_response_restore(self, client: Hypeman) -> None: + with client.instances.snapshots.with_streaming_response.restore( + snapshot_id="snapshotId", + id="id", + ) as response: + assert not response.is_closed + assert response.http_request.headers.get("X-Stainless-Lang") == "python" + + snapshot = response.parse() + assert_matches_type(Instance, snapshot, path=["response"]) + + assert cast(Any, response.is_closed) is True + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + def test_path_params_restore(self, client: Hypeman) -> None: + with pytest.raises(ValueError, match=r"Expected a non-empty value for `id` but received ''"): + client.instances.snapshots.with_raw_response.restore( + snapshot_id="snapshotId", + id="", + ) + + with pytest.raises(ValueError, match=r"Expected a non-empty value for `snapshot_id` but received ''"): + client.instances.snapshots.with_raw_response.restore( + snapshot_id="", + id="id", + ) + + +class TestAsyncSnapshots: + 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: AsyncHypeman) -> None: + snapshot = await async_client.instances.snapshots.create( + id="id", + kind="Standby", + ) + assert_matches_type(Snapshot, snapshot, path=["response"]) + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + async def test_method_create_with_all_params(self, async_client: AsyncHypeman) -> None: + snapshot = await async_client.instances.snapshots.create( + id="id", + kind="Standby", + compression={ + "enabled": True, + "algorithm": "zstd", + "level": 1, + }, + name="pre-upgrade", + tags={ + "team": "backend", + "env": "staging", + }, + ) + assert_matches_type(Snapshot, snapshot, path=["response"]) + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + async def test_raw_response_create(self, async_client: AsyncHypeman) -> None: + response = await async_client.instances.snapshots.with_raw_response.create( + id="id", + kind="Standby", + ) + + assert response.is_closed is True + assert response.http_request.headers.get("X-Stainless-Lang") == "python" + snapshot = await response.parse() + assert_matches_type(Snapshot, snapshot, path=["response"]) + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + async def test_streaming_response_create(self, async_client: AsyncHypeman) -> None: + async with async_client.instances.snapshots.with_streaming_response.create( + id="id", + kind="Standby", + ) as response: + assert not response.is_closed + assert response.http_request.headers.get("X-Stainless-Lang") == "python" + + snapshot = await response.parse() + assert_matches_type(Snapshot, snapshot, 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_create(self, async_client: AsyncHypeman) -> None: + with pytest.raises(ValueError, match=r"Expected a non-empty value for `id` but received ''"): + await async_client.instances.snapshots.with_raw_response.create( + id="", + kind="Standby", + ) + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + async def test_method_restore(self, async_client: AsyncHypeman) -> None: + snapshot = await async_client.instances.snapshots.restore( + snapshot_id="snapshotId", + id="id", + ) + assert_matches_type(Instance, snapshot, path=["response"]) + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + async def test_method_restore_with_all_params(self, async_client: AsyncHypeman) -> None: + snapshot = await async_client.instances.snapshots.restore( + snapshot_id="snapshotId", + id="id", + target_hypervisor="qemu", + target_state="Running", + ) + assert_matches_type(Instance, snapshot, path=["response"]) + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + async def test_raw_response_restore(self, async_client: AsyncHypeman) -> None: + response = await async_client.instances.snapshots.with_raw_response.restore( + snapshot_id="snapshotId", + id="id", + ) + + assert response.is_closed is True + assert response.http_request.headers.get("X-Stainless-Lang") == "python" + snapshot = await response.parse() + assert_matches_type(Instance, snapshot, path=["response"]) + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + async def test_streaming_response_restore(self, async_client: AsyncHypeman) -> None: + async with async_client.instances.snapshots.with_streaming_response.restore( + snapshot_id="snapshotId", + id="id", + ) as response: + assert not response.is_closed + assert response.http_request.headers.get("X-Stainless-Lang") == "python" + + snapshot = await response.parse() + assert_matches_type(Instance, snapshot, 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_restore(self, async_client: AsyncHypeman) -> None: + with pytest.raises(ValueError, match=r"Expected a non-empty value for `id` but received ''"): + await async_client.instances.snapshots.with_raw_response.restore( + snapshot_id="snapshotId", + id="", + ) + + with pytest.raises(ValueError, match=r"Expected a non-empty value for `snapshot_id` but received ''"): + await async_client.instances.snapshots.with_raw_response.restore( + snapshot_id="", + id="id", + ) diff --git a/tests/api_resources/instances/test_volumes.py b/tests/api_resources/instances/test_volumes.py new file mode 100644 index 0000000..24cfac4 --- /dev/null +++ b/tests/api_resources/instances/test_volumes.py @@ -0,0 +1,264 @@ +# 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 hypeman import Hypeman, AsyncHypeman +from tests.utils import assert_matches_type +from hypeman.types import Instance + +base_url = os.environ.get("TEST_API_BASE_URL", "http://127.0.0.1:4010") + + +class TestVolumes: + 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_attach(self, client: Hypeman) -> None: + volume = client.instances.volumes.attach( + volume_id="volumeId", + id="id", + mount_path="/mnt/data", + ) + assert_matches_type(Instance, volume, path=["response"]) + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + def test_method_attach_with_all_params(self, client: Hypeman) -> None: + volume = client.instances.volumes.attach( + volume_id="volumeId", + id="id", + mount_path="/mnt/data", + readonly=True, + ) + assert_matches_type(Instance, volume, path=["response"]) + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + def test_raw_response_attach(self, client: Hypeman) -> None: + response = client.instances.volumes.with_raw_response.attach( + volume_id="volumeId", + id="id", + mount_path="/mnt/data", + ) + + assert response.is_closed is True + assert response.http_request.headers.get("X-Stainless-Lang") == "python" + volume = response.parse() + assert_matches_type(Instance, volume, path=["response"]) + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + def test_streaming_response_attach(self, client: Hypeman) -> None: + with client.instances.volumes.with_streaming_response.attach( + volume_id="volumeId", + id="id", + mount_path="/mnt/data", + ) as response: + assert not response.is_closed + assert response.http_request.headers.get("X-Stainless-Lang") == "python" + + volume = response.parse() + assert_matches_type(Instance, volume, path=["response"]) + + assert cast(Any, response.is_closed) is True + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + def test_path_params_attach(self, client: Hypeman) -> None: + with pytest.raises(ValueError, match=r"Expected a non-empty value for `id` but received ''"): + client.instances.volumes.with_raw_response.attach( + volume_id="volumeId", + id="", + mount_path="/mnt/data", + ) + + with pytest.raises(ValueError, match=r"Expected a non-empty value for `volume_id` but received ''"): + client.instances.volumes.with_raw_response.attach( + volume_id="", + id="id", + mount_path="/mnt/data", + ) + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + def test_method_detach(self, client: Hypeman) -> None: + volume = client.instances.volumes.detach( + volume_id="volumeId", + id="id", + ) + assert_matches_type(Instance, volume, path=["response"]) + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + def test_raw_response_detach(self, client: Hypeman) -> None: + response = client.instances.volumes.with_raw_response.detach( + volume_id="volumeId", + id="id", + ) + + assert response.is_closed is True + assert response.http_request.headers.get("X-Stainless-Lang") == "python" + volume = response.parse() + assert_matches_type(Instance, volume, path=["response"]) + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + def test_streaming_response_detach(self, client: Hypeman) -> None: + with client.instances.volumes.with_streaming_response.detach( + volume_id="volumeId", + id="id", + ) as response: + assert not response.is_closed + assert response.http_request.headers.get("X-Stainless-Lang") == "python" + + volume = response.parse() + assert_matches_type(Instance, volume, path=["response"]) + + assert cast(Any, response.is_closed) is True + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + def test_path_params_detach(self, client: Hypeman) -> None: + with pytest.raises(ValueError, match=r"Expected a non-empty value for `id` but received ''"): + client.instances.volumes.with_raw_response.detach( + volume_id="volumeId", + id="", + ) + + with pytest.raises(ValueError, match=r"Expected a non-empty value for `volume_id` but received ''"): + client.instances.volumes.with_raw_response.detach( + volume_id="", + id="id", + ) + + +class TestAsyncVolumes: + 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_attach(self, async_client: AsyncHypeman) -> None: + volume = await async_client.instances.volumes.attach( + volume_id="volumeId", + id="id", + mount_path="/mnt/data", + ) + assert_matches_type(Instance, volume, path=["response"]) + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + async def test_method_attach_with_all_params(self, async_client: AsyncHypeman) -> None: + volume = await async_client.instances.volumes.attach( + volume_id="volumeId", + id="id", + mount_path="/mnt/data", + readonly=True, + ) + assert_matches_type(Instance, volume, path=["response"]) + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + async def test_raw_response_attach(self, async_client: AsyncHypeman) -> None: + response = await async_client.instances.volumes.with_raw_response.attach( + volume_id="volumeId", + id="id", + mount_path="/mnt/data", + ) + + assert response.is_closed is True + assert response.http_request.headers.get("X-Stainless-Lang") == "python" + volume = await response.parse() + assert_matches_type(Instance, volume, path=["response"]) + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + async def test_streaming_response_attach(self, async_client: AsyncHypeman) -> None: + async with async_client.instances.volumes.with_streaming_response.attach( + volume_id="volumeId", + id="id", + mount_path="/mnt/data", + ) as response: + assert not response.is_closed + assert response.http_request.headers.get("X-Stainless-Lang") == "python" + + volume = await response.parse() + assert_matches_type(Instance, volume, 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_attach(self, async_client: AsyncHypeman) -> None: + with pytest.raises(ValueError, match=r"Expected a non-empty value for `id` but received ''"): + await async_client.instances.volumes.with_raw_response.attach( + volume_id="volumeId", + id="", + mount_path="/mnt/data", + ) + + with pytest.raises(ValueError, match=r"Expected a non-empty value for `volume_id` but received ''"): + await async_client.instances.volumes.with_raw_response.attach( + volume_id="", + id="id", + mount_path="/mnt/data", + ) + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + async def test_method_detach(self, async_client: AsyncHypeman) -> None: + volume = await async_client.instances.volumes.detach( + volume_id="volumeId", + id="id", + ) + assert_matches_type(Instance, volume, path=["response"]) + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + async def test_raw_response_detach(self, async_client: AsyncHypeman) -> None: + response = await async_client.instances.volumes.with_raw_response.detach( + volume_id="volumeId", + id="id", + ) + + assert response.is_closed is True + assert response.http_request.headers.get("X-Stainless-Lang") == "python" + volume = await response.parse() + assert_matches_type(Instance, volume, path=["response"]) + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + async def test_streaming_response_detach(self, async_client: AsyncHypeman) -> None: + async with async_client.instances.volumes.with_streaming_response.detach( + volume_id="volumeId", + id="id", + ) as response: + assert not response.is_closed + assert response.http_request.headers.get("X-Stainless-Lang") == "python" + + volume = await response.parse() + assert_matches_type(Instance, volume, 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_detach(self, async_client: AsyncHypeman) -> None: + with pytest.raises(ValueError, match=r"Expected a non-empty value for `id` but received ''"): + await async_client.instances.volumes.with_raw_response.detach( + volume_id="volumeId", + id="", + ) + + with pytest.raises(ValueError, match=r"Expected a non-empty value for `volume_id` but received ''"): + await async_client.instances.volumes.with_raw_response.detach( + volume_id="", + id="id", + ) diff --git a/tests/api_resources/test_builders.py b/tests/api_resources/test_builders.py new file mode 100644 index 0000000..56a9612 --- /dev/null +++ b/tests/api_resources/test_builders.py @@ -0,0 +1,438 @@ +# 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 hypeman import Hypeman, AsyncHypeman +from tests.utils import assert_matches_type +from hypeman.types import Builder, BuilderListResponse + +base_url = os.environ.get("TEST_API_BASE_URL", "http://127.0.0.1:4010") + + +class TestBuilders: + 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: Hypeman) -> None: + builder = client.builders.create() + assert_matches_type(Builder, builder, path=["response"]) + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + def test_method_create_with_all_params(self, client: Hypeman) -> None: + builder = client.builders.create( + id="team-cache-1", + disk_size_gb=50, + name="team-cache", + tags={ + "team": "backend", + "env": "staging", + }, + ) + assert_matches_type(Builder, builder, path=["response"]) + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + def test_raw_response_create(self, client: Hypeman) -> None: + response = client.builders.with_raw_response.create() + + assert response.is_closed is True + assert response.http_request.headers.get("X-Stainless-Lang") == "python" + builder = response.parse() + assert_matches_type(Builder, builder, path=["response"]) + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + def test_streaming_response_create(self, client: Hypeman) -> None: + with client.builders.with_streaming_response.create() as response: + assert not response.is_closed + assert response.http_request.headers.get("X-Stainless-Lang") == "python" + + builder = response.parse() + assert_matches_type(Builder, builder, path=["response"]) + + assert cast(Any, response.is_closed) is True + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + def test_method_list(self, client: Hypeman) -> None: + builder = client.builders.list() + assert_matches_type(BuilderListResponse, builder, path=["response"]) + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + def test_method_list_with_all_params(self, client: Hypeman) -> None: + builder = client.builders.list( + tags={ + "team": "backend", + "env": "staging", + }, + ) + assert_matches_type(BuilderListResponse, builder, path=["response"]) + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + def test_raw_response_list(self, client: Hypeman) -> None: + response = client.builders.with_raw_response.list() + + assert response.is_closed is True + assert response.http_request.headers.get("X-Stainless-Lang") == "python" + builder = response.parse() + assert_matches_type(BuilderListResponse, builder, path=["response"]) + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + def test_streaming_response_list(self, client: Hypeman) -> None: + with client.builders.with_streaming_response.list() as response: + assert not response.is_closed + assert response.http_request.headers.get("X-Stainless-Lang") == "python" + + builder = response.parse() + assert_matches_type(BuilderListResponse, builder, 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: Hypeman) -> None: + builder = client.builders.delete( + "id", + ) + assert builder is None + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + def test_raw_response_delete(self, client: Hypeman) -> None: + response = client.builders.with_raw_response.delete( + "id", + ) + + assert response.is_closed is True + assert response.http_request.headers.get("X-Stainless-Lang") == "python" + builder = response.parse() + assert builder is None + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + def test_streaming_response_delete(self, client: Hypeman) -> None: + with client.builders.with_streaming_response.delete( + "id", + ) as response: + assert not response.is_closed + assert response.http_request.headers.get("X-Stainless-Lang") == "python" + + builder = response.parse() + assert builder 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: Hypeman) -> None: + with pytest.raises(ValueError, match=r"Expected a non-empty value for `id` but received ''"): + client.builders.with_raw_response.delete( + "", + ) + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + def test_method_get(self, client: Hypeman) -> None: + builder = client.builders.get( + "id", + ) + assert_matches_type(Builder, builder, path=["response"]) + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + def test_raw_response_get(self, client: Hypeman) -> None: + response = client.builders.with_raw_response.get( + "id", + ) + + assert response.is_closed is True + assert response.http_request.headers.get("X-Stainless-Lang") == "python" + builder = response.parse() + assert_matches_type(Builder, builder, path=["response"]) + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + def test_streaming_response_get(self, client: Hypeman) -> None: + with client.builders.with_streaming_response.get( + "id", + ) as response: + assert not response.is_closed + assert response.http_request.headers.get("X-Stainless-Lang") == "python" + + builder = response.parse() + assert_matches_type(Builder, builder, 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: Hypeman) -> None: + with pytest.raises(ValueError, match=r"Expected a non-empty value for `id` but received ''"): + client.builders.with_raw_response.get( + "", + ) + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + def test_method_prune(self, client: Hypeman) -> None: + builder = client.builders.prune( + "id", + ) + assert_matches_type(Builder, builder, path=["response"]) + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + def test_raw_response_prune(self, client: Hypeman) -> None: + response = client.builders.with_raw_response.prune( + "id", + ) + + assert response.is_closed is True + assert response.http_request.headers.get("X-Stainless-Lang") == "python" + builder = response.parse() + assert_matches_type(Builder, builder, path=["response"]) + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + def test_streaming_response_prune(self, client: Hypeman) -> None: + with client.builders.with_streaming_response.prune( + "id", + ) as response: + assert not response.is_closed + assert response.http_request.headers.get("X-Stainless-Lang") == "python" + + builder = response.parse() + assert_matches_type(Builder, builder, path=["response"]) + + assert cast(Any, response.is_closed) is True + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + def test_path_params_prune(self, client: Hypeman) -> None: + with pytest.raises(ValueError, match=r"Expected a non-empty value for `id` but received ''"): + client.builders.with_raw_response.prune( + "", + ) + + +class TestAsyncBuilders: + 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: AsyncHypeman) -> None: + builder = await async_client.builders.create() + assert_matches_type(Builder, builder, path=["response"]) + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + async def test_method_create_with_all_params(self, async_client: AsyncHypeman) -> None: + builder = await async_client.builders.create( + id="team-cache-1", + disk_size_gb=50, + name="team-cache", + tags={ + "team": "backend", + "env": "staging", + }, + ) + assert_matches_type(Builder, builder, path=["response"]) + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + async def test_raw_response_create(self, async_client: AsyncHypeman) -> None: + response = await async_client.builders.with_raw_response.create() + + assert response.is_closed is True + assert response.http_request.headers.get("X-Stainless-Lang") == "python" + builder = await response.parse() + assert_matches_type(Builder, builder, path=["response"]) + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + async def test_streaming_response_create(self, async_client: AsyncHypeman) -> None: + async with async_client.builders.with_streaming_response.create() as response: + assert not response.is_closed + assert response.http_request.headers.get("X-Stainless-Lang") == "python" + + builder = await response.parse() + assert_matches_type(Builder, builder, path=["response"]) + + assert cast(Any, response.is_closed) is True + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + async def test_method_list(self, async_client: AsyncHypeman) -> None: + builder = await async_client.builders.list() + assert_matches_type(BuilderListResponse, builder, path=["response"]) + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + async def test_method_list_with_all_params(self, async_client: AsyncHypeman) -> None: + builder = await async_client.builders.list( + tags={ + "team": "backend", + "env": "staging", + }, + ) + assert_matches_type(BuilderListResponse, builder, path=["response"]) + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + async def test_raw_response_list(self, async_client: AsyncHypeman) -> None: + response = await async_client.builders.with_raw_response.list() + + assert response.is_closed is True + assert response.http_request.headers.get("X-Stainless-Lang") == "python" + builder = await response.parse() + assert_matches_type(BuilderListResponse, builder, path=["response"]) + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + async def test_streaming_response_list(self, async_client: AsyncHypeman) -> None: + async with async_client.builders.with_streaming_response.list() as response: + assert not response.is_closed + assert response.http_request.headers.get("X-Stainless-Lang") == "python" + + builder = await response.parse() + assert_matches_type(BuilderListResponse, builder, 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: AsyncHypeman) -> None: + builder = await async_client.builders.delete( + "id", + ) + assert builder is None + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + async def test_raw_response_delete(self, async_client: AsyncHypeman) -> None: + response = await async_client.builders.with_raw_response.delete( + "id", + ) + + assert response.is_closed is True + assert response.http_request.headers.get("X-Stainless-Lang") == "python" + builder = await response.parse() + assert builder is None + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + async def test_streaming_response_delete(self, async_client: AsyncHypeman) -> None: + async with async_client.builders.with_streaming_response.delete( + "id", + ) as response: + assert not response.is_closed + assert response.http_request.headers.get("X-Stainless-Lang") == "python" + + builder = await response.parse() + assert builder 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: AsyncHypeman) -> None: + with pytest.raises(ValueError, match=r"Expected a non-empty value for `id` but received ''"): + await async_client.builders.with_raw_response.delete( + "", + ) + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + async def test_method_get(self, async_client: AsyncHypeman) -> None: + builder = await async_client.builders.get( + "id", + ) + assert_matches_type(Builder, builder, path=["response"]) + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + async def test_raw_response_get(self, async_client: AsyncHypeman) -> None: + response = await async_client.builders.with_raw_response.get( + "id", + ) + + assert response.is_closed is True + assert response.http_request.headers.get("X-Stainless-Lang") == "python" + builder = await response.parse() + assert_matches_type(Builder, builder, path=["response"]) + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + async def test_streaming_response_get(self, async_client: AsyncHypeman) -> None: + async with async_client.builders.with_streaming_response.get( + "id", + ) as response: + assert not response.is_closed + assert response.http_request.headers.get("X-Stainless-Lang") == "python" + + builder = await response.parse() + assert_matches_type(Builder, builder, 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: AsyncHypeman) -> None: + with pytest.raises(ValueError, match=r"Expected a non-empty value for `id` but received ''"): + await async_client.builders.with_raw_response.get( + "", + ) + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + async def test_method_prune(self, async_client: AsyncHypeman) -> None: + builder = await async_client.builders.prune( + "id", + ) + assert_matches_type(Builder, builder, path=["response"]) + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + async def test_raw_response_prune(self, async_client: AsyncHypeman) -> None: + response = await async_client.builders.with_raw_response.prune( + "id", + ) + + assert response.is_closed is True + assert response.http_request.headers.get("X-Stainless-Lang") == "python" + builder = await response.parse() + assert_matches_type(Builder, builder, path=["response"]) + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + async def test_streaming_response_prune(self, async_client: AsyncHypeman) -> None: + async with async_client.builders.with_streaming_response.prune( + "id", + ) as response: + assert not response.is_closed + assert response.http_request.headers.get("X-Stainless-Lang") == "python" + + builder = await response.parse() + assert_matches_type(Builder, builder, 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_prune(self, async_client: AsyncHypeman) -> None: + with pytest.raises(ValueError, match=r"Expected a non-empty value for `id` but received ''"): + await async_client.builders.with_raw_response.prune( + "", + ) diff --git a/tests/api_resources/test_builds.py b/tests/api_resources/test_builds.py new file mode 100644 index 0000000..99e2b6a --- /dev/null +++ b/tests/api_resources/test_builds.py @@ -0,0 +1,478 @@ +# 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 hypeman import Hypeman, AsyncHypeman +from tests.utils import assert_matches_type +from hypeman.types import Build, BuildListResponse + +base_url = os.environ.get("TEST_API_BASE_URL", "http://127.0.0.1:4010") + + +class TestBuilds: + 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: Hypeman) -> None: + build = client.builds.create( + source=b"Example data", + ) + assert_matches_type(Build, build, path=["response"]) + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + def test_method_create_with_all_params(self, client: Hypeman) -> None: + build = client.builds.create( + source=b"Example data", + base_image_digest="base_image_digest", + builder_id="builder_id", + cache_scope="cache_scope", + cpus=0, + dockerfile="dockerfile", + global_cache_key="global_cache_key", + image_name="image_name", + is_admin_build="is_admin_build", + memory_mb=0, + secrets="secrets", + tags="tags", + timeout_seconds=0, + ) + assert_matches_type(Build, build, path=["response"]) + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + def test_raw_response_create(self, client: Hypeman) -> None: + response = client.builds.with_raw_response.create( + source=b"Example data", + ) + + assert response.is_closed is True + assert response.http_request.headers.get("X-Stainless-Lang") == "python" + build = response.parse() + assert_matches_type(Build, build, path=["response"]) + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + def test_streaming_response_create(self, client: Hypeman) -> None: + with client.builds.with_streaming_response.create( + source=b"Example data", + ) as response: + assert not response.is_closed + assert response.http_request.headers.get("X-Stainless-Lang") == "python" + + build = response.parse() + assert_matches_type(Build, build, path=["response"]) + + assert cast(Any, response.is_closed) is True + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + def test_method_list(self, client: Hypeman) -> None: + build = client.builds.list() + assert_matches_type(BuildListResponse, build, path=["response"]) + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + def test_method_list_with_all_params(self, client: Hypeman) -> None: + build = client.builds.list( + tags={ + "team": "backend", + "env": "staging", + }, + ) + assert_matches_type(BuildListResponse, build, path=["response"]) + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + def test_raw_response_list(self, client: Hypeman) -> None: + response = client.builds.with_raw_response.list() + + assert response.is_closed is True + assert response.http_request.headers.get("X-Stainless-Lang") == "python" + build = response.parse() + assert_matches_type(BuildListResponse, build, path=["response"]) + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + def test_streaming_response_list(self, client: Hypeman) -> None: + with client.builds.with_streaming_response.list() as response: + assert not response.is_closed + assert response.http_request.headers.get("X-Stainless-Lang") == "python" + + build = response.parse() + assert_matches_type(BuildListResponse, build, path=["response"]) + + assert cast(Any, response.is_closed) is True + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + def test_method_cancel(self, client: Hypeman) -> None: + build = client.builds.cancel( + "id", + ) + assert build is None + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + def test_raw_response_cancel(self, client: Hypeman) -> None: + response = client.builds.with_raw_response.cancel( + "id", + ) + + assert response.is_closed is True + assert response.http_request.headers.get("X-Stainless-Lang") == "python" + build = response.parse() + assert build is None + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + def test_streaming_response_cancel(self, client: Hypeman) -> None: + with client.builds.with_streaming_response.cancel( + "id", + ) as response: + assert not response.is_closed + assert response.http_request.headers.get("X-Stainless-Lang") == "python" + + build = response.parse() + assert build is None + + assert cast(Any, response.is_closed) is True + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + def test_path_params_cancel(self, client: Hypeman) -> None: + with pytest.raises(ValueError, match=r"Expected a non-empty value for `id` but received ''"): + client.builds.with_raw_response.cancel( + "", + ) + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + def test_method_events(self, client: Hypeman) -> None: + build_stream = client.builds.events( + id="id", + ) + build_stream.response.close() + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + def test_method_events_with_all_params(self, client: Hypeman) -> None: + build_stream = client.builds.events( + id="id", + follow=True, + ) + build_stream.response.close() + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + def test_raw_response_events(self, client: Hypeman) -> None: + response = client.builds.with_raw_response.events( + 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: Hypeman) -> None: + with client.builds.with_streaming_response.events( + 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: Hypeman) -> None: + with pytest.raises(ValueError, match=r"Expected a non-empty value for `id` but received ''"): + client.builds.with_raw_response.events( + id="", + ) + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + def test_method_get(self, client: Hypeman) -> None: + build = client.builds.get( + "id", + ) + assert_matches_type(Build, build, path=["response"]) + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + def test_raw_response_get(self, client: Hypeman) -> None: + response = client.builds.with_raw_response.get( + "id", + ) + + assert response.is_closed is True + assert response.http_request.headers.get("X-Stainless-Lang") == "python" + build = response.parse() + assert_matches_type(Build, build, path=["response"]) + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + def test_streaming_response_get(self, client: Hypeman) -> None: + with client.builds.with_streaming_response.get( + "id", + ) as response: + assert not response.is_closed + assert response.http_request.headers.get("X-Stainless-Lang") == "python" + + build = response.parse() + assert_matches_type(Build, build, 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: Hypeman) -> None: + with pytest.raises(ValueError, match=r"Expected a non-empty value for `id` but received ''"): + client.builds.with_raw_response.get( + "", + ) + + +class TestAsyncBuilds: + 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: AsyncHypeman) -> None: + build = await async_client.builds.create( + source=b"Example data", + ) + assert_matches_type(Build, build, path=["response"]) + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + async def test_method_create_with_all_params(self, async_client: AsyncHypeman) -> None: + build = await async_client.builds.create( + source=b"Example data", + base_image_digest="base_image_digest", + builder_id="builder_id", + cache_scope="cache_scope", + cpus=0, + dockerfile="dockerfile", + global_cache_key="global_cache_key", + image_name="image_name", + is_admin_build="is_admin_build", + memory_mb=0, + secrets="secrets", + tags="tags", + timeout_seconds=0, + ) + assert_matches_type(Build, build, path=["response"]) + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + async def test_raw_response_create(self, async_client: AsyncHypeman) -> None: + response = await async_client.builds.with_raw_response.create( + source=b"Example data", + ) + + assert response.is_closed is True + assert response.http_request.headers.get("X-Stainless-Lang") == "python" + build = await response.parse() + assert_matches_type(Build, build, path=["response"]) + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + async def test_streaming_response_create(self, async_client: AsyncHypeman) -> None: + async with async_client.builds.with_streaming_response.create( + source=b"Example data", + ) as response: + assert not response.is_closed + assert response.http_request.headers.get("X-Stainless-Lang") == "python" + + build = await response.parse() + assert_matches_type(Build, build, path=["response"]) + + assert cast(Any, response.is_closed) is True + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + async def test_method_list(self, async_client: AsyncHypeman) -> None: + build = await async_client.builds.list() + assert_matches_type(BuildListResponse, build, path=["response"]) + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + async def test_method_list_with_all_params(self, async_client: AsyncHypeman) -> None: + build = await async_client.builds.list( + tags={ + "team": "backend", + "env": "staging", + }, + ) + assert_matches_type(BuildListResponse, build, path=["response"]) + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + async def test_raw_response_list(self, async_client: AsyncHypeman) -> None: + response = await async_client.builds.with_raw_response.list() + + assert response.is_closed is True + assert response.http_request.headers.get("X-Stainless-Lang") == "python" + build = await response.parse() + assert_matches_type(BuildListResponse, build, path=["response"]) + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + async def test_streaming_response_list(self, async_client: AsyncHypeman) -> None: + async with async_client.builds.with_streaming_response.list() as response: + assert not response.is_closed + assert response.http_request.headers.get("X-Stainless-Lang") == "python" + + build = await response.parse() + assert_matches_type(BuildListResponse, build, path=["response"]) + + assert cast(Any, response.is_closed) is True + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + async def test_method_cancel(self, async_client: AsyncHypeman) -> None: + build = await async_client.builds.cancel( + "id", + ) + assert build is None + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + async def test_raw_response_cancel(self, async_client: AsyncHypeman) -> None: + response = await async_client.builds.with_raw_response.cancel( + "id", + ) + + assert response.is_closed is True + assert response.http_request.headers.get("X-Stainless-Lang") == "python" + build = await response.parse() + assert build is None + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + async def test_streaming_response_cancel(self, async_client: AsyncHypeman) -> None: + async with async_client.builds.with_streaming_response.cancel( + "id", + ) as response: + assert not response.is_closed + assert response.http_request.headers.get("X-Stainless-Lang") == "python" + + build = await response.parse() + assert build 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_cancel(self, async_client: AsyncHypeman) -> None: + with pytest.raises(ValueError, match=r"Expected a non-empty value for `id` but received ''"): + await async_client.builds.with_raw_response.cancel( + "", + ) + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + async def test_method_events(self, async_client: AsyncHypeman) -> None: + build_stream = await async_client.builds.events( + id="id", + ) + await build_stream.response.aclose() + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + async def test_method_events_with_all_params(self, async_client: AsyncHypeman) -> None: + build_stream = await async_client.builds.events( + id="id", + follow=True, + ) + await build_stream.response.aclose() + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + async def test_raw_response_events(self, async_client: AsyncHypeman) -> None: + response = await async_client.builds.with_raw_response.events( + 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: AsyncHypeman) -> None: + async with async_client.builds.with_streaming_response.events( + 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: AsyncHypeman) -> None: + with pytest.raises(ValueError, match=r"Expected a non-empty value for `id` but received ''"): + await async_client.builds.with_raw_response.events( + id="", + ) + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + async def test_method_get(self, async_client: AsyncHypeman) -> None: + build = await async_client.builds.get( + "id", + ) + assert_matches_type(Build, build, path=["response"]) + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + async def test_raw_response_get(self, async_client: AsyncHypeman) -> None: + response = await async_client.builds.with_raw_response.get( + "id", + ) + + assert response.is_closed is True + assert response.http_request.headers.get("X-Stainless-Lang") == "python" + build = await response.parse() + assert_matches_type(Build, build, path=["response"]) + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + async def test_streaming_response_get(self, async_client: AsyncHypeman) -> None: + async with async_client.builds.with_streaming_response.get( + "id", + ) as response: + assert not response.is_closed + assert response.http_request.headers.get("X-Stainless-Lang") == "python" + + build = await response.parse() + assert_matches_type(Build, build, 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: AsyncHypeman) -> None: + with pytest.raises(ValueError, match=r"Expected a non-empty value for `id` but received ''"): + await async_client.builds.with_raw_response.get( + "", + ) diff --git a/tests/api_resources/test_capabilities.py b/tests/api_resources/test_capabilities.py new file mode 100644 index 0000000..c2d6130 --- /dev/null +++ b/tests/api_resources/test_capabilities.py @@ -0,0 +1,80 @@ +# 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 hypeman import Hypeman, AsyncHypeman +from tests.utils import assert_matches_type +from hypeman.types import Capabilities + +base_url = os.environ.get("TEST_API_BASE_URL", "http://127.0.0.1:4010") + + +class TestCapabilities: + 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_get(self, client: Hypeman) -> None: + capability = client.capabilities.get() + assert_matches_type(Capabilities, capability, path=["response"]) + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + def test_raw_response_get(self, client: Hypeman) -> None: + response = client.capabilities.with_raw_response.get() + + assert response.is_closed is True + assert response.http_request.headers.get("X-Stainless-Lang") == "python" + capability = response.parse() + assert_matches_type(Capabilities, capability, path=["response"]) + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + def test_streaming_response_get(self, client: Hypeman) -> None: + with client.capabilities.with_streaming_response.get() as response: + assert not response.is_closed + assert response.http_request.headers.get("X-Stainless-Lang") == "python" + + capability = response.parse() + assert_matches_type(Capabilities, capability, path=["response"]) + + assert cast(Any, response.is_closed) is True + + +class TestAsyncCapabilities: + 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_get(self, async_client: AsyncHypeman) -> None: + capability = await async_client.capabilities.get() + assert_matches_type(Capabilities, capability, path=["response"]) + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + async def test_raw_response_get(self, async_client: AsyncHypeman) -> None: + response = await async_client.capabilities.with_raw_response.get() + + assert response.is_closed is True + assert response.http_request.headers.get("X-Stainless-Lang") == "python" + capability = await response.parse() + assert_matches_type(Capabilities, capability, path=["response"]) + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + async def test_streaming_response_get(self, async_client: AsyncHypeman) -> None: + async with async_client.capabilities.with_streaming_response.get() as response: + assert not response.is_closed + assert response.http_request.headers.get("X-Stainless-Lang") == "python" + + capability = await response.parse() + assert_matches_type(Capabilities, capability, path=["response"]) + + assert cast(Any, response.is_closed) is True diff --git a/tests/api_resources/test_devices.py b/tests/api_resources/test_devices.py new file mode 100644 index 0000000..9926514 --- /dev/null +++ b/tests/api_resources/test_devices.py @@ -0,0 +1,424 @@ +# 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 hypeman import Hypeman, AsyncHypeman +from tests.utils import assert_matches_type +from hypeman.types import ( + Device, + DeviceListResponse, + DeviceListAvailableResponse, +) + +base_url = os.environ.get("TEST_API_BASE_URL", "http://127.0.0.1:4010") + + +class TestDevices: + 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: Hypeman) -> None: + device = client.devices.create( + pci_address="0000:a2:00.0", + ) + assert_matches_type(Device, device, path=["response"]) + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + def test_method_create_with_all_params(self, client: Hypeman) -> None: + device = client.devices.create( + pci_address="0000:a2:00.0", + name="l4-gpu", + tags={ + "team": "backend", + "env": "staging", + }, + ) + assert_matches_type(Device, device, path=["response"]) + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + def test_raw_response_create(self, client: Hypeman) -> None: + response = client.devices.with_raw_response.create( + pci_address="0000:a2:00.0", + ) + + assert response.is_closed is True + assert response.http_request.headers.get("X-Stainless-Lang") == "python" + device = response.parse() + assert_matches_type(Device, device, path=["response"]) + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + def test_streaming_response_create(self, client: Hypeman) -> None: + with client.devices.with_streaming_response.create( + pci_address="0000:a2:00.0", + ) as response: + assert not response.is_closed + assert response.http_request.headers.get("X-Stainless-Lang") == "python" + + device = response.parse() + assert_matches_type(Device, device, 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: Hypeman) -> None: + device = client.devices.retrieve( + "id", + ) + assert_matches_type(Device, device, path=["response"]) + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + def test_raw_response_retrieve(self, client: Hypeman) -> None: + response = client.devices.with_raw_response.retrieve( + "id", + ) + + assert response.is_closed is True + assert response.http_request.headers.get("X-Stainless-Lang") == "python" + device = response.parse() + assert_matches_type(Device, device, path=["response"]) + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + def test_streaming_response_retrieve(self, client: Hypeman) -> None: + with client.devices.with_streaming_response.retrieve( + "id", + ) as response: + assert not response.is_closed + assert response.http_request.headers.get("X-Stainless-Lang") == "python" + + device = response.parse() + assert_matches_type(Device, device, 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: Hypeman) -> None: + with pytest.raises(ValueError, match=r"Expected a non-empty value for `id` but received ''"): + client.devices.with_raw_response.retrieve( + "", + ) + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + def test_method_list(self, client: Hypeman) -> None: + device = client.devices.list() + assert_matches_type(DeviceListResponse, device, path=["response"]) + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + def test_method_list_with_all_params(self, client: Hypeman) -> None: + device = client.devices.list( + tags={ + "team": "backend", + "env": "staging", + }, + ) + assert_matches_type(DeviceListResponse, device, path=["response"]) + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + def test_raw_response_list(self, client: Hypeman) -> None: + response = client.devices.with_raw_response.list() + + assert response.is_closed is True + assert response.http_request.headers.get("X-Stainless-Lang") == "python" + device = response.parse() + assert_matches_type(DeviceListResponse, device, path=["response"]) + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + def test_streaming_response_list(self, client: Hypeman) -> None: + with client.devices.with_streaming_response.list() as response: + assert not response.is_closed + assert response.http_request.headers.get("X-Stainless-Lang") == "python" + + device = response.parse() + assert_matches_type(DeviceListResponse, device, 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: Hypeman) -> None: + device = client.devices.delete( + "id", + ) + assert device is None + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + def test_raw_response_delete(self, client: Hypeman) -> None: + response = client.devices.with_raw_response.delete( + "id", + ) + + assert response.is_closed is True + assert response.http_request.headers.get("X-Stainless-Lang") == "python" + device = response.parse() + assert device is None + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + def test_streaming_response_delete(self, client: Hypeman) -> None: + with client.devices.with_streaming_response.delete( + "id", + ) as response: + assert not response.is_closed + assert response.http_request.headers.get("X-Stainless-Lang") == "python" + + device = response.parse() + assert device 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: Hypeman) -> None: + with pytest.raises(ValueError, match=r"Expected a non-empty value for `id` but received ''"): + client.devices.with_raw_response.delete( + "", + ) + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + def test_method_list_available(self, client: Hypeman) -> None: + device = client.devices.list_available() + assert_matches_type(DeviceListAvailableResponse, device, path=["response"]) + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + def test_raw_response_list_available(self, client: Hypeman) -> None: + response = client.devices.with_raw_response.list_available() + + assert response.is_closed is True + assert response.http_request.headers.get("X-Stainless-Lang") == "python" + device = response.parse() + assert_matches_type(DeviceListAvailableResponse, device, path=["response"]) + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + def test_streaming_response_list_available(self, client: Hypeman) -> None: + with client.devices.with_streaming_response.list_available() as response: + assert not response.is_closed + assert response.http_request.headers.get("X-Stainless-Lang") == "python" + + device = response.parse() + assert_matches_type(DeviceListAvailableResponse, device, path=["response"]) + + assert cast(Any, response.is_closed) is True + + +class TestAsyncDevices: + 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: AsyncHypeman) -> None: + device = await async_client.devices.create( + pci_address="0000:a2:00.0", + ) + assert_matches_type(Device, device, path=["response"]) + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + async def test_method_create_with_all_params(self, async_client: AsyncHypeman) -> None: + device = await async_client.devices.create( + pci_address="0000:a2:00.0", + name="l4-gpu", + tags={ + "team": "backend", + "env": "staging", + }, + ) + assert_matches_type(Device, device, path=["response"]) + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + async def test_raw_response_create(self, async_client: AsyncHypeman) -> None: + response = await async_client.devices.with_raw_response.create( + pci_address="0000:a2:00.0", + ) + + assert response.is_closed is True + assert response.http_request.headers.get("X-Stainless-Lang") == "python" + device = await response.parse() + assert_matches_type(Device, device, path=["response"]) + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + async def test_streaming_response_create(self, async_client: AsyncHypeman) -> None: + async with async_client.devices.with_streaming_response.create( + pci_address="0000:a2:00.0", + ) as response: + assert not response.is_closed + assert response.http_request.headers.get("X-Stainless-Lang") == "python" + + device = await response.parse() + assert_matches_type(Device, device, 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: AsyncHypeman) -> None: + device = await async_client.devices.retrieve( + "id", + ) + assert_matches_type(Device, device, path=["response"]) + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + async def test_raw_response_retrieve(self, async_client: AsyncHypeman) -> None: + response = await async_client.devices.with_raw_response.retrieve( + "id", + ) + + assert response.is_closed is True + assert response.http_request.headers.get("X-Stainless-Lang") == "python" + device = await response.parse() + assert_matches_type(Device, device, path=["response"]) + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + async def test_streaming_response_retrieve(self, async_client: AsyncHypeman) -> None: + async with async_client.devices.with_streaming_response.retrieve( + "id", + ) as response: + assert not response.is_closed + assert response.http_request.headers.get("X-Stainless-Lang") == "python" + + device = await response.parse() + assert_matches_type(Device, device, 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: AsyncHypeman) -> None: + with pytest.raises(ValueError, match=r"Expected a non-empty value for `id` but received ''"): + await async_client.devices.with_raw_response.retrieve( + "", + ) + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + async def test_method_list(self, async_client: AsyncHypeman) -> None: + device = await async_client.devices.list() + assert_matches_type(DeviceListResponse, device, path=["response"]) + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + async def test_method_list_with_all_params(self, async_client: AsyncHypeman) -> None: + device = await async_client.devices.list( + tags={ + "team": "backend", + "env": "staging", + }, + ) + assert_matches_type(DeviceListResponse, device, path=["response"]) + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + async def test_raw_response_list(self, async_client: AsyncHypeman) -> None: + response = await async_client.devices.with_raw_response.list() + + assert response.is_closed is True + assert response.http_request.headers.get("X-Stainless-Lang") == "python" + device = await response.parse() + assert_matches_type(DeviceListResponse, device, path=["response"]) + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + async def test_streaming_response_list(self, async_client: AsyncHypeman) -> None: + async with async_client.devices.with_streaming_response.list() as response: + assert not response.is_closed + assert response.http_request.headers.get("X-Stainless-Lang") == "python" + + device = await response.parse() + assert_matches_type(DeviceListResponse, device, 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: AsyncHypeman) -> None: + device = await async_client.devices.delete( + "id", + ) + assert device is None + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + async def test_raw_response_delete(self, async_client: AsyncHypeman) -> None: + response = await async_client.devices.with_raw_response.delete( + "id", + ) + + assert response.is_closed is True + assert response.http_request.headers.get("X-Stainless-Lang") == "python" + device = await response.parse() + assert device is None + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + async def test_streaming_response_delete(self, async_client: AsyncHypeman) -> None: + async with async_client.devices.with_streaming_response.delete( + "id", + ) as response: + assert not response.is_closed + assert response.http_request.headers.get("X-Stainless-Lang") == "python" + + device = await response.parse() + assert device 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: AsyncHypeman) -> None: + with pytest.raises(ValueError, match=r"Expected a non-empty value for `id` but received ''"): + await async_client.devices.with_raw_response.delete( + "", + ) + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + async def test_method_list_available(self, async_client: AsyncHypeman) -> None: + device = await async_client.devices.list_available() + assert_matches_type(DeviceListAvailableResponse, device, path=["response"]) + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + async def test_raw_response_list_available(self, async_client: AsyncHypeman) -> None: + response = await async_client.devices.with_raw_response.list_available() + + assert response.is_closed is True + assert response.http_request.headers.get("X-Stainless-Lang") == "python" + device = await response.parse() + assert_matches_type(DeviceListAvailableResponse, device, path=["response"]) + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + async def test_streaming_response_list_available(self, async_client: AsyncHypeman) -> None: + async with async_client.devices.with_streaming_response.list_available() as response: + assert not response.is_closed + assert response.http_request.headers.get("X-Stainless-Lang") == "python" + + device = await response.parse() + assert_matches_type(DeviceListAvailableResponse, device, path=["response"]) + + assert cast(Any, response.is_closed) is True diff --git a/tests/api_resources/test_health.py b/tests/api_resources/test_health.py new file mode 100644 index 0000000..f59ad54 --- /dev/null +++ b/tests/api_resources/test_health.py @@ -0,0 +1,80 @@ +# 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 hypeman import Hypeman, AsyncHypeman +from tests.utils import assert_matches_type +from hypeman.types import HealthCheckResponse + +base_url = os.environ.get("TEST_API_BASE_URL", "http://127.0.0.1:4010") + + +class TestHealth: + 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_check(self, client: Hypeman) -> None: + health = client.health.check() + assert_matches_type(HealthCheckResponse, health, path=["response"]) + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + def test_raw_response_check(self, client: Hypeman) -> None: + response = client.health.with_raw_response.check() + + assert response.is_closed is True + assert response.http_request.headers.get("X-Stainless-Lang") == "python" + health = response.parse() + assert_matches_type(HealthCheckResponse, health, path=["response"]) + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + def test_streaming_response_check(self, client: Hypeman) -> None: + with client.health.with_streaming_response.check() as response: + assert not response.is_closed + assert response.http_request.headers.get("X-Stainless-Lang") == "python" + + health = response.parse() + assert_matches_type(HealthCheckResponse, health, path=["response"]) + + assert cast(Any, response.is_closed) is True + + +class TestAsyncHealth: + 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_check(self, async_client: AsyncHypeman) -> None: + health = await async_client.health.check() + assert_matches_type(HealthCheckResponse, health, path=["response"]) + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + async def test_raw_response_check(self, async_client: AsyncHypeman) -> None: + response = await async_client.health.with_raw_response.check() + + assert response.is_closed is True + assert response.http_request.headers.get("X-Stainless-Lang") == "python" + health = await response.parse() + assert_matches_type(HealthCheckResponse, health, path=["response"]) + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + async def test_streaming_response_check(self, async_client: AsyncHypeman) -> None: + async with async_client.health.with_streaming_response.check() as response: + assert not response.is_closed + assert response.http_request.headers.get("X-Stainless-Lang") == "python" + + health = await response.parse() + assert_matches_type(HealthCheckResponse, health, path=["response"]) + + assert cast(Any, response.is_closed) is True diff --git a/tests/api_resources/test_images.py b/tests/api_resources/test_images.py new file mode 100644 index 0000000..3d49984 --- /dev/null +++ b/tests/api_resources/test_images.py @@ -0,0 +1,374 @@ +# 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 hypeman import Hypeman, AsyncHypeman +from tests.utils import assert_matches_type +from hypeman.types import Image, ImageListResponse + +base_url = os.environ.get("TEST_API_BASE_URL", "http://127.0.0.1:4010") + + +class TestImages: + 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: Hypeman) -> None: + image = client.images.create( + name="docker.io/library/nginx:latest", + ) + assert_matches_type(Image, image, path=["response"]) + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + def test_method_create_with_all_params(self, client: Hypeman) -> None: + image = client.images.create( + name="docker.io/library/nginx:latest", + credentials={ + "password": "password", + "registry_token": "registry_token", + "username": "username", + }, + platform="linux/amd64", + tags={ + "team": "backend", + "env": "staging", + }, + ) + assert_matches_type(Image, image, path=["response"]) + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + def test_raw_response_create(self, client: Hypeman) -> None: + response = client.images.with_raw_response.create( + name="docker.io/library/nginx:latest", + ) + + assert response.is_closed is True + assert response.http_request.headers.get("X-Stainless-Lang") == "python" + image = response.parse() + assert_matches_type(Image, image, path=["response"]) + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + def test_streaming_response_create(self, client: Hypeman) -> None: + with client.images.with_streaming_response.create( + name="docker.io/library/nginx:latest", + ) as response: + assert not response.is_closed + assert response.http_request.headers.get("X-Stainless-Lang") == "python" + + image = response.parse() + assert_matches_type(Image, image, path=["response"]) + + assert cast(Any, response.is_closed) is True + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + def test_method_list(self, client: Hypeman) -> None: + image = client.images.list() + assert_matches_type(ImageListResponse, image, path=["response"]) + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + def test_method_list_with_all_params(self, client: Hypeman) -> None: + image = client.images.list( + tags={ + "team": "backend", + "env": "staging", + }, + ) + assert_matches_type(ImageListResponse, image, path=["response"]) + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + def test_raw_response_list(self, client: Hypeman) -> None: + response = client.images.with_raw_response.list() + + assert response.is_closed is True + assert response.http_request.headers.get("X-Stainless-Lang") == "python" + image = response.parse() + assert_matches_type(ImageListResponse, image, path=["response"]) + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + def test_streaming_response_list(self, client: Hypeman) -> None: + with client.images.with_streaming_response.list() as response: + assert not response.is_closed + assert response.http_request.headers.get("X-Stainless-Lang") == "python" + + image = response.parse() + assert_matches_type(ImageListResponse, image, 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: Hypeman) -> None: + image = client.images.delete( + "name", + ) + assert image is None + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + def test_raw_response_delete(self, client: Hypeman) -> None: + response = client.images.with_raw_response.delete( + "name", + ) + + assert response.is_closed is True + assert response.http_request.headers.get("X-Stainless-Lang") == "python" + image = response.parse() + assert image is None + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + def test_streaming_response_delete(self, client: Hypeman) -> None: + with client.images.with_streaming_response.delete( + "name", + ) as response: + assert not response.is_closed + assert response.http_request.headers.get("X-Stainless-Lang") == "python" + + image = response.parse() + assert image 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: Hypeman) -> None: + with pytest.raises(ValueError, match=r"Expected a non-empty value for `name` but received ''"): + client.images.with_raw_response.delete( + "", + ) + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + def test_method_get(self, client: Hypeman) -> None: + image = client.images.get( + "name", + ) + assert_matches_type(Image, image, path=["response"]) + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + def test_raw_response_get(self, client: Hypeman) -> None: + response = client.images.with_raw_response.get( + "name", + ) + + assert response.is_closed is True + assert response.http_request.headers.get("X-Stainless-Lang") == "python" + image = response.parse() + assert_matches_type(Image, image, path=["response"]) + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + def test_streaming_response_get(self, client: Hypeman) -> None: + with client.images.with_streaming_response.get( + "name", + ) as response: + assert not response.is_closed + assert response.http_request.headers.get("X-Stainless-Lang") == "python" + + image = response.parse() + assert_matches_type(Image, image, 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: Hypeman) -> None: + with pytest.raises(ValueError, match=r"Expected a non-empty value for `name` but received ''"): + client.images.with_raw_response.get( + "", + ) + + +class TestAsyncImages: + 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: AsyncHypeman) -> None: + image = await async_client.images.create( + name="docker.io/library/nginx:latest", + ) + assert_matches_type(Image, image, path=["response"]) + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + async def test_method_create_with_all_params(self, async_client: AsyncHypeman) -> None: + image = await async_client.images.create( + name="docker.io/library/nginx:latest", + credentials={ + "password": "password", + "registry_token": "registry_token", + "username": "username", + }, + platform="linux/amd64", + tags={ + "team": "backend", + "env": "staging", + }, + ) + assert_matches_type(Image, image, path=["response"]) + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + async def test_raw_response_create(self, async_client: AsyncHypeman) -> None: + response = await async_client.images.with_raw_response.create( + name="docker.io/library/nginx:latest", + ) + + assert response.is_closed is True + assert response.http_request.headers.get("X-Stainless-Lang") == "python" + image = await response.parse() + assert_matches_type(Image, image, path=["response"]) + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + async def test_streaming_response_create(self, async_client: AsyncHypeman) -> None: + async with async_client.images.with_streaming_response.create( + name="docker.io/library/nginx:latest", + ) as response: + assert not response.is_closed + assert response.http_request.headers.get("X-Stainless-Lang") == "python" + + image = await response.parse() + assert_matches_type(Image, image, path=["response"]) + + assert cast(Any, response.is_closed) is True + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + async def test_method_list(self, async_client: AsyncHypeman) -> None: + image = await async_client.images.list() + assert_matches_type(ImageListResponse, image, path=["response"]) + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + async def test_method_list_with_all_params(self, async_client: AsyncHypeman) -> None: + image = await async_client.images.list( + tags={ + "team": "backend", + "env": "staging", + }, + ) + assert_matches_type(ImageListResponse, image, path=["response"]) + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + async def test_raw_response_list(self, async_client: AsyncHypeman) -> None: + response = await async_client.images.with_raw_response.list() + + assert response.is_closed is True + assert response.http_request.headers.get("X-Stainless-Lang") == "python" + image = await response.parse() + assert_matches_type(ImageListResponse, image, path=["response"]) + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + async def test_streaming_response_list(self, async_client: AsyncHypeman) -> None: + async with async_client.images.with_streaming_response.list() as response: + assert not response.is_closed + assert response.http_request.headers.get("X-Stainless-Lang") == "python" + + image = await response.parse() + assert_matches_type(ImageListResponse, image, 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: AsyncHypeman) -> None: + image = await async_client.images.delete( + "name", + ) + assert image is None + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + async def test_raw_response_delete(self, async_client: AsyncHypeman) -> None: + response = await async_client.images.with_raw_response.delete( + "name", + ) + + assert response.is_closed is True + assert response.http_request.headers.get("X-Stainless-Lang") == "python" + image = await response.parse() + assert image is None + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + async def test_streaming_response_delete(self, async_client: AsyncHypeman) -> None: + async with async_client.images.with_streaming_response.delete( + "name", + ) as response: + assert not response.is_closed + assert response.http_request.headers.get("X-Stainless-Lang") == "python" + + image = await response.parse() + assert image 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: AsyncHypeman) -> None: + with pytest.raises(ValueError, match=r"Expected a non-empty value for `name` but received ''"): + await async_client.images.with_raw_response.delete( + "", + ) + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + async def test_method_get(self, async_client: AsyncHypeman) -> None: + image = await async_client.images.get( + "name", + ) + assert_matches_type(Image, image, path=["response"]) + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + async def test_raw_response_get(self, async_client: AsyncHypeman) -> None: + response = await async_client.images.with_raw_response.get( + "name", + ) + + assert response.is_closed is True + assert response.http_request.headers.get("X-Stainless-Lang") == "python" + image = await response.parse() + assert_matches_type(Image, image, path=["response"]) + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + async def test_streaming_response_get(self, async_client: AsyncHypeman) -> None: + async with async_client.images.with_streaming_response.get( + "name", + ) as response: + assert not response.is_closed + assert response.http_request.headers.get("X-Stainless-Lang") == "python" + + image = await response.parse() + assert_matches_type(Image, image, 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: AsyncHypeman) -> None: + with pytest.raises(ValueError, match=r"Expected a non-empty value for `name` but received ''"): + await async_client.images.with_raw_response.get( + "", + ) diff --git a/tests/api_resources/test_ingresses.py b/tests/api_resources/test_ingresses.py new file mode 100644 index 0000000..c2e7d59 --- /dev/null +++ b/tests/api_resources/test_ingresses.py @@ -0,0 +1,452 @@ +# 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 hypeman import Hypeman, AsyncHypeman +from tests.utils import assert_matches_type +from hypeman.types import Ingress, IngressListResponse + +base_url = os.environ.get("TEST_API_BASE_URL", "http://127.0.0.1:4010") + + +class TestIngresses: + 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: Hypeman) -> None: + ingress = client.ingresses.create( + name="my-api-ingress", + rules=[ + { + "match": {"hostname": "{instance}.example.com"}, + "target": { + "instance": "{instance}", + "port": 8080, + }, + } + ], + ) + assert_matches_type(Ingress, ingress, path=["response"]) + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + def test_method_create_with_all_params(self, client: Hypeman) -> None: + ingress = client.ingresses.create( + name="my-api-ingress", + rules=[ + { + "match": { + "hostname": "{instance}.example.com", + "port": 8080, + }, + "target": { + "instance": "{instance}", + "port": 8080, + }, + "redirect_http": True, + "request_header_auth": { + "header": "X-Ingress-Verification", + "value": "0123456789abcdef0123456789abcdef", + }, + "tls": True, + } + ], + tags={ + "team": "backend", + "env": "staging", + }, + ) + assert_matches_type(Ingress, ingress, path=["response"]) + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + def test_raw_response_create(self, client: Hypeman) -> None: + response = client.ingresses.with_raw_response.create( + name="my-api-ingress", + rules=[ + { + "match": {"hostname": "{instance}.example.com"}, + "target": { + "instance": "{instance}", + "port": 8080, + }, + } + ], + ) + + assert response.is_closed is True + assert response.http_request.headers.get("X-Stainless-Lang") == "python" + ingress = response.parse() + assert_matches_type(Ingress, ingress, path=["response"]) + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + def test_streaming_response_create(self, client: Hypeman) -> None: + with client.ingresses.with_streaming_response.create( + name="my-api-ingress", + rules=[ + { + "match": {"hostname": "{instance}.example.com"}, + "target": { + "instance": "{instance}", + "port": 8080, + }, + } + ], + ) as response: + assert not response.is_closed + assert response.http_request.headers.get("X-Stainless-Lang") == "python" + + ingress = response.parse() + assert_matches_type(Ingress, ingress, path=["response"]) + + assert cast(Any, response.is_closed) is True + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + def test_method_list(self, client: Hypeman) -> None: + ingress = client.ingresses.list() + assert_matches_type(IngressListResponse, ingress, path=["response"]) + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + def test_method_list_with_all_params(self, client: Hypeman) -> None: + ingress = client.ingresses.list( + tags={ + "team": "backend", + "env": "staging", + }, + ) + assert_matches_type(IngressListResponse, ingress, path=["response"]) + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + def test_raw_response_list(self, client: Hypeman) -> None: + response = client.ingresses.with_raw_response.list() + + assert response.is_closed is True + assert response.http_request.headers.get("X-Stainless-Lang") == "python" + ingress = response.parse() + assert_matches_type(IngressListResponse, ingress, path=["response"]) + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + def test_streaming_response_list(self, client: Hypeman) -> None: + with client.ingresses.with_streaming_response.list() as response: + assert not response.is_closed + assert response.http_request.headers.get("X-Stainless-Lang") == "python" + + ingress = response.parse() + assert_matches_type(IngressListResponse, ingress, 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: Hypeman) -> None: + ingress = client.ingresses.delete( + "id", + ) + assert ingress is None + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + def test_raw_response_delete(self, client: Hypeman) -> None: + response = client.ingresses.with_raw_response.delete( + "id", + ) + + assert response.is_closed is True + assert response.http_request.headers.get("X-Stainless-Lang") == "python" + ingress = response.parse() + assert ingress is None + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + def test_streaming_response_delete(self, client: Hypeman) -> None: + with client.ingresses.with_streaming_response.delete( + "id", + ) as response: + assert not response.is_closed + assert response.http_request.headers.get("X-Stainless-Lang") == "python" + + ingress = response.parse() + assert ingress 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: Hypeman) -> None: + with pytest.raises(ValueError, match=r"Expected a non-empty value for `id` but received ''"): + client.ingresses.with_raw_response.delete( + "", + ) + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + def test_method_get(self, client: Hypeman) -> None: + ingress = client.ingresses.get( + "id", + ) + assert_matches_type(Ingress, ingress, path=["response"]) + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + def test_raw_response_get(self, client: Hypeman) -> None: + response = client.ingresses.with_raw_response.get( + "id", + ) + + assert response.is_closed is True + assert response.http_request.headers.get("X-Stainless-Lang") == "python" + ingress = response.parse() + assert_matches_type(Ingress, ingress, path=["response"]) + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + def test_streaming_response_get(self, client: Hypeman) -> None: + with client.ingresses.with_streaming_response.get( + "id", + ) as response: + assert not response.is_closed + assert response.http_request.headers.get("X-Stainless-Lang") == "python" + + ingress = response.parse() + assert_matches_type(Ingress, ingress, 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: Hypeman) -> None: + with pytest.raises(ValueError, match=r"Expected a non-empty value for `id` but received ''"): + client.ingresses.with_raw_response.get( + "", + ) + + +class TestAsyncIngresses: + 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: AsyncHypeman) -> None: + ingress = await async_client.ingresses.create( + name="my-api-ingress", + rules=[ + { + "match": {"hostname": "{instance}.example.com"}, + "target": { + "instance": "{instance}", + "port": 8080, + }, + } + ], + ) + assert_matches_type(Ingress, ingress, path=["response"]) + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + async def test_method_create_with_all_params(self, async_client: AsyncHypeman) -> None: + ingress = await async_client.ingresses.create( + name="my-api-ingress", + rules=[ + { + "match": { + "hostname": "{instance}.example.com", + "port": 8080, + }, + "target": { + "instance": "{instance}", + "port": 8080, + }, + "redirect_http": True, + "request_header_auth": { + "header": "X-Ingress-Verification", + "value": "0123456789abcdef0123456789abcdef", + }, + "tls": True, + } + ], + tags={ + "team": "backend", + "env": "staging", + }, + ) + assert_matches_type(Ingress, ingress, path=["response"]) + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + async def test_raw_response_create(self, async_client: AsyncHypeman) -> None: + response = await async_client.ingresses.with_raw_response.create( + name="my-api-ingress", + rules=[ + { + "match": {"hostname": "{instance}.example.com"}, + "target": { + "instance": "{instance}", + "port": 8080, + }, + } + ], + ) + + assert response.is_closed is True + assert response.http_request.headers.get("X-Stainless-Lang") == "python" + ingress = await response.parse() + assert_matches_type(Ingress, ingress, path=["response"]) + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + async def test_streaming_response_create(self, async_client: AsyncHypeman) -> None: + async with async_client.ingresses.with_streaming_response.create( + name="my-api-ingress", + rules=[ + { + "match": {"hostname": "{instance}.example.com"}, + "target": { + "instance": "{instance}", + "port": 8080, + }, + } + ], + ) as response: + assert not response.is_closed + assert response.http_request.headers.get("X-Stainless-Lang") == "python" + + ingress = await response.parse() + assert_matches_type(Ingress, ingress, path=["response"]) + + assert cast(Any, response.is_closed) is True + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + async def test_method_list(self, async_client: AsyncHypeman) -> None: + ingress = await async_client.ingresses.list() + assert_matches_type(IngressListResponse, ingress, path=["response"]) + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + async def test_method_list_with_all_params(self, async_client: AsyncHypeman) -> None: + ingress = await async_client.ingresses.list( + tags={ + "team": "backend", + "env": "staging", + }, + ) + assert_matches_type(IngressListResponse, ingress, path=["response"]) + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + async def test_raw_response_list(self, async_client: AsyncHypeman) -> None: + response = await async_client.ingresses.with_raw_response.list() + + assert response.is_closed is True + assert response.http_request.headers.get("X-Stainless-Lang") == "python" + ingress = await response.parse() + assert_matches_type(IngressListResponse, ingress, path=["response"]) + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + async def test_streaming_response_list(self, async_client: AsyncHypeman) -> None: + async with async_client.ingresses.with_streaming_response.list() as response: + assert not response.is_closed + assert response.http_request.headers.get("X-Stainless-Lang") == "python" + + ingress = await response.parse() + assert_matches_type(IngressListResponse, ingress, 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: AsyncHypeman) -> None: + ingress = await async_client.ingresses.delete( + "id", + ) + assert ingress is None + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + async def test_raw_response_delete(self, async_client: AsyncHypeman) -> None: + response = await async_client.ingresses.with_raw_response.delete( + "id", + ) + + assert response.is_closed is True + assert response.http_request.headers.get("X-Stainless-Lang") == "python" + ingress = await response.parse() + assert ingress is None + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + async def test_streaming_response_delete(self, async_client: AsyncHypeman) -> None: + async with async_client.ingresses.with_streaming_response.delete( + "id", + ) as response: + assert not response.is_closed + assert response.http_request.headers.get("X-Stainless-Lang") == "python" + + ingress = await response.parse() + assert ingress 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: AsyncHypeman) -> None: + with pytest.raises(ValueError, match=r"Expected a non-empty value for `id` but received ''"): + await async_client.ingresses.with_raw_response.delete( + "", + ) + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + async def test_method_get(self, async_client: AsyncHypeman) -> None: + ingress = await async_client.ingresses.get( + "id", + ) + assert_matches_type(Ingress, ingress, path=["response"]) + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + async def test_raw_response_get(self, async_client: AsyncHypeman) -> None: + response = await async_client.ingresses.with_raw_response.get( + "id", + ) + + assert response.is_closed is True + assert response.http_request.headers.get("X-Stainless-Lang") == "python" + ingress = await response.parse() + assert_matches_type(Ingress, ingress, path=["response"]) + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + async def test_streaming_response_get(self, async_client: AsyncHypeman) -> None: + async with async_client.ingresses.with_streaming_response.get( + "id", + ) as response: + assert not response.is_closed + assert response.http_request.headers.get("X-Stainless-Lang") == "python" + + ingress = await response.parse() + assert_matches_type(Ingress, ingress, 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: AsyncHypeman) -> None: + with pytest.raises(ValueError, match=r"Expected a non-empty value for `id` but received ''"): + await async_client.ingresses.with_raw_response.get( + "", + ) diff --git a/tests/api_resources/test_instances.py b/tests/api_resources/test_instances.py new file mode 100644 index 0000000..84c69ba --- /dev/null +++ b/tests/api_resources/test_instances.py @@ -0,0 +1,1628 @@ +# 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 hypeman import Hypeman, AsyncHypeman +from tests.utils import assert_matches_type +from hypeman.types import ( + Instance, + PathInfo, + InstanceStats, + InstanceListResponse, + WaitForStateResponse, +) + +base_url = os.environ.get("TEST_API_BASE_URL", "http://127.0.0.1:4010") + + +class TestInstances: + 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: Hypeman) -> None: + instance = client.instances.create( + image="docker.io/library/alpine:latest", + name="my-workload-1", + ) + assert_matches_type(Instance, instance, path=["response"]) + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + def test_method_create_with_all_params(self, client: Hypeman) -> None: + instance = client.instances.create( + image="docker.io/library/alpine:latest", + name="my-workload-1", + auto_standby={ + "enabled": True, + "idle_timeout": "5m", + "ignore_destination_ports": [22, 9000], + "ignore_source_cidrs": ["10.0.0.0/8", "192.168.0.0/16"], + }, + cmd=["echo", "hello"], + credentials={ + "OUTBOUND_OPENAI_KEY": { + "inject": [ + { + "as": { + "format": "Bearer ${value}", + "header": "Authorization", + }, + "hosts": ["api.openai.com", "*.openai.com"], + } + ], + "source": {"env": "OUTBOUND_OPENAI_KEY"}, + } + }, + devices=["l4-gpu"], + disk_io_bps="100MB/s", + entrypoint=["/bin/sh", "-c"], + env={ + "PORT": "3000", + "NODE_ENV": "production", + }, + gpu={"profile": "L40S-1Q"}, + health_check={ + "exec": { + "command": ["curl", "-f", "http://localhost:4318/"], + "working_dir": "/app", + }, + "failure_threshold": 3, + "http": { + "port": 8080, + "expected_status": 200, + "path": "/healthz", + "scheme": "http", + }, + "interval": "10s", + "start_period": "30s", + "success_threshold": 1, + "tcp": {"port": 5432}, + "timeout": "2s", + "type": "none", + }, + hotplug_size="2GB", + hypervisor="cloud-hypervisor", + network={ + "bandwidth_download": "1Gbps", + "bandwidth_upload": "1Gbps", + "egress": { + "enabled": True, + "enforcement": {"mode": "all"}, + }, + "enabled": True, + }, + overlay_size="20GB", + platform="linux/amd64", + restart_policy={ + "backoff": "5s", + "max_attempts": 10, + "policy": "on_failure", + "stable_after": "10m", + }, + size="2GB", + skip_guest_agent=False, + skip_kernel_headers=True, + snapshot_policy={ + "compression": { + "enabled": True, + "algorithm": "zstd", + "level": 1, + }, + "standby_compression_delay": "2m", + }, + tags={ + "team": "backend", + "env": "staging", + }, + vcpus=2, + volumes=[ + { + "mount_path": "/mnt/data", + "volume_id": "vol-abc123", + "overlay": True, + "overlay_size": "1GB", + "readonly": True, + } + ], + ) + assert_matches_type(Instance, instance, path=["response"]) + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + def test_raw_response_create(self, client: Hypeman) -> None: + response = client.instances.with_raw_response.create( + image="docker.io/library/alpine:latest", + name="my-workload-1", + ) + + assert response.is_closed is True + assert response.http_request.headers.get("X-Stainless-Lang") == "python" + instance = response.parse() + assert_matches_type(Instance, instance, path=["response"]) + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + def test_streaming_response_create(self, client: Hypeman) -> None: + with client.instances.with_streaming_response.create( + image="docker.io/library/alpine:latest", + name="my-workload-1", + ) as response: + assert not response.is_closed + assert response.http_request.headers.get("X-Stainless-Lang") == "python" + + instance = response.parse() + assert_matches_type(Instance, instance, 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: Hypeman) -> None: + instance = client.instances.update( + id="id", + ) + assert_matches_type(Instance, instance, path=["response"]) + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + def test_method_update_with_all_params(self, client: Hypeman) -> None: + instance = client.instances.update( + id="id", + auto_standby={ + "enabled": True, + "idle_timeout": "5m", + "ignore_destination_ports": [22, 9000], + "ignore_source_cidrs": ["10.0.0.0/8", "192.168.0.0/16"], + }, + env={"OUTBOUND_OPENAI_KEY": "new-rotated-key-456"}, + health_check={ + "exec": { + "command": ["curl", "-f", "http://localhost:4318/"], + "working_dir": "/app", + }, + "failure_threshold": 3, + "http": { + "port": 8080, + "expected_status": 200, + "path": "/healthz", + "scheme": "http", + }, + "interval": "10s", + "start_period": "30s", + "success_threshold": 1, + "tcp": {"port": 5432}, + "timeout": "2s", + "type": "none", + }, + restart_policy={ + "backoff": "5s", + "max_attempts": 10, + "policy": "on_failure", + "stable_after": "10m", + }, + ) + assert_matches_type(Instance, instance, path=["response"]) + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + def test_raw_response_update(self, client: Hypeman) -> None: + response = client.instances.with_raw_response.update( + id="id", + ) + + assert response.is_closed is True + assert response.http_request.headers.get("X-Stainless-Lang") == "python" + instance = response.parse() + assert_matches_type(Instance, instance, path=["response"]) + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + def test_streaming_response_update(self, client: Hypeman) -> None: + with client.instances.with_streaming_response.update( + id="id", + ) as response: + assert not response.is_closed + assert response.http_request.headers.get("X-Stainless-Lang") == "python" + + instance = response.parse() + assert_matches_type(Instance, instance, 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: Hypeman) -> None: + with pytest.raises(ValueError, match=r"Expected a non-empty value for `id` but received ''"): + client.instances.with_raw_response.update( + id="", + ) + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + def test_method_list(self, client: Hypeman) -> None: + instance = client.instances.list() + assert_matches_type(InstanceListResponse, instance, path=["response"]) + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + def test_method_list_with_all_params(self, client: Hypeman) -> None: + instance = client.instances.list( + state="Created", + tags={ + "team": "backend", + "env": "staging", + }, + ) + assert_matches_type(InstanceListResponse, instance, path=["response"]) + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + def test_raw_response_list(self, client: Hypeman) -> None: + response = client.instances.with_raw_response.list() + + assert response.is_closed is True + assert response.http_request.headers.get("X-Stainless-Lang") == "python" + instance = response.parse() + assert_matches_type(InstanceListResponse, instance, path=["response"]) + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + def test_streaming_response_list(self, client: Hypeman) -> None: + with client.instances.with_streaming_response.list() as response: + assert not response.is_closed + assert response.http_request.headers.get("X-Stainless-Lang") == "python" + + instance = response.parse() + assert_matches_type(InstanceListResponse, instance, 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: Hypeman) -> None: + instance = client.instances.delete( + "id", + ) + assert instance is None + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + def test_raw_response_delete(self, client: Hypeman) -> None: + response = client.instances.with_raw_response.delete( + "id", + ) + + assert response.is_closed is True + assert response.http_request.headers.get("X-Stainless-Lang") == "python" + instance = response.parse() + assert instance is None + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + def test_streaming_response_delete(self, client: Hypeman) -> None: + with client.instances.with_streaming_response.delete( + "id", + ) as response: + assert not response.is_closed + assert response.http_request.headers.get("X-Stainless-Lang") == "python" + + instance = response.parse() + assert instance 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: Hypeman) -> None: + with pytest.raises(ValueError, match=r"Expected a non-empty value for `id` but received ''"): + client.instances.with_raw_response.delete( + "", + ) + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + def test_method_fork(self, client: Hypeman) -> None: + instance = client.instances.fork( + id="id", + name="my-workload-1-fork", + ) + assert_matches_type(Instance, instance, path=["response"]) + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + def test_method_fork_with_all_params(self, client: Hypeman) -> None: + instance = client.instances.fork( + id="id", + name="my-workload-1-fork", + from_running=False, + target_state="Running", + ) + assert_matches_type(Instance, instance, path=["response"]) + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + def test_raw_response_fork(self, client: Hypeman) -> None: + response = client.instances.with_raw_response.fork( + id="id", + name="my-workload-1-fork", + ) + + assert response.is_closed is True + assert response.http_request.headers.get("X-Stainless-Lang") == "python" + instance = response.parse() + assert_matches_type(Instance, instance, path=["response"]) + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + def test_streaming_response_fork(self, client: Hypeman) -> None: + with client.instances.with_streaming_response.fork( + id="id", + name="my-workload-1-fork", + ) as response: + assert not response.is_closed + assert response.http_request.headers.get("X-Stainless-Lang") == "python" + + instance = response.parse() + assert_matches_type(Instance, instance, path=["response"]) + + assert cast(Any, response.is_closed) is True + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + def test_path_params_fork(self, client: Hypeman) -> None: + with pytest.raises(ValueError, match=r"Expected a non-empty value for `id` but received ''"): + client.instances.with_raw_response.fork( + id="", + name="my-workload-1-fork", + ) + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + def test_method_get(self, client: Hypeman) -> None: + instance = client.instances.get( + "id", + ) + assert_matches_type(Instance, instance, path=["response"]) + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + def test_raw_response_get(self, client: Hypeman) -> None: + response = client.instances.with_raw_response.get( + "id", + ) + + assert response.is_closed is True + assert response.http_request.headers.get("X-Stainless-Lang") == "python" + instance = response.parse() + assert_matches_type(Instance, instance, path=["response"]) + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + def test_streaming_response_get(self, client: Hypeman) -> None: + with client.instances.with_streaming_response.get( + "id", + ) as response: + assert not response.is_closed + assert response.http_request.headers.get("X-Stainless-Lang") == "python" + + instance = response.parse() + assert_matches_type(Instance, instance, 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: Hypeman) -> None: + with pytest.raises(ValueError, match=r"Expected a non-empty value for `id` but received ''"): + client.instances.with_raw_response.get( + "", + ) + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + def test_method_logs(self, client: Hypeman) -> None: + instance_stream = client.instances.logs( + id="id", + ) + instance_stream.response.close() + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + def test_method_logs_with_all_params(self, client: Hypeman) -> None: + instance_stream = client.instances.logs( + id="id", + follow=True, + source="app", + tail=0, + ) + instance_stream.response.close() + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + def test_raw_response_logs(self, client: Hypeman) -> None: + response = client.instances.with_raw_response.logs( + 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_logs(self, client: Hypeman) -> None: + with client.instances.with_streaming_response.logs( + 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_logs(self, client: Hypeman) -> None: + with pytest.raises(ValueError, match=r"Expected a non-empty value for `id` but received ''"): + client.instances.with_raw_response.logs( + id="", + ) + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + def test_method_restore(self, client: Hypeman) -> None: + instance = client.instances.restore( + "id", + ) + assert_matches_type(Instance, instance, path=["response"]) + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + def test_raw_response_restore(self, client: Hypeman) -> None: + response = client.instances.with_raw_response.restore( + "id", + ) + + assert response.is_closed is True + assert response.http_request.headers.get("X-Stainless-Lang") == "python" + instance = response.parse() + assert_matches_type(Instance, instance, path=["response"]) + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + def test_streaming_response_restore(self, client: Hypeman) -> None: + with client.instances.with_streaming_response.restore( + "id", + ) as response: + assert not response.is_closed + assert response.http_request.headers.get("X-Stainless-Lang") == "python" + + instance = response.parse() + assert_matches_type(Instance, instance, path=["response"]) + + assert cast(Any, response.is_closed) is True + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + def test_path_params_restore(self, client: Hypeman) -> None: + with pytest.raises(ValueError, match=r"Expected a non-empty value for `id` but received ''"): + client.instances.with_raw_response.restore( + "", + ) + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + def test_method_standby(self, client: Hypeman) -> None: + instance = client.instances.standby( + id="id", + ) + assert_matches_type(Instance, instance, path=["response"]) + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + def test_method_standby_with_all_params(self, client: Hypeman) -> None: + instance = client.instances.standby( + id="id", + compression={ + "enabled": True, + "algorithm": "zstd", + "level": 1, + }, + compression_delay="45s", + ) + assert_matches_type(Instance, instance, path=["response"]) + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + def test_raw_response_standby(self, client: Hypeman) -> None: + response = client.instances.with_raw_response.standby( + id="id", + ) + + assert response.is_closed is True + assert response.http_request.headers.get("X-Stainless-Lang") == "python" + instance = response.parse() + assert_matches_type(Instance, instance, path=["response"]) + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + def test_streaming_response_standby(self, client: Hypeman) -> None: + with client.instances.with_streaming_response.standby( + id="id", + ) as response: + assert not response.is_closed + assert response.http_request.headers.get("X-Stainless-Lang") == "python" + + instance = response.parse() + assert_matches_type(Instance, instance, path=["response"]) + + assert cast(Any, response.is_closed) is True + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + def test_path_params_standby(self, client: Hypeman) -> None: + with pytest.raises(ValueError, match=r"Expected a non-empty value for `id` but received ''"): + client.instances.with_raw_response.standby( + id="", + ) + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + def test_method_start(self, client: Hypeman) -> None: + instance = client.instances.start( + id="id", + ) + assert_matches_type(Instance, instance, path=["response"]) + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + def test_method_start_with_all_params(self, client: Hypeman) -> None: + instance = client.instances.start( + id="id", + cmd=["string"], + entrypoint=["string"], + ) + assert_matches_type(Instance, instance, path=["response"]) + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + def test_raw_response_start(self, client: Hypeman) -> None: + response = client.instances.with_raw_response.start( + id="id", + ) + + assert response.is_closed is True + assert response.http_request.headers.get("X-Stainless-Lang") == "python" + instance = response.parse() + assert_matches_type(Instance, instance, path=["response"]) + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + def test_streaming_response_start(self, client: Hypeman) -> None: + with client.instances.with_streaming_response.start( + id="id", + ) as response: + assert not response.is_closed + assert response.http_request.headers.get("X-Stainless-Lang") == "python" + + instance = response.parse() + assert_matches_type(Instance, instance, 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: Hypeman) -> None: + with pytest.raises(ValueError, match=r"Expected a non-empty value for `id` but received ''"): + client.instances.with_raw_response.start( + id="", + ) + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + def test_method_stat(self, client: Hypeman) -> None: + instance = client.instances.stat( + id="id", + path="path", + ) + assert_matches_type(PathInfo, instance, path=["response"]) + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + def test_method_stat_with_all_params(self, client: Hypeman) -> None: + instance = client.instances.stat( + id="id", + path="path", + follow_links=True, + ) + assert_matches_type(PathInfo, instance, path=["response"]) + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + def test_raw_response_stat(self, client: Hypeman) -> None: + response = client.instances.with_raw_response.stat( + id="id", + path="path", + ) + + assert response.is_closed is True + assert response.http_request.headers.get("X-Stainless-Lang") == "python" + instance = response.parse() + assert_matches_type(PathInfo, instance, path=["response"]) + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + def test_streaming_response_stat(self, client: Hypeman) -> None: + with client.instances.with_streaming_response.stat( + id="id", + path="path", + ) as response: + assert not response.is_closed + assert response.http_request.headers.get("X-Stainless-Lang") == "python" + + instance = response.parse() + assert_matches_type(PathInfo, instance, path=["response"]) + + assert cast(Any, response.is_closed) is True + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + def test_path_params_stat(self, client: Hypeman) -> None: + with pytest.raises(ValueError, match=r"Expected a non-empty value for `id` but received ''"): + client.instances.with_raw_response.stat( + id="", + path="path", + ) + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + def test_method_stats(self, client: Hypeman) -> None: + instance = client.instances.stats( + "id", + ) + assert_matches_type(InstanceStats, instance, path=["response"]) + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + def test_raw_response_stats(self, client: Hypeman) -> None: + response = client.instances.with_raw_response.stats( + "id", + ) + + assert response.is_closed is True + assert response.http_request.headers.get("X-Stainless-Lang") == "python" + instance = response.parse() + assert_matches_type(InstanceStats, instance, path=["response"]) + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + def test_streaming_response_stats(self, client: Hypeman) -> None: + with client.instances.with_streaming_response.stats( + "id", + ) as response: + assert not response.is_closed + assert response.http_request.headers.get("X-Stainless-Lang") == "python" + + instance = response.parse() + assert_matches_type(InstanceStats, instance, path=["response"]) + + assert cast(Any, response.is_closed) is True + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + def test_path_params_stats(self, client: Hypeman) -> None: + with pytest.raises(ValueError, match=r"Expected a non-empty value for `id` but received ''"): + client.instances.with_raw_response.stats( + "", + ) + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + def test_method_stop(self, client: Hypeman) -> None: + instance = client.instances.stop( + "id", + ) + assert_matches_type(Instance, instance, path=["response"]) + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + def test_raw_response_stop(self, client: Hypeman) -> None: + response = client.instances.with_raw_response.stop( + "id", + ) + + assert response.is_closed is True + assert response.http_request.headers.get("X-Stainless-Lang") == "python" + instance = response.parse() + assert_matches_type(Instance, instance, path=["response"]) + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + def test_streaming_response_stop(self, client: Hypeman) -> None: + with client.instances.with_streaming_response.stop( + "id", + ) as response: + assert not response.is_closed + assert response.http_request.headers.get("X-Stainless-Lang") == "python" + + instance = response.parse() + assert_matches_type(Instance, instance, path=["response"]) + + 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: Hypeman) -> None: + with pytest.raises(ValueError, match=r"Expected a non-empty value for `id` but received ''"): + client.instances.with_raw_response.stop( + "", + ) + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + def test_method_wait(self, client: Hypeman) -> None: + instance = client.instances.wait( + id="id", + state="Created", + ) + assert_matches_type(WaitForStateResponse, instance, path=["response"]) + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + def test_method_wait_with_all_params(self, client: Hypeman) -> None: + instance = client.instances.wait( + id="id", + state="Created", + api_timeout="timeout", + ) + assert_matches_type(WaitForStateResponse, instance, path=["response"]) + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + def test_raw_response_wait(self, client: Hypeman) -> None: + response = client.instances.with_raw_response.wait( + id="id", + state="Created", + ) + + assert response.is_closed is True + assert response.http_request.headers.get("X-Stainless-Lang") == "python" + instance = response.parse() + assert_matches_type(WaitForStateResponse, instance, path=["response"]) + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + def test_streaming_response_wait(self, client: Hypeman) -> None: + with client.instances.with_streaming_response.wait( + id="id", + state="Created", + ) as response: + assert not response.is_closed + assert response.http_request.headers.get("X-Stainless-Lang") == "python" + + instance = response.parse() + assert_matches_type(WaitForStateResponse, instance, path=["response"]) + + assert cast(Any, response.is_closed) is True + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + def test_path_params_wait(self, client: Hypeman) -> None: + with pytest.raises(ValueError, match=r"Expected a non-empty value for `id` but received ''"): + client.instances.with_raw_response.wait( + id="", + state="Created", + ) + + +class TestAsyncInstances: + 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: AsyncHypeman) -> None: + instance = await async_client.instances.create( + image="docker.io/library/alpine:latest", + name="my-workload-1", + ) + assert_matches_type(Instance, instance, path=["response"]) + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + async def test_method_create_with_all_params(self, async_client: AsyncHypeman) -> None: + instance = await async_client.instances.create( + image="docker.io/library/alpine:latest", + name="my-workload-1", + auto_standby={ + "enabled": True, + "idle_timeout": "5m", + "ignore_destination_ports": [22, 9000], + "ignore_source_cidrs": ["10.0.0.0/8", "192.168.0.0/16"], + }, + cmd=["echo", "hello"], + credentials={ + "OUTBOUND_OPENAI_KEY": { + "inject": [ + { + "as": { + "format": "Bearer ${value}", + "header": "Authorization", + }, + "hosts": ["api.openai.com", "*.openai.com"], + } + ], + "source": {"env": "OUTBOUND_OPENAI_KEY"}, + } + }, + devices=["l4-gpu"], + disk_io_bps="100MB/s", + entrypoint=["/bin/sh", "-c"], + env={ + "PORT": "3000", + "NODE_ENV": "production", + }, + gpu={"profile": "L40S-1Q"}, + health_check={ + "exec": { + "command": ["curl", "-f", "http://localhost:4318/"], + "working_dir": "/app", + }, + "failure_threshold": 3, + "http": { + "port": 8080, + "expected_status": 200, + "path": "/healthz", + "scheme": "http", + }, + "interval": "10s", + "start_period": "30s", + "success_threshold": 1, + "tcp": {"port": 5432}, + "timeout": "2s", + "type": "none", + }, + hotplug_size="2GB", + hypervisor="cloud-hypervisor", + network={ + "bandwidth_download": "1Gbps", + "bandwidth_upload": "1Gbps", + "egress": { + "enabled": True, + "enforcement": {"mode": "all"}, + }, + "enabled": True, + }, + overlay_size="20GB", + platform="linux/amd64", + restart_policy={ + "backoff": "5s", + "max_attempts": 10, + "policy": "on_failure", + "stable_after": "10m", + }, + size="2GB", + skip_guest_agent=False, + skip_kernel_headers=True, + snapshot_policy={ + "compression": { + "enabled": True, + "algorithm": "zstd", + "level": 1, + }, + "standby_compression_delay": "2m", + }, + tags={ + "team": "backend", + "env": "staging", + }, + vcpus=2, + volumes=[ + { + "mount_path": "/mnt/data", + "volume_id": "vol-abc123", + "overlay": True, + "overlay_size": "1GB", + "readonly": True, + } + ], + ) + assert_matches_type(Instance, instance, path=["response"]) + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + async def test_raw_response_create(self, async_client: AsyncHypeman) -> None: + response = await async_client.instances.with_raw_response.create( + image="docker.io/library/alpine:latest", + name="my-workload-1", + ) + + assert response.is_closed is True + assert response.http_request.headers.get("X-Stainless-Lang") == "python" + instance = await response.parse() + assert_matches_type(Instance, instance, path=["response"]) + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + async def test_streaming_response_create(self, async_client: AsyncHypeman) -> None: + async with async_client.instances.with_streaming_response.create( + image="docker.io/library/alpine:latest", + name="my-workload-1", + ) as response: + assert not response.is_closed + assert response.http_request.headers.get("X-Stainless-Lang") == "python" + + instance = await response.parse() + assert_matches_type(Instance, instance, 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: AsyncHypeman) -> None: + instance = await async_client.instances.update( + id="id", + ) + assert_matches_type(Instance, instance, path=["response"]) + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + async def test_method_update_with_all_params(self, async_client: AsyncHypeman) -> None: + instance = await async_client.instances.update( + id="id", + auto_standby={ + "enabled": True, + "idle_timeout": "5m", + "ignore_destination_ports": [22, 9000], + "ignore_source_cidrs": ["10.0.0.0/8", "192.168.0.0/16"], + }, + env={"OUTBOUND_OPENAI_KEY": "new-rotated-key-456"}, + health_check={ + "exec": { + "command": ["curl", "-f", "http://localhost:4318/"], + "working_dir": "/app", + }, + "failure_threshold": 3, + "http": { + "port": 8080, + "expected_status": 200, + "path": "/healthz", + "scheme": "http", + }, + "interval": "10s", + "start_period": "30s", + "success_threshold": 1, + "tcp": {"port": 5432}, + "timeout": "2s", + "type": "none", + }, + restart_policy={ + "backoff": "5s", + "max_attempts": 10, + "policy": "on_failure", + "stable_after": "10m", + }, + ) + assert_matches_type(Instance, instance, path=["response"]) + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + async def test_raw_response_update(self, async_client: AsyncHypeman) -> None: + response = await async_client.instances.with_raw_response.update( + id="id", + ) + + assert response.is_closed is True + assert response.http_request.headers.get("X-Stainless-Lang") == "python" + instance = await response.parse() + assert_matches_type(Instance, instance, path=["response"]) + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + async def test_streaming_response_update(self, async_client: AsyncHypeman) -> None: + async with async_client.instances.with_streaming_response.update( + id="id", + ) as response: + assert not response.is_closed + assert response.http_request.headers.get("X-Stainless-Lang") == "python" + + instance = await response.parse() + assert_matches_type(Instance, instance, 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: AsyncHypeman) -> None: + with pytest.raises(ValueError, match=r"Expected a non-empty value for `id` but received ''"): + await async_client.instances.with_raw_response.update( + id="", + ) + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + async def test_method_list(self, async_client: AsyncHypeman) -> None: + instance = await async_client.instances.list() + assert_matches_type(InstanceListResponse, instance, path=["response"]) + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + async def test_method_list_with_all_params(self, async_client: AsyncHypeman) -> None: + instance = await async_client.instances.list( + state="Created", + tags={ + "team": "backend", + "env": "staging", + }, + ) + assert_matches_type(InstanceListResponse, instance, path=["response"]) + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + async def test_raw_response_list(self, async_client: AsyncHypeman) -> None: + response = await async_client.instances.with_raw_response.list() + + assert response.is_closed is True + assert response.http_request.headers.get("X-Stainless-Lang") == "python" + instance = await response.parse() + assert_matches_type(InstanceListResponse, instance, path=["response"]) + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + async def test_streaming_response_list(self, async_client: AsyncHypeman) -> None: + async with async_client.instances.with_streaming_response.list() as response: + assert not response.is_closed + assert response.http_request.headers.get("X-Stainless-Lang") == "python" + + instance = await response.parse() + assert_matches_type(InstanceListResponse, instance, 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: AsyncHypeman) -> None: + instance = await async_client.instances.delete( + "id", + ) + assert instance is None + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + async def test_raw_response_delete(self, async_client: AsyncHypeman) -> None: + response = await async_client.instances.with_raw_response.delete( + "id", + ) + + assert response.is_closed is True + assert response.http_request.headers.get("X-Stainless-Lang") == "python" + instance = await response.parse() + assert instance is None + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + async def test_streaming_response_delete(self, async_client: AsyncHypeman) -> None: + async with async_client.instances.with_streaming_response.delete( + "id", + ) as response: + assert not response.is_closed + assert response.http_request.headers.get("X-Stainless-Lang") == "python" + + instance = await response.parse() + assert instance 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: AsyncHypeman) -> None: + with pytest.raises(ValueError, match=r"Expected a non-empty value for `id` but received ''"): + await async_client.instances.with_raw_response.delete( + "", + ) + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + async def test_method_fork(self, async_client: AsyncHypeman) -> None: + instance = await async_client.instances.fork( + id="id", + name="my-workload-1-fork", + ) + assert_matches_type(Instance, instance, path=["response"]) + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + async def test_method_fork_with_all_params(self, async_client: AsyncHypeman) -> None: + instance = await async_client.instances.fork( + id="id", + name="my-workload-1-fork", + from_running=False, + target_state="Running", + ) + assert_matches_type(Instance, instance, path=["response"]) + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + async def test_raw_response_fork(self, async_client: AsyncHypeman) -> None: + response = await async_client.instances.with_raw_response.fork( + id="id", + name="my-workload-1-fork", + ) + + assert response.is_closed is True + assert response.http_request.headers.get("X-Stainless-Lang") == "python" + instance = await response.parse() + assert_matches_type(Instance, instance, path=["response"]) + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + async def test_streaming_response_fork(self, async_client: AsyncHypeman) -> None: + async with async_client.instances.with_streaming_response.fork( + id="id", + name="my-workload-1-fork", + ) as response: + assert not response.is_closed + assert response.http_request.headers.get("X-Stainless-Lang") == "python" + + instance = await response.parse() + assert_matches_type(Instance, instance, 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_fork(self, async_client: AsyncHypeman) -> None: + with pytest.raises(ValueError, match=r"Expected a non-empty value for `id` but received ''"): + await async_client.instances.with_raw_response.fork( + id="", + name="my-workload-1-fork", + ) + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + async def test_method_get(self, async_client: AsyncHypeman) -> None: + instance = await async_client.instances.get( + "id", + ) + assert_matches_type(Instance, instance, path=["response"]) + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + async def test_raw_response_get(self, async_client: AsyncHypeman) -> None: + response = await async_client.instances.with_raw_response.get( + "id", + ) + + assert response.is_closed is True + assert response.http_request.headers.get("X-Stainless-Lang") == "python" + instance = await response.parse() + assert_matches_type(Instance, instance, path=["response"]) + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + async def test_streaming_response_get(self, async_client: AsyncHypeman) -> None: + async with async_client.instances.with_streaming_response.get( + "id", + ) as response: + assert not response.is_closed + assert response.http_request.headers.get("X-Stainless-Lang") == "python" + + instance = await response.parse() + assert_matches_type(Instance, instance, 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: AsyncHypeman) -> None: + with pytest.raises(ValueError, match=r"Expected a non-empty value for `id` but received ''"): + await async_client.instances.with_raw_response.get( + "", + ) + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + async def test_method_logs(self, async_client: AsyncHypeman) -> None: + instance_stream = await async_client.instances.logs( + id="id", + ) + await instance_stream.response.aclose() + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + async def test_method_logs_with_all_params(self, async_client: AsyncHypeman) -> None: + instance_stream = await async_client.instances.logs( + id="id", + follow=True, + source="app", + tail=0, + ) + await instance_stream.response.aclose() + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + async def test_raw_response_logs(self, async_client: AsyncHypeman) -> None: + response = await async_client.instances.with_raw_response.logs( + 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_logs(self, async_client: AsyncHypeman) -> None: + async with async_client.instances.with_streaming_response.logs( + 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_logs(self, async_client: AsyncHypeman) -> None: + with pytest.raises(ValueError, match=r"Expected a non-empty value for `id` but received ''"): + await async_client.instances.with_raw_response.logs( + id="", + ) + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + async def test_method_restore(self, async_client: AsyncHypeman) -> None: + instance = await async_client.instances.restore( + "id", + ) + assert_matches_type(Instance, instance, path=["response"]) + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + async def test_raw_response_restore(self, async_client: AsyncHypeman) -> None: + response = await async_client.instances.with_raw_response.restore( + "id", + ) + + assert response.is_closed is True + assert response.http_request.headers.get("X-Stainless-Lang") == "python" + instance = await response.parse() + assert_matches_type(Instance, instance, path=["response"]) + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + async def test_streaming_response_restore(self, async_client: AsyncHypeman) -> None: + async with async_client.instances.with_streaming_response.restore( + "id", + ) as response: + assert not response.is_closed + assert response.http_request.headers.get("X-Stainless-Lang") == "python" + + instance = await response.parse() + assert_matches_type(Instance, instance, 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_restore(self, async_client: AsyncHypeman) -> None: + with pytest.raises(ValueError, match=r"Expected a non-empty value for `id` but received ''"): + await async_client.instances.with_raw_response.restore( + "", + ) + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + async def test_method_standby(self, async_client: AsyncHypeman) -> None: + instance = await async_client.instances.standby( + id="id", + ) + assert_matches_type(Instance, instance, path=["response"]) + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + async def test_method_standby_with_all_params(self, async_client: AsyncHypeman) -> None: + instance = await async_client.instances.standby( + id="id", + compression={ + "enabled": True, + "algorithm": "zstd", + "level": 1, + }, + compression_delay="45s", + ) + assert_matches_type(Instance, instance, path=["response"]) + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + async def test_raw_response_standby(self, async_client: AsyncHypeman) -> None: + response = await async_client.instances.with_raw_response.standby( + id="id", + ) + + assert response.is_closed is True + assert response.http_request.headers.get("X-Stainless-Lang") == "python" + instance = await response.parse() + assert_matches_type(Instance, instance, path=["response"]) + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + async def test_streaming_response_standby(self, async_client: AsyncHypeman) -> None: + async with async_client.instances.with_streaming_response.standby( + id="id", + ) as response: + assert not response.is_closed + assert response.http_request.headers.get("X-Stainless-Lang") == "python" + + instance = await response.parse() + assert_matches_type(Instance, instance, 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_standby(self, async_client: AsyncHypeman) -> None: + with pytest.raises(ValueError, match=r"Expected a non-empty value for `id` but received ''"): + await async_client.instances.with_raw_response.standby( + id="", + ) + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + async def test_method_start(self, async_client: AsyncHypeman) -> None: + instance = await async_client.instances.start( + id="id", + ) + assert_matches_type(Instance, instance, path=["response"]) + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + async def test_method_start_with_all_params(self, async_client: AsyncHypeman) -> None: + instance = await async_client.instances.start( + id="id", + cmd=["string"], + entrypoint=["string"], + ) + assert_matches_type(Instance, instance, path=["response"]) + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + async def test_raw_response_start(self, async_client: AsyncHypeman) -> None: + response = await async_client.instances.with_raw_response.start( + id="id", + ) + + assert response.is_closed is True + assert response.http_request.headers.get("X-Stainless-Lang") == "python" + instance = await response.parse() + assert_matches_type(Instance, instance, path=["response"]) + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + async def test_streaming_response_start(self, async_client: AsyncHypeman) -> None: + async with async_client.instances.with_streaming_response.start( + id="id", + ) as response: + assert not response.is_closed + assert response.http_request.headers.get("X-Stainless-Lang") == "python" + + instance = await response.parse() + assert_matches_type(Instance, instance, 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: AsyncHypeman) -> None: + with pytest.raises(ValueError, match=r"Expected a non-empty value for `id` but received ''"): + await async_client.instances.with_raw_response.start( + id="", + ) + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + async def test_method_stat(self, async_client: AsyncHypeman) -> None: + instance = await async_client.instances.stat( + id="id", + path="path", + ) + assert_matches_type(PathInfo, instance, path=["response"]) + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + async def test_method_stat_with_all_params(self, async_client: AsyncHypeman) -> None: + instance = await async_client.instances.stat( + id="id", + path="path", + follow_links=True, + ) + assert_matches_type(PathInfo, instance, path=["response"]) + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + async def test_raw_response_stat(self, async_client: AsyncHypeman) -> None: + response = await async_client.instances.with_raw_response.stat( + id="id", + path="path", + ) + + assert response.is_closed is True + assert response.http_request.headers.get("X-Stainless-Lang") == "python" + instance = await response.parse() + assert_matches_type(PathInfo, instance, path=["response"]) + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + async def test_streaming_response_stat(self, async_client: AsyncHypeman) -> None: + async with async_client.instances.with_streaming_response.stat( + id="id", + path="path", + ) as response: + assert not response.is_closed + assert response.http_request.headers.get("X-Stainless-Lang") == "python" + + instance = await response.parse() + assert_matches_type(PathInfo, instance, 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_stat(self, async_client: AsyncHypeman) -> None: + with pytest.raises(ValueError, match=r"Expected a non-empty value for `id` but received ''"): + await async_client.instances.with_raw_response.stat( + id="", + path="path", + ) + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + async def test_method_stats(self, async_client: AsyncHypeman) -> None: + instance = await async_client.instances.stats( + "id", + ) + assert_matches_type(InstanceStats, instance, path=["response"]) + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + async def test_raw_response_stats(self, async_client: AsyncHypeman) -> None: + response = await async_client.instances.with_raw_response.stats( + "id", + ) + + assert response.is_closed is True + assert response.http_request.headers.get("X-Stainless-Lang") == "python" + instance = await response.parse() + assert_matches_type(InstanceStats, instance, path=["response"]) + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + async def test_streaming_response_stats(self, async_client: AsyncHypeman) -> None: + async with async_client.instances.with_streaming_response.stats( + "id", + ) as response: + assert not response.is_closed + assert response.http_request.headers.get("X-Stainless-Lang") == "python" + + instance = await response.parse() + assert_matches_type(InstanceStats, instance, 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_stats(self, async_client: AsyncHypeman) -> None: + with pytest.raises(ValueError, match=r"Expected a non-empty value for `id` but received ''"): + await async_client.instances.with_raw_response.stats( + "", + ) + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + async def test_method_stop(self, async_client: AsyncHypeman) -> None: + instance = await async_client.instances.stop( + "id", + ) + assert_matches_type(Instance, instance, path=["response"]) + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + async def test_raw_response_stop(self, async_client: AsyncHypeman) -> None: + response = await async_client.instances.with_raw_response.stop( + "id", + ) + + assert response.is_closed is True + assert response.http_request.headers.get("X-Stainless-Lang") == "python" + instance = await response.parse() + assert_matches_type(Instance, instance, path=["response"]) + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + async def test_streaming_response_stop(self, async_client: AsyncHypeman) -> None: + async with async_client.instances.with_streaming_response.stop( + "id", + ) as response: + assert not response.is_closed + assert response.http_request.headers.get("X-Stainless-Lang") == "python" + + instance = await response.parse() + assert_matches_type(Instance, instance, 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_stop(self, async_client: AsyncHypeman) -> None: + with pytest.raises(ValueError, match=r"Expected a non-empty value for `id` but received ''"): + await async_client.instances.with_raw_response.stop( + "", + ) + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + async def test_method_wait(self, async_client: AsyncHypeman) -> None: + instance = await async_client.instances.wait( + id="id", + state="Created", + ) + assert_matches_type(WaitForStateResponse, instance, path=["response"]) + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + async def test_method_wait_with_all_params(self, async_client: AsyncHypeman) -> None: + instance = await async_client.instances.wait( + id="id", + state="Created", + api_timeout="timeout", + ) + assert_matches_type(WaitForStateResponse, instance, path=["response"]) + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + async def test_raw_response_wait(self, async_client: AsyncHypeman) -> None: + response = await async_client.instances.with_raw_response.wait( + id="id", + state="Created", + ) + + assert response.is_closed is True + assert response.http_request.headers.get("X-Stainless-Lang") == "python" + instance = await response.parse() + assert_matches_type(WaitForStateResponse, instance, path=["response"]) + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + async def test_streaming_response_wait(self, async_client: AsyncHypeman) -> None: + async with async_client.instances.with_streaming_response.wait( + id="id", + state="Created", + ) as response: + assert not response.is_closed + assert response.http_request.headers.get("X-Stainless-Lang") == "python" + + instance = await response.parse() + assert_matches_type(WaitForStateResponse, instance, 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_wait(self, async_client: AsyncHypeman) -> None: + with pytest.raises(ValueError, match=r"Expected a non-empty value for `id` but received ''"): + await async_client.instances.with_raw_response.wait( + id="", + state="Created", + ) diff --git a/tests/api_resources/test_pushes.py b/tests/api_resources/test_pushes.py new file mode 100644 index 0000000..27a86dd --- /dev/null +++ b/tests/api_resources/test_pushes.py @@ -0,0 +1,268 @@ +# 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 hypeman import Hypeman, AsyncHypeman +from tests.utils import assert_matches_type +from hypeman.types import Push, PushListResponse + +base_url = os.environ.get("TEST_API_BASE_URL", "http://127.0.0.1:4010") + + +class TestPushes: + 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: Hypeman) -> None: + push = client.pushes.create( + image="docker.io/library/alpine:latest", + target="123456789.dkr.ecr.us-east-1.amazonaws.com/myapp:v1", + ) + assert_matches_type(Push, push, path=["response"]) + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + def test_method_create_with_all_params(self, client: Hypeman) -> None: + push = client.pushes.create( + image="docker.io/library/alpine:latest", + target="123456789.dkr.ecr.us-east-1.amazonaws.com/myapp:v1", + credentials={ + "password": "password", + "registry_token": "registry_token", + "username": "username", + }, + insecure=True, + ) + assert_matches_type(Push, push, path=["response"]) + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + def test_raw_response_create(self, client: Hypeman) -> None: + response = client.pushes.with_raw_response.create( + image="docker.io/library/alpine:latest", + target="123456789.dkr.ecr.us-east-1.amazonaws.com/myapp:v1", + ) + + assert response.is_closed is True + assert response.http_request.headers.get("X-Stainless-Lang") == "python" + push = response.parse() + assert_matches_type(Push, push, path=["response"]) + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + def test_streaming_response_create(self, client: Hypeman) -> None: + with client.pushes.with_streaming_response.create( + image="docker.io/library/alpine:latest", + target="123456789.dkr.ecr.us-east-1.amazonaws.com/myapp:v1", + ) as response: + assert not response.is_closed + assert response.http_request.headers.get("X-Stainless-Lang") == "python" + + push = response.parse() + assert_matches_type(Push, push, path=["response"]) + + assert cast(Any, response.is_closed) is True + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + def test_method_list(self, client: Hypeman) -> None: + push = client.pushes.list() + assert_matches_type(PushListResponse, push, path=["response"]) + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + def test_raw_response_list(self, client: Hypeman) -> None: + response = client.pushes.with_raw_response.list() + + assert response.is_closed is True + assert response.http_request.headers.get("X-Stainless-Lang") == "python" + push = response.parse() + assert_matches_type(PushListResponse, push, path=["response"]) + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + def test_streaming_response_list(self, client: Hypeman) -> None: + with client.pushes.with_streaming_response.list() as response: + assert not response.is_closed + assert response.http_request.headers.get("X-Stainless-Lang") == "python" + + push = response.parse() + assert_matches_type(PushListResponse, push, path=["response"]) + + assert cast(Any, response.is_closed) is True + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + def test_method_get(self, client: Hypeman) -> None: + push = client.pushes.get( + "id", + ) + assert_matches_type(Push, push, path=["response"]) + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + def test_raw_response_get(self, client: Hypeman) -> None: + response = client.pushes.with_raw_response.get( + "id", + ) + + assert response.is_closed is True + assert response.http_request.headers.get("X-Stainless-Lang") == "python" + push = response.parse() + assert_matches_type(Push, push, path=["response"]) + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + def test_streaming_response_get(self, client: Hypeman) -> None: + with client.pushes.with_streaming_response.get( + "id", + ) as response: + assert not response.is_closed + assert response.http_request.headers.get("X-Stainless-Lang") == "python" + + push = response.parse() + assert_matches_type(Push, push, 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: Hypeman) -> None: + with pytest.raises(ValueError, match=r"Expected a non-empty value for `id` but received ''"): + client.pushes.with_raw_response.get( + "", + ) + + +class TestAsyncPushes: + 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: AsyncHypeman) -> None: + push = await async_client.pushes.create( + image="docker.io/library/alpine:latest", + target="123456789.dkr.ecr.us-east-1.amazonaws.com/myapp:v1", + ) + assert_matches_type(Push, push, path=["response"]) + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + async def test_method_create_with_all_params(self, async_client: AsyncHypeman) -> None: + push = await async_client.pushes.create( + image="docker.io/library/alpine:latest", + target="123456789.dkr.ecr.us-east-1.amazonaws.com/myapp:v1", + credentials={ + "password": "password", + "registry_token": "registry_token", + "username": "username", + }, + insecure=True, + ) + assert_matches_type(Push, push, path=["response"]) + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + async def test_raw_response_create(self, async_client: AsyncHypeman) -> None: + response = await async_client.pushes.with_raw_response.create( + image="docker.io/library/alpine:latest", + target="123456789.dkr.ecr.us-east-1.amazonaws.com/myapp:v1", + ) + + assert response.is_closed is True + assert response.http_request.headers.get("X-Stainless-Lang") == "python" + push = await response.parse() + assert_matches_type(Push, push, path=["response"]) + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + async def test_streaming_response_create(self, async_client: AsyncHypeman) -> None: + async with async_client.pushes.with_streaming_response.create( + image="docker.io/library/alpine:latest", + target="123456789.dkr.ecr.us-east-1.amazonaws.com/myapp:v1", + ) as response: + assert not response.is_closed + assert response.http_request.headers.get("X-Stainless-Lang") == "python" + + push = await response.parse() + assert_matches_type(Push, push, path=["response"]) + + assert cast(Any, response.is_closed) is True + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + async def test_method_list(self, async_client: AsyncHypeman) -> None: + push = await async_client.pushes.list() + assert_matches_type(PushListResponse, push, path=["response"]) + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + async def test_raw_response_list(self, async_client: AsyncHypeman) -> None: + response = await async_client.pushes.with_raw_response.list() + + assert response.is_closed is True + assert response.http_request.headers.get("X-Stainless-Lang") == "python" + push = await response.parse() + assert_matches_type(PushListResponse, push, path=["response"]) + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + async def test_streaming_response_list(self, async_client: AsyncHypeman) -> None: + async with async_client.pushes.with_streaming_response.list() as response: + assert not response.is_closed + assert response.http_request.headers.get("X-Stainless-Lang") == "python" + + push = await response.parse() + assert_matches_type(PushListResponse, push, path=["response"]) + + assert cast(Any, response.is_closed) is True + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + async def test_method_get(self, async_client: AsyncHypeman) -> None: + push = await async_client.pushes.get( + "id", + ) + assert_matches_type(Push, push, path=["response"]) + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + async def test_raw_response_get(self, async_client: AsyncHypeman) -> None: + response = await async_client.pushes.with_raw_response.get( + "id", + ) + + assert response.is_closed is True + assert response.http_request.headers.get("X-Stainless-Lang") == "python" + push = await response.parse() + assert_matches_type(Push, push, path=["response"]) + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + async def test_streaming_response_get(self, async_client: AsyncHypeman) -> None: + async with async_client.pushes.with_streaming_response.get( + "id", + ) as response: + assert not response.is_closed + assert response.http_request.headers.get("X-Stainless-Lang") == "python" + + push = await response.parse() + assert_matches_type(Push, push, 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: AsyncHypeman) -> None: + with pytest.raises(ValueError, match=r"Expected a non-empty value for `id` but received ''"): + await async_client.pushes.with_raw_response.get( + "", + ) diff --git a/tests/api_resources/test_resources.py b/tests/api_resources/test_resources.py new file mode 100644 index 0000000..d829264 --- /dev/null +++ b/tests/api_resources/test_resources.py @@ -0,0 +1,170 @@ +# 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 hypeman import Hypeman, AsyncHypeman +from tests.utils import assert_matches_type +from hypeman.types import Resources, MemoryReclaimResponse + +base_url = os.environ.get("TEST_API_BASE_URL", "http://127.0.0.1:4010") + + +class TestResources: + 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_get(self, client: Hypeman) -> None: + resource = client.resources.get() + assert_matches_type(Resources, resource, path=["response"]) + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + def test_raw_response_get(self, client: Hypeman) -> None: + response = client.resources.with_raw_response.get() + + assert response.is_closed is True + assert response.http_request.headers.get("X-Stainless-Lang") == "python" + resource = response.parse() + assert_matches_type(Resources, resource, path=["response"]) + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + def test_streaming_response_get(self, client: Hypeman) -> None: + with client.resources.with_streaming_response.get() as response: + assert not response.is_closed + assert response.http_request.headers.get("X-Stainless-Lang") == "python" + + resource = response.parse() + assert_matches_type(Resources, resource, path=["response"]) + + assert cast(Any, response.is_closed) is True + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + def test_method_reclaim_memory(self, client: Hypeman) -> None: + resource = client.resources.reclaim_memory( + reclaim_bytes=536870912, + ) + assert_matches_type(MemoryReclaimResponse, resource, path=["response"]) + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + def test_method_reclaim_memory_with_all_params(self, client: Hypeman) -> None: + resource = client.resources.reclaim_memory( + reclaim_bytes=536870912, + dry_run=True, + hold_for="5m", + reason="prepare for another vm start", + ) + assert_matches_type(MemoryReclaimResponse, resource, path=["response"]) + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + def test_raw_response_reclaim_memory(self, client: Hypeman) -> None: + response = client.resources.with_raw_response.reclaim_memory( + reclaim_bytes=536870912, + ) + + assert response.is_closed is True + assert response.http_request.headers.get("X-Stainless-Lang") == "python" + resource = response.parse() + assert_matches_type(MemoryReclaimResponse, resource, path=["response"]) + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + def test_streaming_response_reclaim_memory(self, client: Hypeman) -> None: + with client.resources.with_streaming_response.reclaim_memory( + reclaim_bytes=536870912, + ) as response: + assert not response.is_closed + assert response.http_request.headers.get("X-Stainless-Lang") == "python" + + resource = response.parse() + assert_matches_type(MemoryReclaimResponse, resource, path=["response"]) + + assert cast(Any, response.is_closed) is True + + +class TestAsyncResources: + 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_get(self, async_client: AsyncHypeman) -> None: + resource = await async_client.resources.get() + assert_matches_type(Resources, resource, path=["response"]) + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + async def test_raw_response_get(self, async_client: AsyncHypeman) -> None: + response = await async_client.resources.with_raw_response.get() + + assert response.is_closed is True + assert response.http_request.headers.get("X-Stainless-Lang") == "python" + resource = await response.parse() + assert_matches_type(Resources, resource, path=["response"]) + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + async def test_streaming_response_get(self, async_client: AsyncHypeman) -> None: + async with async_client.resources.with_streaming_response.get() as response: + assert not response.is_closed + assert response.http_request.headers.get("X-Stainless-Lang") == "python" + + resource = await response.parse() + assert_matches_type(Resources, resource, path=["response"]) + + assert cast(Any, response.is_closed) is True + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + async def test_method_reclaim_memory(self, async_client: AsyncHypeman) -> None: + resource = await async_client.resources.reclaim_memory( + reclaim_bytes=536870912, + ) + assert_matches_type(MemoryReclaimResponse, resource, path=["response"]) + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + async def test_method_reclaim_memory_with_all_params(self, async_client: AsyncHypeman) -> None: + resource = await async_client.resources.reclaim_memory( + reclaim_bytes=536870912, + dry_run=True, + hold_for="5m", + reason="prepare for another vm start", + ) + assert_matches_type(MemoryReclaimResponse, resource, path=["response"]) + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + async def test_raw_response_reclaim_memory(self, async_client: AsyncHypeman) -> None: + response = await async_client.resources.with_raw_response.reclaim_memory( + reclaim_bytes=536870912, + ) + + assert response.is_closed is True + assert response.http_request.headers.get("X-Stainless-Lang") == "python" + resource = await response.parse() + assert_matches_type(MemoryReclaimResponse, resource, path=["response"]) + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + async def test_streaming_response_reclaim_memory(self, async_client: AsyncHypeman) -> None: + async with async_client.resources.with_streaming_response.reclaim_memory( + reclaim_bytes=536870912, + ) as response: + assert not response.is_closed + assert response.http_request.headers.get("X-Stainless-Lang") == "python" + + resource = await response.parse() + assert_matches_type(MemoryReclaimResponse, resource, path=["response"]) + + assert cast(Any, response.is_closed) is True diff --git a/tests/api_resources/test_snapshots.py b/tests/api_resources/test_snapshots.py new file mode 100644 index 0000000..a20dc9b --- /dev/null +++ b/tests/api_resources/test_snapshots.py @@ -0,0 +1,394 @@ +# 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 hypeman import Hypeman, AsyncHypeman +from tests.utils import assert_matches_type +from hypeman.types import ( + Instance, + Snapshot, + SnapshotListResponse, +) + +base_url = os.environ.get("TEST_API_BASE_URL", "http://127.0.0.1:4010") + + +class TestSnapshots: + 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: Hypeman) -> None: + snapshot = client.snapshots.list() + assert_matches_type(SnapshotListResponse, snapshot, path=["response"]) + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + def test_method_list_with_all_params(self, client: Hypeman) -> None: + snapshot = client.snapshots.list( + kind="Standby", + name="name", + source_instance_id="source_instance_id", + tags={ + "team": "backend", + "env": "staging", + }, + ) + assert_matches_type(SnapshotListResponse, snapshot, path=["response"]) + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + def test_raw_response_list(self, client: Hypeman) -> None: + response = client.snapshots.with_raw_response.list() + + assert response.is_closed is True + assert response.http_request.headers.get("X-Stainless-Lang") == "python" + snapshot = response.parse() + assert_matches_type(SnapshotListResponse, snapshot, path=["response"]) + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + def test_streaming_response_list(self, client: Hypeman) -> None: + with client.snapshots.with_streaming_response.list() as response: + assert not response.is_closed + assert response.http_request.headers.get("X-Stainless-Lang") == "python" + + snapshot = response.parse() + assert_matches_type(SnapshotListResponse, snapshot, 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: Hypeman) -> None: + snapshot = client.snapshots.delete( + "snapshotId", + ) + assert snapshot is None + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + def test_raw_response_delete(self, client: Hypeman) -> None: + response = client.snapshots.with_raw_response.delete( + "snapshotId", + ) + + assert response.is_closed is True + assert response.http_request.headers.get("X-Stainless-Lang") == "python" + snapshot = response.parse() + assert snapshot is None + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + def test_streaming_response_delete(self, client: Hypeman) -> None: + with client.snapshots.with_streaming_response.delete( + "snapshotId", + ) as response: + assert not response.is_closed + assert response.http_request.headers.get("X-Stainless-Lang") == "python" + + snapshot = response.parse() + assert snapshot 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: Hypeman) -> None: + with pytest.raises(ValueError, match=r"Expected a non-empty value for `snapshot_id` but received ''"): + client.snapshots.with_raw_response.delete( + "", + ) + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + def test_method_fork(self, client: Hypeman) -> None: + snapshot = client.snapshots.fork( + snapshot_id="snapshotId", + name="nginx-from-snap", + ) + assert_matches_type(Instance, snapshot, path=["response"]) + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + def test_method_fork_with_all_params(self, client: Hypeman) -> None: + snapshot = client.snapshots.fork( + snapshot_id="snapshotId", + name="nginx-from-snap", + target_hypervisor="cloud-hypervisor", + target_state="Running", + ) + assert_matches_type(Instance, snapshot, path=["response"]) + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + def test_raw_response_fork(self, client: Hypeman) -> None: + response = client.snapshots.with_raw_response.fork( + snapshot_id="snapshotId", + name="nginx-from-snap", + ) + + assert response.is_closed is True + assert response.http_request.headers.get("X-Stainless-Lang") == "python" + snapshot = response.parse() + assert_matches_type(Instance, snapshot, path=["response"]) + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + def test_streaming_response_fork(self, client: Hypeman) -> None: + with client.snapshots.with_streaming_response.fork( + snapshot_id="snapshotId", + name="nginx-from-snap", + ) as response: + assert not response.is_closed + assert response.http_request.headers.get("X-Stainless-Lang") == "python" + + snapshot = response.parse() + assert_matches_type(Instance, snapshot, path=["response"]) + + assert cast(Any, response.is_closed) is True + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + def test_path_params_fork(self, client: Hypeman) -> None: + with pytest.raises(ValueError, match=r"Expected a non-empty value for `snapshot_id` but received ''"): + client.snapshots.with_raw_response.fork( + snapshot_id="", + name="nginx-from-snap", + ) + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + def test_method_get(self, client: Hypeman) -> None: + snapshot = client.snapshots.get( + "snapshotId", + ) + assert_matches_type(Snapshot, snapshot, path=["response"]) + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + def test_raw_response_get(self, client: Hypeman) -> None: + response = client.snapshots.with_raw_response.get( + "snapshotId", + ) + + assert response.is_closed is True + assert response.http_request.headers.get("X-Stainless-Lang") == "python" + snapshot = response.parse() + assert_matches_type(Snapshot, snapshot, path=["response"]) + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + def test_streaming_response_get(self, client: Hypeman) -> None: + with client.snapshots.with_streaming_response.get( + "snapshotId", + ) as response: + assert not response.is_closed + assert response.http_request.headers.get("X-Stainless-Lang") == "python" + + snapshot = response.parse() + assert_matches_type(Snapshot, snapshot, 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: Hypeman) -> None: + with pytest.raises(ValueError, match=r"Expected a non-empty value for `snapshot_id` but received ''"): + client.snapshots.with_raw_response.get( + "", + ) + + +class TestAsyncSnapshots: + 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: AsyncHypeman) -> None: + snapshot = await async_client.snapshots.list() + assert_matches_type(SnapshotListResponse, snapshot, path=["response"]) + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + async def test_method_list_with_all_params(self, async_client: AsyncHypeman) -> None: + snapshot = await async_client.snapshots.list( + kind="Standby", + name="name", + source_instance_id="source_instance_id", + tags={ + "team": "backend", + "env": "staging", + }, + ) + assert_matches_type(SnapshotListResponse, snapshot, path=["response"]) + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + async def test_raw_response_list(self, async_client: AsyncHypeman) -> None: + response = await async_client.snapshots.with_raw_response.list() + + assert response.is_closed is True + assert response.http_request.headers.get("X-Stainless-Lang") == "python" + snapshot = await response.parse() + assert_matches_type(SnapshotListResponse, snapshot, path=["response"]) + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + async def test_streaming_response_list(self, async_client: AsyncHypeman) -> None: + async with async_client.snapshots.with_streaming_response.list() as response: + assert not response.is_closed + assert response.http_request.headers.get("X-Stainless-Lang") == "python" + + snapshot = await response.parse() + assert_matches_type(SnapshotListResponse, snapshot, 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: AsyncHypeman) -> None: + snapshot = await async_client.snapshots.delete( + "snapshotId", + ) + assert snapshot is None + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + async def test_raw_response_delete(self, async_client: AsyncHypeman) -> None: + response = await async_client.snapshots.with_raw_response.delete( + "snapshotId", + ) + + assert response.is_closed is True + assert response.http_request.headers.get("X-Stainless-Lang") == "python" + snapshot = await response.parse() + assert snapshot is None + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + async def test_streaming_response_delete(self, async_client: AsyncHypeman) -> None: + async with async_client.snapshots.with_streaming_response.delete( + "snapshotId", + ) as response: + assert not response.is_closed + assert response.http_request.headers.get("X-Stainless-Lang") == "python" + + snapshot = await response.parse() + assert snapshot 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: AsyncHypeman) -> None: + with pytest.raises(ValueError, match=r"Expected a non-empty value for `snapshot_id` but received ''"): + await async_client.snapshots.with_raw_response.delete( + "", + ) + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + async def test_method_fork(self, async_client: AsyncHypeman) -> None: + snapshot = await async_client.snapshots.fork( + snapshot_id="snapshotId", + name="nginx-from-snap", + ) + assert_matches_type(Instance, snapshot, path=["response"]) + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + async def test_method_fork_with_all_params(self, async_client: AsyncHypeman) -> None: + snapshot = await async_client.snapshots.fork( + snapshot_id="snapshotId", + name="nginx-from-snap", + target_hypervisor="cloud-hypervisor", + target_state="Running", + ) + assert_matches_type(Instance, snapshot, path=["response"]) + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + async def test_raw_response_fork(self, async_client: AsyncHypeman) -> None: + response = await async_client.snapshots.with_raw_response.fork( + snapshot_id="snapshotId", + name="nginx-from-snap", + ) + + assert response.is_closed is True + assert response.http_request.headers.get("X-Stainless-Lang") == "python" + snapshot = await response.parse() + assert_matches_type(Instance, snapshot, path=["response"]) + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + async def test_streaming_response_fork(self, async_client: AsyncHypeman) -> None: + async with async_client.snapshots.with_streaming_response.fork( + snapshot_id="snapshotId", + name="nginx-from-snap", + ) as response: + assert not response.is_closed + assert response.http_request.headers.get("X-Stainless-Lang") == "python" + + snapshot = await response.parse() + assert_matches_type(Instance, snapshot, 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_fork(self, async_client: AsyncHypeman) -> None: + with pytest.raises(ValueError, match=r"Expected a non-empty value for `snapshot_id` but received ''"): + await async_client.snapshots.with_raw_response.fork( + snapshot_id="", + name="nginx-from-snap", + ) + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + async def test_method_get(self, async_client: AsyncHypeman) -> None: + snapshot = await async_client.snapshots.get( + "snapshotId", + ) + assert_matches_type(Snapshot, snapshot, path=["response"]) + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + async def test_raw_response_get(self, async_client: AsyncHypeman) -> None: + response = await async_client.snapshots.with_raw_response.get( + "snapshotId", + ) + + assert response.is_closed is True + assert response.http_request.headers.get("X-Stainless-Lang") == "python" + snapshot = await response.parse() + assert_matches_type(Snapshot, snapshot, path=["response"]) + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + async def test_streaming_response_get(self, async_client: AsyncHypeman) -> None: + async with async_client.snapshots.with_streaming_response.get( + "snapshotId", + ) as response: + assert not response.is_closed + assert response.http_request.headers.get("X-Stainless-Lang") == "python" + + snapshot = await response.parse() + assert_matches_type(Snapshot, snapshot, 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: AsyncHypeman) -> None: + with pytest.raises(ValueError, match=r"Expected a non-empty value for `snapshot_id` but received ''"): + await async_client.snapshots.with_raw_response.get( + "", + ) diff --git a/tests/api_resources/test_volumes.py b/tests/api_resources/test_volumes.py new file mode 100644 index 0000000..fb88eb8 --- /dev/null +++ b/tests/api_resources/test_volumes.py @@ -0,0 +1,485 @@ +# 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 hypeman import Hypeman, AsyncHypeman +from tests.utils import assert_matches_type +from hypeman.types import ( + Volume, + VolumeListResponse, +) + +base_url = os.environ.get("TEST_API_BASE_URL", "http://127.0.0.1:4010") + + +class TestVolumes: + 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: Hypeman) -> None: + volume = client.volumes.create( + name="my-data-volume", + size_gb=10, + ) + assert_matches_type(Volume, volume, path=["response"]) + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + def test_method_create_with_all_params(self, client: Hypeman) -> None: + volume = client.volumes.create( + name="my-data-volume", + size_gb=10, + id="vol-data-1", + tags={ + "team": "backend", + "env": "staging", + }, + ) + assert_matches_type(Volume, volume, path=["response"]) + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + def test_raw_response_create(self, client: Hypeman) -> None: + response = client.volumes.with_raw_response.create( + name="my-data-volume", + size_gb=10, + ) + + assert response.is_closed is True + assert response.http_request.headers.get("X-Stainless-Lang") == "python" + volume = response.parse() + assert_matches_type(Volume, volume, path=["response"]) + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + def test_streaming_response_create(self, client: Hypeman) -> None: + with client.volumes.with_streaming_response.create( + name="my-data-volume", + size_gb=10, + ) as response: + assert not response.is_closed + assert response.http_request.headers.get("X-Stainless-Lang") == "python" + + volume = response.parse() + assert_matches_type(Volume, volume, path=["response"]) + + assert cast(Any, response.is_closed) is True + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + def test_method_list(self, client: Hypeman) -> None: + volume = client.volumes.list() + assert_matches_type(VolumeListResponse, volume, path=["response"]) + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + def test_method_list_with_all_params(self, client: Hypeman) -> None: + volume = client.volumes.list( + tags={ + "team": "backend", + "env": "staging", + }, + ) + assert_matches_type(VolumeListResponse, volume, path=["response"]) + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + def test_raw_response_list(self, client: Hypeman) -> None: + response = client.volumes.with_raw_response.list() + + assert response.is_closed is True + assert response.http_request.headers.get("X-Stainless-Lang") == "python" + volume = response.parse() + assert_matches_type(VolumeListResponse, volume, path=["response"]) + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + def test_streaming_response_list(self, client: Hypeman) -> None: + with client.volumes.with_streaming_response.list() as response: + assert not response.is_closed + assert response.http_request.headers.get("X-Stainless-Lang") == "python" + + volume = response.parse() + assert_matches_type(VolumeListResponse, volume, 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: Hypeman) -> None: + volume = client.volumes.delete( + "id", + ) + assert volume is None + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + def test_raw_response_delete(self, client: Hypeman) -> None: + response = client.volumes.with_raw_response.delete( + "id", + ) + + assert response.is_closed is True + assert response.http_request.headers.get("X-Stainless-Lang") == "python" + volume = response.parse() + assert volume is None + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + def test_streaming_response_delete(self, client: Hypeman) -> None: + with client.volumes.with_streaming_response.delete( + "id", + ) as response: + assert not response.is_closed + assert response.http_request.headers.get("X-Stainless-Lang") == "python" + + volume = response.parse() + assert volume 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: Hypeman) -> None: + with pytest.raises(ValueError, match=r"Expected a non-empty value for `id` but received ''"): + client.volumes.with_raw_response.delete( + "", + ) + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + def test_method_create_from_archive(self, client: Hypeman) -> None: + volume = client.volumes.create_from_archive( + body=b"Example data", + name="name", + size_gb=0, + ) + assert_matches_type(Volume, volume, path=["response"]) + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + def test_method_create_from_archive_with_all_params(self, client: Hypeman) -> None: + volume = client.volumes.create_from_archive( + body=b"Example data", + name="name", + size_gb=0, + id="id", + tags={ + "team": "backend", + "env": "staging", + }, + ) + assert_matches_type(Volume, volume, path=["response"]) + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + def test_raw_response_create_from_archive(self, client: Hypeman) -> None: + response = client.volumes.with_raw_response.create_from_archive( + body=b"Example data", + name="name", + size_gb=0, + ) + + assert response.is_closed is True + assert response.http_request.headers.get("X-Stainless-Lang") == "python" + volume = response.parse() + assert_matches_type(Volume, volume, path=["response"]) + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + def test_streaming_response_create_from_archive(self, client: Hypeman) -> None: + with client.volumes.with_streaming_response.create_from_archive( + body=b"Example data", + name="name", + size_gb=0, + ) as response: + assert not response.is_closed + assert response.http_request.headers.get("X-Stainless-Lang") == "python" + + volume = response.parse() + assert_matches_type(Volume, volume, path=["response"]) + + assert cast(Any, response.is_closed) is True + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + def test_method_get(self, client: Hypeman) -> None: + volume = client.volumes.get( + "id", + ) + assert_matches_type(Volume, volume, path=["response"]) + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + def test_raw_response_get(self, client: Hypeman) -> None: + response = client.volumes.with_raw_response.get( + "id", + ) + + assert response.is_closed is True + assert response.http_request.headers.get("X-Stainless-Lang") == "python" + volume = response.parse() + assert_matches_type(Volume, volume, path=["response"]) + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + def test_streaming_response_get(self, client: Hypeman) -> None: + with client.volumes.with_streaming_response.get( + "id", + ) as response: + assert not response.is_closed + assert response.http_request.headers.get("X-Stainless-Lang") == "python" + + volume = response.parse() + assert_matches_type(Volume, volume, 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: Hypeman) -> None: + with pytest.raises(ValueError, match=r"Expected a non-empty value for `id` but received ''"): + client.volumes.with_raw_response.get( + "", + ) + + +class TestAsyncVolumes: + 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: AsyncHypeman) -> None: + volume = await async_client.volumes.create( + name="my-data-volume", + size_gb=10, + ) + assert_matches_type(Volume, volume, path=["response"]) + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + async def test_method_create_with_all_params(self, async_client: AsyncHypeman) -> None: + volume = await async_client.volumes.create( + name="my-data-volume", + size_gb=10, + id="vol-data-1", + tags={ + "team": "backend", + "env": "staging", + }, + ) + assert_matches_type(Volume, volume, path=["response"]) + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + async def test_raw_response_create(self, async_client: AsyncHypeman) -> None: + response = await async_client.volumes.with_raw_response.create( + name="my-data-volume", + size_gb=10, + ) + + assert response.is_closed is True + assert response.http_request.headers.get("X-Stainless-Lang") == "python" + volume = await response.parse() + assert_matches_type(Volume, volume, path=["response"]) + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + async def test_streaming_response_create(self, async_client: AsyncHypeman) -> None: + async with async_client.volumes.with_streaming_response.create( + name="my-data-volume", + size_gb=10, + ) as response: + assert not response.is_closed + assert response.http_request.headers.get("X-Stainless-Lang") == "python" + + volume = await response.parse() + assert_matches_type(Volume, volume, path=["response"]) + + assert cast(Any, response.is_closed) is True + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + async def test_method_list(self, async_client: AsyncHypeman) -> None: + volume = await async_client.volumes.list() + assert_matches_type(VolumeListResponse, volume, path=["response"]) + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + async def test_method_list_with_all_params(self, async_client: AsyncHypeman) -> None: + volume = await async_client.volumes.list( + tags={ + "team": "backend", + "env": "staging", + }, + ) + assert_matches_type(VolumeListResponse, volume, path=["response"]) + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + async def test_raw_response_list(self, async_client: AsyncHypeman) -> None: + response = await async_client.volumes.with_raw_response.list() + + assert response.is_closed is True + assert response.http_request.headers.get("X-Stainless-Lang") == "python" + volume = await response.parse() + assert_matches_type(VolumeListResponse, volume, path=["response"]) + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + async def test_streaming_response_list(self, async_client: AsyncHypeman) -> None: + async with async_client.volumes.with_streaming_response.list() as response: + assert not response.is_closed + assert response.http_request.headers.get("X-Stainless-Lang") == "python" + + volume = await response.parse() + assert_matches_type(VolumeListResponse, volume, 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: AsyncHypeman) -> None: + volume = await async_client.volumes.delete( + "id", + ) + assert volume is None + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + async def test_raw_response_delete(self, async_client: AsyncHypeman) -> None: + response = await async_client.volumes.with_raw_response.delete( + "id", + ) + + assert response.is_closed is True + assert response.http_request.headers.get("X-Stainless-Lang") == "python" + volume = await response.parse() + assert volume is None + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + async def test_streaming_response_delete(self, async_client: AsyncHypeman) -> None: + async with async_client.volumes.with_streaming_response.delete( + "id", + ) as response: + assert not response.is_closed + assert response.http_request.headers.get("X-Stainless-Lang") == "python" + + volume = await response.parse() + assert volume 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: AsyncHypeman) -> None: + with pytest.raises(ValueError, match=r"Expected a non-empty value for `id` but received ''"): + await async_client.volumes.with_raw_response.delete( + "", + ) + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + async def test_method_create_from_archive(self, async_client: AsyncHypeman) -> None: + volume = await async_client.volumes.create_from_archive( + body=b"Example data", + name="name", + size_gb=0, + ) + assert_matches_type(Volume, volume, path=["response"]) + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + async def test_method_create_from_archive_with_all_params(self, async_client: AsyncHypeman) -> None: + volume = await async_client.volumes.create_from_archive( + body=b"Example data", + name="name", + size_gb=0, + id="id", + tags={ + "team": "backend", + "env": "staging", + }, + ) + assert_matches_type(Volume, volume, path=["response"]) + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + async def test_raw_response_create_from_archive(self, async_client: AsyncHypeman) -> None: + response = await async_client.volumes.with_raw_response.create_from_archive( + body=b"Example data", + name="name", + size_gb=0, + ) + + assert response.is_closed is True + assert response.http_request.headers.get("X-Stainless-Lang") == "python" + volume = await response.parse() + assert_matches_type(Volume, volume, path=["response"]) + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + async def test_streaming_response_create_from_archive(self, async_client: AsyncHypeman) -> None: + async with async_client.volumes.with_streaming_response.create_from_archive( + body=b"Example data", + name="name", + size_gb=0, + ) as response: + assert not response.is_closed + assert response.http_request.headers.get("X-Stainless-Lang") == "python" + + volume = await response.parse() + assert_matches_type(Volume, volume, path=["response"]) + + assert cast(Any, response.is_closed) is True + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + async def test_method_get(self, async_client: AsyncHypeman) -> None: + volume = await async_client.volumes.get( + "id", + ) + assert_matches_type(Volume, volume, path=["response"]) + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + async def test_raw_response_get(self, async_client: AsyncHypeman) -> None: + response = await async_client.volumes.with_raw_response.get( + "id", + ) + + assert response.is_closed is True + assert response.http_request.headers.get("X-Stainless-Lang") == "python" + volume = await response.parse() + assert_matches_type(Volume, volume, path=["response"]) + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + async def test_streaming_response_get(self, async_client: AsyncHypeman) -> None: + async with async_client.volumes.with_streaming_response.get( + "id", + ) as response: + assert not response.is_closed + assert response.http_request.headers.get("X-Stainless-Lang") == "python" + + volume = await response.parse() + assert_matches_type(Volume, volume, 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: AsyncHypeman) -> None: + with pytest.raises(ValueError, match=r"Expected a non-empty value for `id` but received ''"): + await async_client.volumes.with_raw_response.get( + "", + ) diff --git a/tests/conftest.py b/tests/conftest.py new file mode 100644 index 0000000..15ec706 --- /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 hypeman import Hypeman, AsyncHypeman, DefaultAioHttpClient +from hypeman._utils import is_dict + +if TYPE_CHECKING: + from _pytest.fixtures import FixtureRequest # pyright: ignore[reportPrivateImportUsage] + +pytest.register_assert_rewrite("tests.utils") + +logging.getLogger("hypeman").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[Hypeman]: + strict = getattr(request, "param", True) + if not isinstance(strict, bool): + raise TypeError(f"Unexpected fixture parameter type {type(strict)}, expected {bool}") + + with Hypeman(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[AsyncHypeman]: + 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 AsyncHypeman( + base_url=base_url, api_key=api_key, _strict_response_validation=strict, http_client=http_client + ) as client: + yield client diff --git a/tests/lib/test_websocket_lib.py b/tests/lib/test_websocket_lib.py new file mode 100644 index 0000000..72b4f9f --- /dev/null +++ b/tests/lib/test_websocket_lib.py @@ -0,0 +1,557 @@ +from __future__ import annotations + +import os +import json +from types import TracebackType +from pathlib import Path +from collections import deque +from dataclasses import field, dataclass +from collections.abc import AsyncIterator + +import pytest +from websockets.exceptions import PayloadTooBig + +from hypeman.lib import ( + CopyCallbacks, + CopyProtocolError, + ExecProtocolError, + exec, + exec_async, + cp_to_instance, + cp_from_instance, + cp_to_instance_async, + cp_from_instance_async, +) +from hypeman.lib.cp import _request, _UploadEntry + + +@dataclass +class FakeClient: + base_url: str = "https://example.test/api/" + api_key: str = "secret" + + +@dataclass +class FakeWebSocket: + frames: deque[bytes | str | BaseException] + sent: list[bytes | str] = field(default_factory=lambda: []) + closed: bool = False + + def send(self, message: bytes | str) -> None: + self.sent.append(message) + + def recv(self) -> bytes | str: + frame = self.frames.popleft() + if isinstance(frame, BaseException): + raise frame + return frame + + def close(self) -> None: + self.closed = True + + def __enter__(self) -> FakeWebSocket: + return self + + def __exit__( + self, + exc_type: type[BaseException] | None, + exc_value: BaseException | None, + traceback: TracebackType | None, + ) -> None: + self.close() + + +@dataclass +class FakeConnector: + connections: deque[FakeWebSocket] + calls: list[tuple[str, dict[str, str], int]] = field(default_factory=lambda: []) + + def __call__(self, url: str, *, additional_headers: dict[str, str], max_size: int) -> FakeWebSocket: + self.calls.append((url, additional_headers, max_size)) + return self.connections.popleft() + + +@dataclass +class FakeAsyncWebSocket: + frames: deque[bytes | str | BaseException] + sent: list[bytes | str] = field(default_factory=lambda: []) + closed: bool = False + + async def send(self, message: bytes | str) -> None: + self.sent.append(message) + + async def recv(self) -> bytes | str: + frame = self.frames.popleft() + if isinstance(frame, BaseException): + raise frame + return frame + + async def close(self) -> None: + self.closed = True + + async def __aenter__(self) -> FakeAsyncWebSocket: + return self + + async def __aexit__( + self, + exc_type: type[BaseException] | None, + exc_value: BaseException | None, + traceback: TracebackType | None, + ) -> None: + await self.close() + + +@dataclass +class FakeAsyncConnector: + connections: deque[FakeAsyncWebSocket] + calls: list[tuple[str, dict[str, str], int]] = field(default_factory=lambda: []) + + def __call__(self, url: str, *, additional_headers: dict[str, str], max_size: int) -> FakeAsyncWebSocket: + self.calls.append((url, additional_headers, max_size)) + return self.connections.popleft() + + +def text_frame(payload: dict[str, object]) -> str: + return json.dumps(payload, separators=(",", ":")) + + +def upload_result(size: int = 0) -> str: + return text_frame({"type": "result", "success": True, "bytes_written": size}) + + +def file_header(path: str, *, size: int, mode: int = 0o640, mtime: int = 0) -> str: + return text_frame( + { + "type": "header", + "path": path, + "mode": mode, + "is_dir": False, + "is_symlink": False, + "link_target": "", + "size": size, + "mtime": mtime, + } + ) + + +def test_exec_uses_client_auth_url_and_all_request_dimensions() -> None: + websocket = FakeWebSocket(deque([b"out", b"err", '{"exitCode":0}'])) + connector = FakeConnector(deque([websocket])) + + result = exec( + FakeClient(), + "inst_123", + ["sh", "-lc", "echo hi"], + cwd="/app", + env={"A": "b"}, + timeout=30, + wait_for_agent=5, + tty=True, + rows=24, + cols=80, + stdin=[b"first", b"second"], + resize=[(40, 120)], + connector=connector, + ) + + assert result.output == b"outerr" + assert result.exit_code == 0 + assert connector.calls == [ + ("wss://example.test/api/instances/inst_123/exec", {"Authorization": "Bearer secret"}, 2**20) + ] + assert json.loads(str(websocket.sent[0])) == { + "command": ["sh", "-lc", "echo hi"], + "tty": True, + "env": {"A": "b"}, + "cwd": "/app", + "timeout": 30, + "wait_for_agent": 5, + "rows": 24, + "cols": 80, + } + assert websocket.sent[1:3] == [b"first", b"second"] + assert json.loads(str(websocket.sent[3])) == {"resize": {"rows": 40, "cols": 120}} + assert websocket.closed + + +def test_exec_returns_nonzero_exit_without_losing_output() -> None: + connector = FakeConnector(deque([FakeWebSocket(deque([b"failure\n", '{"exitCode":23}']))])) + result = exec(FakeClient(base_url="http://localhost:4973"), "inst", ["false"], connector=connector) + assert result.exit_code == 23 + assert result.output == b"failure\n" + assert connector.calls[0][0] == "ws://localhost:4973/instances/inst/exec" + + +@pytest.mark.parametrize("frame", ["not-json", '{"error":"failed"}', '{"exitCode":"0"}', "[]"]) +def test_exec_rejects_malformed_control_frames(frame: str) -> None: + connector = FakeConnector(deque([FakeWebSocket(deque([frame]))])) + with pytest.raises(ExecProtocolError): + exec(FakeClient(), "inst", ["true"], connector=connector) + + +def test_exec_never_retries_after_dispatch() -> None: + websocket = FakeWebSocket(deque([EOFError("closed")])) + connector = FakeConnector(deque([websocket, FakeWebSocket(deque(['{"exitCode":0}']))])) + with pytest.raises(ExecProtocolError, match="before an exitCode"): + exec(FakeClient(), "inst", ["do-once"], connector=connector) + assert len(connector.calls) == 1 + assert len(websocket.sent) == 1 + + +@pytest.mark.parametrize(("tty", "resize"), [(False, [(24, 80)]), (True, [(0, 80)]), (True, [(24, -1)])]) +def test_exec_rejects_invalid_resize_before_connect(tty: bool, resize: list[tuple[int, int]]) -> None: + connector = FakeConnector(deque()) + with pytest.raises(ValueError, match="resize"): + exec(FakeClient(), "inst", ["true"], tty=tty, resize=resize, connector=connector) + assert not connector.calls + + +def test_exec_rejects_oversized_inbound_message() -> None: + oversized = PayloadTooBig(2**20 + 1, 2**20) + connector = FakeConnector(deque([FakeWebSocket(deque([oversized]))])) + with pytest.raises(ExecProtocolError, match="before an exitCode") as exc_info: + exec(FakeClient(), "inst", ["true"], connector=connector) + assert isinstance(exc_info.value.__cause__, PayloadTooBig) + assert connector.calls[0][2] == 2**20 + + +@pytest.mark.asyncio +async def test_exec_async_rejects_invalid_resize_before_connect() -> None: + connector = FakeAsyncConnector(deque()) + with pytest.raises(ValueError, match="resize"): + await exec_async(FakeClient(), "inst", ["true"], tty=False, resize=[(24, 80)], connector=connector) + assert not connector.calls + + +@pytest.mark.asyncio +async def test_exec_async_supports_streaming_stdin() -> None: + websocket = FakeAsyncWebSocket(deque([b"done", '{"exitCode":7}'])) + connector = FakeAsyncConnector(deque([websocket])) + + async def chunks() -> AsyncIterator[bytes]: + yield b"one" + yield b"two" + + result = await exec_async(FakeClient(), "inst", ["cat"], stdin=chunks(), connector=connector) + assert result.output == b"done" + assert result.exit_code == 7 + assert websocket.sent[1:] == [b"one", b"two"] + + +def test_cp_upload_file_preserves_mode_and_reports_progress(tmp_path: Path) -> None: + source = tmp_path / "source.txt" + source.write_bytes(b"payload") + source.chmod(0o640) + events: list[tuple[str, object]] = [] + callbacks = CopyCallbacks( + on_file_start=lambda path, size: events.append(("start", (path, size))), + on_progress=lambda copied: events.append(("progress", copied)), + on_file_end=lambda path: events.append(("end", path)), + ) + websocket = FakeWebSocket(deque([upload_result(7)])) + connector = FakeConnector(deque([websocket])) + + cp_to_instance( + FakeClient(), + "inst", + source, + "/app/source.txt", + archive=True, + callbacks=callbacks, + connector=connector, + ) + + request = json.loads(str(websocket.sent[0])) + expected_request = { + "direction": "to", + "guest_path": "/app/source.txt", + "is_dir": False, + "mode": 0o640, + } + source_stat = source.stat() + expected_request["uid"] = source_stat.st_uid + expected_request["gid"] = source_stat.st_gid + assert request == expected_request + assert websocket.sent[1:] == [b"payload", '{"type":"end"}'] + assert connector.calls[0][2] == 2**20 + assert events == [ + ("start", (str(source), 7)), + ("progress", 7), + ("end", str(source)), + ] + + +def test_cp_upload_archive_includes_root_ownership(tmp_path: Path) -> None: + entry = _UploadEntry(tmp_path, "/guest/file", False, 0o644, 0, 0, 0) + request = json.loads(_request(entry)) + + assert request["uid"] == 0 + assert request["gid"] == 0 + + +def test_cp_upload_directory_uses_one_connection_per_entry(tmp_path: Path) -> None: + source = tmp_path / "tree" + source.mkdir() + (source / "child.txt").write_bytes(b"x") + first = FakeWebSocket(deque([upload_result()])) + second = FakeWebSocket(deque([upload_result(1)])) + connector = FakeConnector(deque([first, second])) + + cp_to_instance(FakeClient(), "inst", source, "/guest/tree", connector=connector) + + assert json.loads(str(first.sent[0]))["is_dir"] is True + assert json.loads(str(second.sent[0]))["guest_path"] == "/guest/tree/child.txt" + assert second.sent[1] == b"x" + assert len(connector.calls) == 2 + + +def test_cp_upload_file_symlink_follows_contents(tmp_path: Path) -> None: + target = tmp_path / "target" + target.write_bytes(b"contents") + link = tmp_path / "link" + link.symlink_to(target.name) + websocket = FakeWebSocket(deque([upload_result(8)])) + + cp_to_instance(FakeClient(), "inst", link, "/guest/link", connector=FakeConnector(deque([websocket]))) + + assert websocket.sent[1] == b"contents" + + +def test_cp_upload_rejects_partial_server_result(tmp_path: Path) -> None: + source = tmp_path / "source" + source.write_bytes(b"complete") + connector = FakeConnector(deque([FakeWebSocket(deque([upload_result(3)]))])) + with pytest.raises(CopyProtocolError, match="expected 8"): + cp_to_instance(FakeClient(), "inst", source, "/guest/file", connector=connector) + + +def test_cp_download_file_is_atomic_and_preserves_metadata(tmp_path: Path) -> None: + destination = tmp_path / "download" + events: list[tuple[str, object]] = [] + callbacks = CopyCallbacks( + on_file_start=lambda path, size: events.append(("start", (path, size))), + on_progress=lambda copied: events.append(("progress", copied)), + on_file_end=lambda path: events.append(("end", path)), + ) + websocket = FakeWebSocket( + deque( + [ + file_header("sub/output.txt", size=5, mode=0o600, mtime=1_700_000_000), + b"hello", + text_frame({"type": "end", "final": True}), + ] + ) + ) + + cp_from_instance( + FakeClient(), + "inst", + "/guest/output.txt", + destination, + callbacks=callbacks, + connector=FakeConnector(deque([websocket])), + ) + + output = destination / "sub" / "output.txt" + assert output.read_bytes() == b"hello" + assert stat_mode(output) == 0o600 + assert int(output.stat().st_mtime) == 1_700_000_000 + assert events == [("start", ("sub/output.txt", 5)), ("progress", 5), ("end", "sub/output.txt")] + assert json.loads(str(websocket.sent[0])) == { + "direction": "from", + "guest_path": "/guest/output.txt", + "follow_links": False, + } + + +def test_cp_download_defers_restrictive_directory_mode(tmp_path: Path) -> None: + directory = text_frame( + { + "type": "header", + "path": "tree", + "mode": 0o500, + "is_dir": True, + "is_symlink": False, + "link_target": "", + "size": 0, + "mtime": 0, + } + ) + websocket = FakeWebSocket( + deque( + [ + directory, + text_frame({"type": "end", "final": False}), + file_header("tree/child.txt", size=1), + b"x", + text_frame({"type": "end", "final": True}), + ] + ) + ) + destination = tmp_path / "download" + + cp_from_instance( + FakeClient(), + "inst", + "/guest/tree", + destination, + connector=FakeConnector(deque([websocket])), + ) + + assert (destination / "tree" / "child.txt").read_bytes() == b"x" + assert stat_mode(destination / "tree") == 0o500 + + +def stat_mode(path: Path) -> int: + return path.stat().st_mode & 0o7777 + + +@pytest.mark.parametrize("server_path", ["../escape", "/absolute", "sub/../../escape", "sub\\escape"]) +def test_cp_download_rejects_traversal(tmp_path: Path, server_path: str) -> None: + outside = tmp_path / "escape" + connector = FakeConnector(deque([FakeWebSocket(deque([file_header(server_path, size=0)]))])) + with pytest.raises(CopyProtocolError): + cp_from_instance(FakeClient(), "inst", "/guest", tmp_path / "dest", connector=connector) + assert not outside.exists() + + +def test_cp_download_rejects_existing_symlink_parent(tmp_path: Path) -> None: + destination = tmp_path / "dest" + destination.mkdir() + outside = tmp_path / "outside" + outside.mkdir() + (destination / "linked").symlink_to(outside, target_is_directory=True) + connector = FakeConnector(deque([FakeWebSocket(deque([file_header("linked/file", size=1), b"x"]))])) + + with pytest.raises(CopyProtocolError, match="local symlink"): + cp_from_instance(FakeClient(), "inst", "/guest", destination, connector=connector) + assert not (outside / "file").exists() + + +def test_cp_download_creates_safe_relative_symlink(tmp_path: Path) -> None: + frame = text_frame( + { + "type": "header", + "path": "links/current", + "mode": 0o777, + "is_dir": False, + "is_symlink": True, + "link_target": "../target", + "size": 0, + "mtime": 0, + } + ) + connector = FakeConnector(deque([FakeWebSocket(deque([frame, text_frame({"type": "end", "final": True})]))])) + destination = tmp_path / "dest" + + cp_from_instance(FakeClient(), "inst", "/guest", destination, connector=connector) + + link = destination / "links" / "current" + assert link.is_symlink() + assert os.readlink(link) == "../target" + + +def test_cp_download_rejects_escaping_symlink(tmp_path: Path) -> None: + frame = text_frame( + { + "type": "header", + "path": "link", + "mode": 0o777, + "is_dir": False, + "is_symlink": True, + "link_target": "../../outside", + "size": 0, + "mtime": 0, + } + ) + connector = FakeConnector(deque([FakeWebSocket(deque([frame]))])) + with pytest.raises(CopyProtocolError, match="escapes destination"): + cp_from_instance(FakeClient(), "inst", "/guest", tmp_path / "dest", connector=connector) + + +def test_cp_download_removes_partial_file(tmp_path: Path) -> None: + destination = tmp_path / "dest" + connector = FakeConnector( + deque([FakeWebSocket(deque([file_header("partial", size=5), b"ab", EOFError("closed")]))]) + ) + with pytest.raises(CopyProtocolError, match="final marker"): + cp_from_instance(FakeClient(), "inst", "/guest", destination, connector=connector) + assert not (destination / "partial").exists() + assert not list(destination.glob(".hypeman-cp-*")) + + +def test_cp_download_surfaces_server_error(tmp_path: Path) -> None: + error = text_frame({"type": "error", "message": "permission denied", "path": "/root/file"}) + connector = FakeConnector(deque([FakeWebSocket(deque([error]))])) + with pytest.raises(CopyProtocolError, match="permission denied"): + cp_from_instance(FakeClient(), "inst", "/root/file", tmp_path, connector=connector) + + +@pytest.mark.asyncio +async def test_cp_async_upload_and_download(tmp_path: Path) -> None: + source = tmp_path / "source" + source.write_bytes(b"async") + upload_socket = FakeAsyncWebSocket(deque([upload_result(5)])) + upload_connector = FakeAsyncConnector(deque([upload_socket])) + await cp_to_instance_async( + FakeClient(), + "inst", + source, + "/guest/source", + connector=upload_connector, + ) + assert upload_socket.sent[1] == b"async" + assert upload_connector.calls[0][2] == 2**20 + + download_socket = FakeAsyncWebSocket( + deque([file_header("result", size=5), b"async", text_frame({"type": "end", "final": True})]) + ) + destination = tmp_path / "dest" + download_connector = FakeAsyncConnector(deque([download_socket])) + await cp_from_instance_async( + FakeClient(), + "inst", + "/guest/result", + destination, + connector=download_connector, + ) + assert (destination / "result").read_bytes() == b"async" + assert download_connector.calls[0][2] == 2**20 + + +def test_invalid_instance_id_is_rejected_before_connect() -> None: + connector = FakeConnector(deque()) + with pytest.raises(ValueError, match="instance_id"): + exec(FakeClient(), "../instance", ["true"], connector=connector) + assert not connector.calls + + +def test_cp_upload_surfaces_error_and_close_frames(tmp_path: Path) -> None: + source = tmp_path / "source" + source.write_bytes(b"x") + error_connector = FakeConnector( + deque([FakeWebSocket(deque([text_frame({"type": "error", "message": "disk full"})]))]) + ) + with pytest.raises(CopyProtocolError, match="disk full"): + cp_to_instance(FakeClient(), "inst", source, "/guest/file", connector=error_connector) + + close_connector = FakeConnector(deque([FakeWebSocket(deque([EOFError("closed")]))])) + with pytest.raises(CopyProtocolError, match="before the result"): + cp_to_instance(FakeClient(), "inst", source, "/guest/file", connector=close_connector) + + +def test_cp_download_rejects_malformed_control_frame(tmp_path: Path) -> None: + connector = FakeConnector(deque([FakeWebSocket(deque(["not-json"]))])) + with pytest.raises(CopyProtocolError, match="malformed JSON"): + cp_from_instance(FakeClient(), "inst", "/guest", tmp_path, connector=connector) + + +def test_exec_rejects_a_bare_command_string() -> None: + with pytest.raises(ValueError, match="argument sequence"): + exec(FakeClient(), "inst", "echo") + + +def test_cp_download_rejects_invalid_mode(tmp_path: Path) -> None: + connector = FakeConnector(deque([FakeWebSocket(deque([file_header("file", size=0, mode=0o4777)]))])) + with pytest.raises(CopyProtocolError, match="mode"): + cp_from_instance(FakeClient(), "inst", "/guest/file", tmp_path, connector=connector) diff --git a/tests/sample_file.txt b/tests/sample_file.txt new file mode 100644 index 0000000..af5626b --- /dev/null +++ b/tests/sample_file.txt @@ -0,0 +1 @@ +Hello, world! diff --git a/tests/test_client.py b/tests/test_client.py new file mode 100644 index 0000000..bafe267 --- /dev/null +++ b/tests/test_client.py @@ -0,0 +1,1960 @@ +# 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 hypeman import Hypeman, AsyncHypeman, APIResponseValidationError +from hypeman._types import Omit +from hypeman._utils import asyncify +from hypeman._models import BaseModel, FinalRequestOptions +from hypeman._exceptions import HypemanError, APIStatusError, APITimeoutError, APIResponseValidationError +from hypeman._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: Hypeman | AsyncHypeman) -> int: + transport = client._client._transport + assert isinstance(transport, httpx.HTTPTransport) or isinstance(transport, httpx.AsyncHTTPTransport) + + pool = transport._pool + return len(pool._requests) + + +class TestHypeman: + @pytest.mark.respx(base_url=base_url) + def test_raw_response(self, respx_mock: MockRouter, client: Hypeman) -> 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: Hypeman) -> 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: Hypeman) -> 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: Hypeman) -> 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 = Hypeman( + 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 = Hypeman( + 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: Hypeman) -> 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: Hypeman) -> 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. + "hypeman/_legacy_response.py", + "hypeman/_response.py", + # pydantic.BaseModel.model_dump || pydantic.BaseModel.dict leak memory for some reason. + "hypeman/_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: Hypeman) -> 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 = Hypeman(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 = Hypeman( + 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 = Hypeman( + 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 = Hypeman( + 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: + Hypeman( + 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 = Hypeman( + 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 = Hypeman( + 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 = Hypeman(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(HypemanError): + with update_env(**{"HYPEMAN_API_KEY": Omit()}): + client2 = Hypeman(base_url=base_url, api_key=None, _strict_response_validation=True) + _ = client2 + + def test_default_query_option(self) -> None: + client = Hypeman( + 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: Hypeman) -> 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: Hypeman) -> 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: Hypeman) -> 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: Hypeman) -> 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: Hypeman) -> 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: Hypeman) -> 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 Hypeman( + 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: Hypeman) -> 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: Hypeman) -> 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: Hypeman) -> 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: Hypeman) -> 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 = Hypeman(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(HYPEMAN_BASE_URL="http://localhost:5000/from/env"): + client = Hypeman(api_key=api_key, _strict_response_validation=True) + assert client.base_url == "http://localhost:5000/from/env/" + + @pytest.mark.parametrize( + "client", + [ + Hypeman(base_url="http://localhost:5000/custom/path/", api_key=api_key, _strict_response_validation=True), + Hypeman( + 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: Hypeman) -> 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", + [ + Hypeman(base_url="http://localhost:5000/custom/path/", api_key=api_key, _strict_response_validation=True), + Hypeman( + 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: Hypeman) -> 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", + [ + Hypeman(base_url="http://localhost:5000/custom/path/", api_key=api_key, _strict_response_validation=True), + Hypeman( + 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: Hypeman) -> 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 = Hypeman(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 = Hypeman(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: Hypeman) -> 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"): + Hypeman(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 = Hypeman(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 = Hypeman(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: Hypeman + ) -> 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("hypeman._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: Hypeman) -> None: + respx_mock.get("/health").mock(side_effect=httpx.TimeoutException("Test timeout error")) + + with pytest.raises(APITimeoutError): + client.health.with_streaming_response.check().__enter__() + + assert _get_open_connections(client) == 0 + + @mock.patch("hypeman._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: Hypeman) -> None: + respx_mock.get("/health").mock(return_value=httpx.Response(500)) + + with pytest.raises(APIStatusError): + client.health.with_streaming_response.check().__enter__() + assert _get_open_connections(client) == 0 + + @pytest.mark.parametrize("failures_before_success", [0, 2, 4]) + @mock.patch("hypeman._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: Hypeman, + failures_before_success: int, + failure_mode: Literal["status", "exception"], + respx_mock: MockRouter, + ) -> None: + client = client.with_options(max_retries=4) + + nb_retries = 0 + + def retry_handler(_request: httpx.Request) -> httpx.Response: + nonlocal nb_retries + if nb_retries < failures_before_success: + nb_retries += 1 + if failure_mode == "exception": + raise RuntimeError("oops") + return httpx.Response(500) + return httpx.Response(200) + + respx_mock.get("/health").mock(side_effect=retry_handler) + + response = client.health.with_raw_response.check() + + 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("hypeman._base_client.BaseClient._calculate_retry_timeout", _low_retry_timeout) + @pytest.mark.respx(base_url=base_url) + def test_omit_retry_count_header( + self, client: Hypeman, failures_before_success: int, respx_mock: MockRouter + ) -> None: + client = client.with_options(max_retries=4) + + nb_retries = 0 + + def retry_handler(_request: httpx.Request) -> httpx.Response: + nonlocal nb_retries + if nb_retries < failures_before_success: + nb_retries += 1 + return httpx.Response(500) + return httpx.Response(200) + + respx_mock.get("/health").mock(side_effect=retry_handler) + + response = client.health.with_raw_response.check(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("hypeman._base_client.BaseClient._calculate_retry_timeout", _low_retry_timeout) + @pytest.mark.respx(base_url=base_url) + def test_overwrite_retry_count_header( + self, client: Hypeman, failures_before_success: int, respx_mock: MockRouter + ) -> None: + client = client.with_options(max_retries=4) + + nb_retries = 0 + + def retry_handler(_request: httpx.Request) -> httpx.Response: + nonlocal nb_retries + if nb_retries < failures_before_success: + nb_retries += 1 + return httpx.Response(500) + return httpx.Response(200) + + respx_mock.get("/health").mock(side_effect=retry_handler) + + response = client.health.with_raw_response.check(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: Hypeman) -> 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: Hypeman) -> 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 TestAsyncHypeman: + @pytest.mark.respx(base_url=base_url) + async def test_raw_response(self, respx_mock: MockRouter, async_client: AsyncHypeman) -> 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: AsyncHypeman) -> 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: AsyncHypeman) -> 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: AsyncHypeman) -> 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 = AsyncHypeman( + 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 = AsyncHypeman( + 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: AsyncHypeman) -> 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: AsyncHypeman) -> 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. + "hypeman/_legacy_response.py", + "hypeman/_response.py", + # pydantic.BaseModel.model_dump || pydantic.BaseModel.dict leak memory for some reason. + "hypeman/_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: AsyncHypeman) -> 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 = AsyncHypeman( + 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 = AsyncHypeman( + 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 = AsyncHypeman( + 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 = AsyncHypeman( + 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: + AsyncHypeman( + 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 = AsyncHypeman( + 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 = AsyncHypeman( + 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 = AsyncHypeman(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(HypemanError): + with update_env(**{"HYPEMAN_API_KEY": Omit()}): + client2 = AsyncHypeman(base_url=base_url, api_key=None, _strict_response_validation=True) + _ = client2 + + async def test_default_query_option(self) -> None: + client = AsyncHypeman( + 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: AsyncHypeman) -> 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: Hypeman) -> 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: Hypeman) -> 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: Hypeman) -> 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: AsyncHypeman) -> 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: AsyncHypeman) -> 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 AsyncHypeman( + 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: AsyncHypeman + ) -> 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: AsyncHypeman) -> 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: AsyncHypeman) -> 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: AsyncHypeman + ) -> 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 = AsyncHypeman( + 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(HYPEMAN_BASE_URL="http://localhost:5000/from/env"): + client = AsyncHypeman(api_key=api_key, _strict_response_validation=True) + assert client.base_url == "http://localhost:5000/from/env/" + + @pytest.mark.parametrize( + "client", + [ + AsyncHypeman( + base_url="http://localhost:5000/custom/path/", api_key=api_key, _strict_response_validation=True + ), + AsyncHypeman( + 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: AsyncHypeman) -> 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", + [ + AsyncHypeman( + base_url="http://localhost:5000/custom/path/", api_key=api_key, _strict_response_validation=True + ), + AsyncHypeman( + 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: AsyncHypeman) -> 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", + [ + AsyncHypeman( + base_url="http://localhost:5000/custom/path/", api_key=api_key, _strict_response_validation=True + ), + AsyncHypeman( + 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: AsyncHypeman) -> 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 = AsyncHypeman(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 = AsyncHypeman(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: AsyncHypeman) -> 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"): + AsyncHypeman( + 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 = AsyncHypeman(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 = AsyncHypeman(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: AsyncHypeman + ) -> 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("hypeman._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: AsyncHypeman + ) -> None: + respx_mock.get("/health").mock(side_effect=httpx.TimeoutException("Test timeout error")) + + with pytest.raises(APITimeoutError): + await async_client.health.with_streaming_response.check().__aenter__() + + assert _get_open_connections(async_client) == 0 + + @mock.patch("hypeman._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: AsyncHypeman) -> None: + respx_mock.get("/health").mock(return_value=httpx.Response(500)) + + with pytest.raises(APIStatusError): + await async_client.health.with_streaming_response.check().__aenter__() + assert _get_open_connections(async_client) == 0 + + @pytest.mark.parametrize("failures_before_success", [0, 2, 4]) + @mock.patch("hypeman._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: AsyncHypeman, + failures_before_success: int, + failure_mode: Literal["status", "exception"], + respx_mock: MockRouter, + ) -> None: + client = async_client.with_options(max_retries=4) + + nb_retries = 0 + + def retry_handler(_request: httpx.Request) -> httpx.Response: + nonlocal nb_retries + if nb_retries < failures_before_success: + nb_retries += 1 + if failure_mode == "exception": + raise RuntimeError("oops") + return httpx.Response(500) + return httpx.Response(200) + + respx_mock.get("/health").mock(side_effect=retry_handler) + + response = await client.health.with_raw_response.check() + + 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("hypeman._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: AsyncHypeman, failures_before_success: int, respx_mock: MockRouter + ) -> None: + client = async_client.with_options(max_retries=4) + + nb_retries = 0 + + def retry_handler(_request: httpx.Request) -> httpx.Response: + nonlocal nb_retries + if nb_retries < failures_before_success: + nb_retries += 1 + return httpx.Response(500) + return httpx.Response(200) + + respx_mock.get("/health").mock(side_effect=retry_handler) + + response = await client.health.with_raw_response.check(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("hypeman._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: AsyncHypeman, failures_before_success: int, respx_mock: MockRouter + ) -> None: + client = async_client.with_options(max_retries=4) + + nb_retries = 0 + + def retry_handler(_request: httpx.Request) -> httpx.Response: + nonlocal nb_retries + if nb_retries < failures_before_success: + nb_retries += 1 + return httpx.Response(500) + return httpx.Response(200) + + respx_mock.get("/health").mock(side_effect=retry_handler) + + response = await client.health.with_raw_response.check(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: AsyncHypeman) -> 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: AsyncHypeman) -> 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 0000000..835b682 --- /dev/null +++ b/tests/test_extract_files.py @@ -0,0 +1,91 @@ +from __future__ import annotations + +from typing import Sequence + +import pytest + +from hypeman._types import FileTypes, ArrayFormat +from hypeman._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 0000000..833d718 --- /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 hypeman._files import to_httpx_files, deepcopy_with_paths, async_to_httpx_files +from hypeman._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 0000000..19ca97a --- /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 hypeman._utils import PropertyInfo +from hypeman._compat import PYDANTIC_V1, parse_obj, model_dump, model_json +from hypeman._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 0000000..b4f591f --- /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 hypeman._qs import Querystring, stringify + + +def test_empty() -> None: + assert stringify({}) == "" + assert stringify({"a": {}}) == "" + assert stringify({"a": {"b": {"c": {}}}}) == "" + + +def test_basic() -> None: + assert stringify({"a": 1}) == "a=1" + assert stringify({"a": "b"}) == "a=b" + assert stringify({"a": True}) == "a=true" + assert stringify({"a": False}) == "a=false" + assert stringify({"a": 1.23456}) == "a=1.23456" + assert stringify({"a": None}) == "" + + +@pytest.mark.parametrize("method", ["class", "function"]) +def test_nested_dotted(method: str) -> None: + if method == "class": + serialise = Querystring(nested_format="dots").stringify + else: + serialise = partial(stringify, nested_format="dots") + + assert unquote(serialise({"a": {"b": "c"}})) == "a.b=c" + assert unquote(serialise({"a": {"b": "c", "d": "e", "f": "g"}})) == "a.b=c&a.d=e&a.f=g" + assert unquote(serialise({"a": {"b": {"c": {"d": "e"}}}})) == "a.b.c.d=e" + assert unquote(serialise({"a": {"b": True}})) == "a.b=true" + + +def test_nested_brackets() -> None: + assert unquote(stringify({"a": {"b": "c"}})) == "a[b]=c" + assert unquote(stringify({"a": {"b": "c", "d": "e", "f": "g"}})) == "a[b]=c&a[d]=e&a[f]=g" + assert unquote(stringify({"a": {"b": {"c": {"d": "e"}}}})) == "a[b][c][d]=e" + assert unquote(stringify({"a": {"b": True}})) == "a[b]=true" + + +@pytest.mark.parametrize("method", ["class", "function"]) +def test_array_comma(method: str) -> None: + if method == "class": + serialise = Querystring(array_format="comma").stringify + else: + serialise = partial(stringify, array_format="comma") + + assert unquote(serialise({"in": ["foo", "bar"]})) == "in=foo,bar" + assert unquote(serialise({"a": {"b": [True, False]}})) == "a[b]=true,false" + assert unquote(serialise({"a": {"b": [True, False, None, True]}})) == "a[b]=true,false,true" + + +def test_array_repeat() -> None: + assert unquote(stringify({"in": ["foo", "bar"]})) == "in=foo&in=bar" + assert unquote(stringify({"a": {"b": [True, False]}})) == "a[b]=true&a[b]=false" + assert unquote(stringify({"a": {"b": [True, False, None, True]}})) == "a[b]=true&a[b]=false&a[b]=true" + assert unquote(stringify({"in": ["foo", {"b": {"c": ["d", "e"]}}]})) == "in=foo&in[b][c]=d&in[b][c]=e" + + +@pytest.mark.parametrize("method", ["class", "function"]) +def test_array_brackets(method: str) -> None: + if method == "class": + serialise = Querystring(array_format="brackets").stringify + else: + serialise = partial(stringify, array_format="brackets") + + assert unquote(serialise({"in": ["foo", "bar"]})) == "in[]=foo&in[]=bar" + assert unquote(serialise({"a": {"b": [True, False]}})) == "a[b][]=true&a[b][]=false" + assert unquote(serialise({"a": {"b": [True, False, None, True]}})) == "a[b][]=true&a[b][]=false&a[b][]=true" + + +def test_unknown_array_format() -> None: + with pytest.raises(NotImplementedError, match="Unknown array_format value: foo, choose from comma, repeat"): + stringify({"a": ["foo", "bar"]}, array_format=cast(Any, "foo")) diff --git a/tests/test_required_args.py b/tests/test_required_args.py new file mode 100644 index 0000000..e0002a6 --- /dev/null +++ b/tests/test_required_args.py @@ -0,0 +1,111 @@ +from __future__ import annotations + +import pytest + +from hypeman._utils import required_args + + +def test_too_many_positional_params() -> None: + @required_args(["a"]) + def foo(a: str | None = None) -> str | None: + return a + + with pytest.raises(TypeError, match=r"foo\(\) takes 1 argument\(s\) but 2 were given"): + foo("a", "b") # type: ignore + + +def test_positional_param() -> None: + @required_args(["a"]) + def foo(a: str | None = None) -> str | None: + return a + + assert foo("a") == "a" + assert foo(None) is None + assert foo(a="b") == "b" + + with pytest.raises(TypeError, match="Missing required argument: 'a'"): + foo() + + +def test_keyword_only_param() -> None: + @required_args(["a"]) + def foo(*, a: str | None = None) -> str | None: + return a + + assert foo(a="a") == "a" + assert foo(a=None) is None + assert foo(a="b") == "b" + + with pytest.raises(TypeError, match="Missing required argument: 'a'"): + foo() + + +def test_multiple_params() -> None: + @required_args(["a", "b", "c"]) + def foo(a: str = "", *, b: str = "", c: str = "") -> str | None: + return f"{a} {b} {c}" + + assert foo(a="a", b="b", c="c") == "a b c" + + error_message = r"Missing required arguments.*" + + with pytest.raises(TypeError, match=error_message): + foo() + + with pytest.raises(TypeError, match=error_message): + foo(a="a") + + with pytest.raises(TypeError, match=error_message): + foo(b="b") + + with pytest.raises(TypeError, match=error_message): + foo(c="c") + + with pytest.raises(TypeError, match=r"Missing required argument: 'a'"): + foo(b="a", c="c") + + with pytest.raises(TypeError, match=r"Missing required argument: 'b'"): + foo("a", c="c") + + +def test_multiple_variants() -> None: + @required_args(["a"], ["b"]) + def foo(*, a: str | None = None, b: str | None = None) -> str | None: + return a if a is not None else b + + assert foo(a="foo") == "foo" + assert foo(b="bar") == "bar" + assert foo(a=None) is None + assert foo(b=None) is None + + # TODO: this error message could probably be improved + with pytest.raises( + TypeError, + match=r"Missing required arguments; Expected either \('a'\) or \('b'\) arguments to be given", + ): + foo() + + +def test_multiple_params_multiple_variants() -> None: + @required_args(["a", "b"], ["c"]) + def foo(*, a: str | None = None, b: str | None = None, c: str | None = None) -> str | None: + if a is not None: + return a + if b is not None: + return b + return c + + error_message = r"Missing required arguments; Expected either \('a' and 'b'\) or \('c'\) arguments to be given" + + with pytest.raises(TypeError, match=error_message): + foo(a="foo") + + with pytest.raises(TypeError, match=error_message): + foo(b="bar") + + with pytest.raises(TypeError, match=error_message): + foo() + + assert foo(a=None, b="bar") == "bar" + assert foo(c=None) is None + assert foo(c="foo") == "foo" diff --git a/tests/test_response.py b/tests/test_response.py new file mode 100644 index 0000000..0be8be4 --- /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 hypeman import Hypeman, BaseModel, AsyncHypeman +from hypeman._response import ( + APIResponse, + BaseAPIResponse, + AsyncAPIResponse, + BinaryAPIResponse, + AsyncBinaryAPIResponse, + extract_response_type, +) +from hypeman._streaming import Stream +from hypeman._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: Hypeman) -> 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 hypeman import BaseModel`", + ): + response.parse(to=PydanticModel) + + +@pytest.mark.asyncio +async def test_async_response_parse_mismatched_basemodel(async_client: AsyncHypeman) -> 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 hypeman import BaseModel`", + ): + await response.parse(to=PydanticModel) + + +def test_response_parse_custom_stream(client: Hypeman) -> 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: AsyncHypeman) -> 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: Hypeman) -> 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: AsyncHypeman) -> 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: Hypeman) -> 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: AsyncHypeman) -> 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: Hypeman, 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: AsyncHypeman, 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: Hypeman) -> 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: AsyncHypeman) -> None: + response = AsyncAPIResponse( + raw=httpx.Response(200, content=b"foo", headers={"Content-Type": "application/text"}), + client=async_client, + stream=False, + stream_cls=None, + cast_to=str, + options=FinalRequestOptions.construct(method="get", url="/foo"), + ) + + obj = await response.parse(to=cast(Any, Union[CustomModel, OtherModel])) + assert isinstance(obj, str) + assert obj == "foo" diff --git a/tests/test_streaming.py b/tests/test_streaming.py new file mode 100644 index 0000000..e75c904 --- /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 hypeman import Hypeman, AsyncHypeman +from hypeman._streaming import Stream, AsyncStream, ServerSentEvent + + +@pytest.mark.asyncio +@pytest.mark.parametrize("sync", [True, False], ids=["sync", "async"]) +async def test_basic(sync: bool, client: Hypeman, async_client: AsyncHypeman) -> 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: Hypeman, async_client: AsyncHypeman) -> 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: Hypeman, async_client: AsyncHypeman) -> 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: Hypeman, async_client: AsyncHypeman) -> 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: Hypeman, async_client: AsyncHypeman) -> 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: Hypeman, async_client: AsyncHypeman) -> 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: Hypeman, async_client: AsyncHypeman) -> 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: Hypeman, async_client: AsyncHypeman) -> 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: Hypeman, + async_client: AsyncHypeman, +) -> 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: Hypeman, + async_client: AsyncHypeman, +) -> 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: Hypeman, + async_client: AsyncHypeman, +) -> Iterator[ServerSentEvent] | AsyncIterator[ServerSentEvent]: + if sync: + return Stream(cast_to=object, client=client, response=httpx.Response(200, content=content))._iter_events() + + return AsyncStream( + cast_to=object, client=async_client, response=httpx.Response(200, content=to_aiter(content)) + )._iter_events() diff --git a/tests/test_transform.py b/tests/test_transform.py new file mode 100644 index 0000000..ba0c66a --- /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 hypeman._types import Base64FileInput, omit, not_given +from hypeman._utils import ( + PropertyInfo, + transform as _transform, + parse_datetime, + async_transform as _async_transform, +) +from hypeman._compat import PYDANTIC_V1 +from hypeman._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 0000000..a03dcf6 --- /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 hypeman._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 0000000..ab1e343 --- /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 hypeman import _compat +from hypeman._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 0000000..5f8b17d --- /dev/null +++ b/tests/test_utils/test_path.py @@ -0,0 +1,89 @@ +from __future__ import annotations + +from typing import Any + +import pytest + +from hypeman._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 0000000..12bea09 --- /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 hypeman._utils import LazyProxy + + +class RecursiveLazyProxy(LazyProxy[Any]): + @override + def __load__(self) -> Any: + return self + + def __call__(self, *_args: Any, **_kwds: Any) -> Any: + raise RuntimeError("This should never be called!") + + +def test_recursive_proxy() -> None: + proxy = RecursiveLazyProxy() + assert repr(proxy) == "RecursiveLazyProxy" + assert str(proxy) == "RecursiveLazyProxy" + assert dir(proxy) == [] + assert type(proxy).__name__ == "RecursiveLazyProxy" + assert type(operator.attrgetter("name.foo.bar.baz")(proxy)).__name__ == "RecursiveLazyProxy" + + +def test_isinstance_does_not_error() -> None: + class AlwaysErrorProxy(LazyProxy[Any]): + @override + def __load__(self) -> Any: + raise RuntimeError("Mocking missing dependency") + + proxy = AlwaysErrorProxy() + assert not isinstance(proxy, dict) + assert isinstance(proxy, LazyProxy) diff --git a/tests/test_utils/test_typing.py b/tests/test_utils/test_typing.py new file mode 100644 index 0000000..70a2c32 --- /dev/null +++ b/tests/test_utils/test_typing.py @@ -0,0 +1,73 @@ +from __future__ import annotations + +from typing import Generic, TypeVar, cast + +from hypeman._utils import extract_type_var_from_base + +_T = TypeVar("_T") +_T2 = TypeVar("_T2") +_T3 = TypeVar("_T3") + + +class BaseGeneric(Generic[_T]): ... + + +class SubclassGeneric(BaseGeneric[_T]): ... + + +class BaseGenericMultipleTypeArgs(Generic[_T, _T2, _T3]): ... + + +class SubclassGenericMultipleTypeArgs(BaseGenericMultipleTypeArgs[_T, _T2, _T3]): ... + + +class SubclassDifferentOrderGenericMultipleTypeArgs(BaseGenericMultipleTypeArgs[_T2, _T, _T3]): ... + + +def test_extract_type_var() -> None: + assert ( + extract_type_var_from_base( + BaseGeneric[int], + index=0, + generic_bases=cast("tuple[type, ...]", (BaseGeneric,)), + ) + == int + ) + + +def test_extract_type_var_generic_subclass() -> None: + assert ( + extract_type_var_from_base( + SubclassGeneric[int], + index=0, + generic_bases=cast("tuple[type, ...]", (BaseGeneric,)), + ) + == int + ) + + +def test_extract_type_var_multiple() -> None: + typ = BaseGenericMultipleTypeArgs[int, str, None] + + generic_bases = cast("tuple[type, ...]", (BaseGenericMultipleTypeArgs,)) + assert extract_type_var_from_base(typ, index=0, generic_bases=generic_bases) == int + assert extract_type_var_from_base(typ, index=1, generic_bases=generic_bases) == str + assert extract_type_var_from_base(typ, index=2, generic_bases=generic_bases) == type(None) + + +def test_extract_type_var_generic_subclass_multiple() -> None: + typ = SubclassGenericMultipleTypeArgs[int, str, None] + + generic_bases = cast("tuple[type, ...]", (BaseGenericMultipleTypeArgs,)) + assert extract_type_var_from_base(typ, index=0, generic_bases=generic_bases) == int + assert extract_type_var_from_base(typ, index=1, generic_bases=generic_bases) == str + assert extract_type_var_from_base(typ, index=2, generic_bases=generic_bases) == type(None) + + +def test_extract_type_var_generic_subclass_different_ordering_multiple() -> None: + typ = SubclassDifferentOrderGenericMultipleTypeArgs[int, str, None] + + generic_bases = cast("tuple[type, ...]", (BaseGenericMultipleTypeArgs,)) + assert extract_type_var_from_base(typ, index=0, generic_bases=generic_bases) == int + assert extract_type_var_from_base(typ, index=1, generic_bases=generic_bases) == str + assert extract_type_var_from_base(typ, index=2, generic_bases=generic_bases) == type(None) diff --git a/tests/utils.py b/tests/utils.py new file mode 100644 index 0000000..3195b49 --- /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 hypeman._types import Omit, NoneType +from hypeman._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 hypeman._compat import PYDANTIC_V1, field_outer_type, get_model_fields +from hypeman._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)